OpenAI Function Calling: Enhancing .NET Applications with Structured AI Outputs

Summary: This post explores OpenAI’s function calling capability and how to implement it in .NET applications. Learn how to define functions, process structured outputs, and build more reliable AI-powered features that integrate seamlessly with your existing codebase.

Introduction

In June 2023, OpenAI introduced a powerful new capability to their API: function calling. This feature represents a significant advancement in how developers can interact with large language models (LLMs), enabling more structured and reliable outputs that integrate seamlessly with application code.

Function calling allows developers to describe functions to the model, which can then intelligently decide when to call these functions based on user input. The model generates JSON that adheres to the function signature, making it straightforward to parse and execute the appropriate actions in your application.

For .NET developers, this capability opens up exciting possibilities for building more robust AI-powered applications. In this post, we’ll explore how to implement OpenAI function calling in .NET applications, covering everything from basic implementation to advanced patterns and best practices.

Understanding Function Calling

Before diving into implementation, let’s understand what function calling is and why it’s so valuable.

What is Function Calling?

Function calling is a capability that allows you to describe functions to the model, and the model will intelligently choose to output a JSON object containing arguments to call those functions. This creates a structured way for the model to return specific types of information or trigger specific actions in your application.

For example, instead of parsing free-form text to extract weather information, you can define a getWeather function that takes parameters like location and unit. When a user asks about the weather, the model will recognize this intent and return a structured JSON object with the appropriate parameters.

Why is Function Calling Valuable?

Function calling solves several key challenges in AI application development:

  1. Structured outputs: Get consistent, parseable responses instead of free-form text
  2. Reduced hallucinations: The model is constrained to return valid parameters for your functions
  3. Seamless integration: Connect AI capabilities directly to your application’s existing functions
  4. Improved user experience: Handle complex user requests more reliably
  5. Simplified development: Reduce the need for complex prompt engineering and output parsing

Setting Up Your .NET Environment

Let’s start by setting up a .NET project with the necessary packages to work with the Azure OpenAI Service or OpenAI API.

Creating a New Project

bash

dotnet new console -n OpenAiFunctionCalling
cd OpenAiFunctionCalling

Adding Required Packages

For Azure OpenAI Service:

bash

dotnet add package Azure.AI.OpenAI

For OpenAI API:

bash

dotnet add package OpenAI

Basic Function Calling Implementation

Let’s implement a basic example of function calling using the Azure.AI.OpenAI package.

Defining Functions

First, we need to define the functions that we want the model to be able to call:

csharp

using Azure;
using Azure.AI.OpenAI;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        // Initialize the OpenAI client
        string endpoint = "https://your-resource-name.openai.azure.com/";
        string key = "your-api-key";
        string deploymentName = "gpt-4"; // or gpt-35-turbo

        OpenAIClient client = new OpenAIClient(new Uri(endpoint ), new AzureKeyCredential(key));

        // Define the functions
        var functions = new List<FunctionDefinition>
        {
            new FunctionDefinition
            {
                Name = "get_weather",
                Description = "Get the current weather in a given location",
                Parameters = BinaryData.FromObjectAsJson(
                    new
                    {
                        type = "object",
                        properties = new
                        {
                            location = new
                            {
                                type = "string",
                                description = "The city and state, e.g. San Francisco, CA"
                            },
                            unit = new
                            {
                                type = "string",
                                @enum = new[] { "celsius", "fahrenheit" },
                                description = "The temperature unit to use"
                            }
                        },
                        required = new[] { "location" }
                    },
                    new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
            }
        };

        // Create chat completion options
        var chatCompletionsOptions = new ChatCompletionsOptions
        {
            DeploymentName = deploymentName,
            Messages =
            {
                new ChatMessage(ChatRole.System, "You are a helpful assistant."),
                new ChatMessage(ChatRole.User, "What's the weather like in Boston?")
            },
            Functions = functions,
            Temperature = 0.7f,
            MaxTokens = 800
        };

        // Get the response
        var response = await client.GetChatCompletionsAsync(chatCompletionsOptions);
        var responseMessage = response.Value.Choices[0].Message;

        // Check if the model wants to call a function
        if (responseMessage.FunctionCall != null)
        {
            Console.WriteLine($"Function to call: {responseMessage.FunctionCall.Name}");
            Console.WriteLine($"Arguments: {responseMessage.FunctionCall.Arguments}");

            // Parse the arguments
            var arguments = JsonDocument.Parse(responseMessage.FunctionCall.Arguments).RootElement;
            string location = arguments.GetProperty("location").GetString();
            string unit = arguments.TryGetProperty("unit", out var unitProperty) 
                ? unitProperty.GetString() 
                : "celsius";

            Console.WriteLine($"Parsed location: {location}");
            Console.WriteLine($"Parsed unit: {unit}");

            // Here you would call your actual weather service
            string weatherResult = GetWeather(location, unit);

            // Add the function result to the conversation
            chatCompletionsOptions.Messages.Add(new ChatMessage(ChatRole.Function, weatherResult, responseMessage.FunctionCall.Name));
            chatCompletionsOptions.Messages.Add(new ChatMessage(ChatRole.Assistant, responseMessage.Content, functionCall: responseMessage.FunctionCall));

            // Get the final response
            var finalResponse = await client.GetChatCompletionsAsync(chatCompletionsOptions);
            Console.WriteLine($"Final response: {finalResponse.Value.Choices[0].Message.Content}");
        }
        else
        {
            Console.WriteLine($"Response: {responseMessage.Content}");
        }
    }

    static string GetWeather(string location, string unit)
    {
        // In a real application, you would call a weather API here
        // This is a mock implementation
        return JsonSerializer.Serialize(new
        {
            location,
            temperature = unit == "celsius" ? 22 : 72,
            unit,
            condition = "sunny"
        });
    }
}

Understanding the Code

Let’s break down what’s happening in this code:

  1. We initialize the Azure OpenAI client with our endpoint and API key.
  2. We define a get_weather function with parameters for location and unit.
  3. We create a chat completion request with a user message asking about the weather.
  4. We check if the model’s response includes a function call.
  5. If it does, we parse the arguments and call our weather function.
  6. We add the function result back to the conversation and get a final response.

Running the Example

When you run this code with a query like “What’s the weather like in Boston?”, the model will recognize that it should call the get_weather function with “Boston” as the location. It will return a structured JSON object that you can parse and use to call your actual weather service.

Advanced Function Calling Patterns

Now that we understand the basics, let’s explore some more advanced patterns for using function calling in .NET applications.

Multiple Functions

In real applications, you’ll likely have multiple functions that the model can choose from. Let’s expand our example:

csharp

var functions = new List<FunctionDefinition>
{
    new FunctionDefinition
    {
        Name = "get_weather",
        Description = "Get the current weather in a given location",
        Parameters = BinaryData.FromObjectAsJson(
            new
            {
                type = "object",
                properties = new
                {
                    location = new
                    {
                        type = "string",
                        description = "The city and state, e.g. San Francisco, CA"
                    },
                    unit = new
                    {
                        type = "string",
                        @enum = new[] { "celsius", "fahrenheit" },
                        description = "The temperature unit to use"
                    }
                },
                required = new[] { "location" }
            },
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
    },
    new FunctionDefinition
    {
        Name = "get_restaurant_recommendations",
        Description = "Get restaurant recommendations for a location",
        Parameters = BinaryData.FromObjectAsJson(
            new
            {
                type = "object",
                properties = new
                {
                    location = new
                    {
                        type = "string",
                        description = "The city and state, e.g. San Francisco, CA"
                    },
                    cuisine = new
                    {
                        type = "string",
                        description = "Type of cuisine, e.g. Italian, Chinese, etc."
                    },
                    price_range = new
                    {
                        type = "string",
                        @enum = new[] { "cheap", "moderate", "expensive" },
                        description = "Price range for the restaurants"
                    }
                },
                required = new[] { "location" }
            },
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
    }
};

With multiple functions defined, the model will intelligently choose which one to call based on the user’s input. For example, if the user asks “What are some good Italian restaurants in New York?”, the model will call the get_restaurant_recommendations function with the appropriate parameters.

Function Dispatching

When working with multiple functions, you’ll need a way to dispatch the function calls to the appropriate handlers. Here’s a pattern for doing this:

csharp

async Task<string> DispatchFunctionCall(string functionName, JsonElement arguments)
{
    switch (functionName)
    {
        case "get_weather":
            string location = arguments.GetProperty("location").GetString();
            string unit = arguments.TryGetProperty("unit", out var unitProperty) 
                ? unitProperty.GetString() 
                : "celsius";
            return GetWeather(location, unit);
            
        case "get_restaurant_recommendations":
            string recLocation = arguments.GetProperty("location").GetString();
            string cuisine = arguments.TryGetProperty("cuisine", out var cuisineProperty)
                ? cuisineProperty.GetString()
                : null;
            string priceRange = arguments.TryGetProperty("price_range", out var priceProperty)
                ? priceProperty.GetString()
                : "moderate";
            return GetRestaurantRecommendations(recLocation, cuisine, priceRange);
            
        default:
            throw new ArgumentException($"Unknown function: {functionName}");
    }
}

string GetRestaurantRecommendations(string location, string cuisine, string priceRange)
{
    // In a real application, you would call a restaurant API here
    // This is a mock implementation
    return JsonSerializer.Serialize(new
    {
        location,
        cuisine,
        priceRange,
        restaurants = new[]
        {
            new { name = "Restaurant A", rating = 4.5 },
            new { name = "Restaurant B", rating = 4.2 },
            new { name = "Restaurant C", rating = 4.8 }
        }
    });
}

Handling Complex Parameter Types

Function calling supports complex parameter types, including nested objects and arrays. Here’s an example of a function with more complex parameters:

csharp

new FunctionDefinition
{
    Name = "create_itinerary",
    Description = "Create a travel itinerary based on user preferences",
    Parameters = BinaryData.FromObjectAsJson(
        new
        {
            type = "object",
            properties = new
            {
                destination = new
                {
                    type = "string",
                    description = "The travel destination"
                },
                start_date = new
                {
                    type = "string",
                    description = "The start date of the trip (YYYY-MM-DD)"
                },
                end_date = new
                {
                    type = "string",
                    description = "The end date of the trip (YYYY-MM-DD)"
                },
                preferences = new
                {
                    type = "object",
                    properties = new
                    {
                        activities = new
                        {
                            type = "array",
                            items = new
                            {
                                type = "string",
                                @enum = new[] { "sightseeing", "museums", "food", "shopping", "nature", "relaxation" }
                            },
                            description = "Preferred activities during the trip"
                        },
                        accommodation_type = new
                        {
                            type = "string",
                            @enum = new[] { "hotel", "hostel", "apartment", "resort" },
                            description = "Preferred type of accommodation"
                        },
                        budget = new
                        {
                            type = "string",
                            @enum = new[] { "budget", "moderate", "luxury" },
                            description = "Budget level for the trip"
                        }
                    }
                }
            },
            required = new[] { "destination", "start_date", "end_date" }
        },
        new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
}