Generating Structured Code Using Azure, OpenAI and .NET

Introduction

This will be the first post on AI that I write, which is really a shame, as it's such an interesting and vast topic; you should expect more in the future! This time I'm going to talk about code generation from inside .NET using OpenAI and it's Azure integration, in a structured way.

As you know, code generation is one of the typical usages for Large Language Models (LLMs); it is part of what is called generative AI. It essentially works out of the box, but we can tweak it to be more useful. In this post I’ll show how we can get multiple files in response to a prompt.

We will use Azure, but all the concepts are really from OpenAI. Let's start from the beginning.

Setting Up OpenAI in Azure

From the Azure portal, start by creating a new resource of type Microsoft Foundry (it supports OpenAI and others):

QIf you need more info on this operation, please have a look here. You can now see it in the dashboard page:

The endpoint name will be shown at the top left (ending in -eastus, because I picked the US East region) and the full URL will be something like "https://XXXXXXXXXX-eastus.cognitiveservices.azure.com", which you'll need in a second.

Now go to the Foundry portal by clicking on the button at the bottom:

Get a copy of that API key, as you'll need it later.

Now you need to deploy a model, in my case, I picked gpt-4.1 and named it "gpt-4.1" (surprise, surprise!); this will be the deployment name. You can deploy multiple models, of different types.

Now, we have an OpenAI endpoint and a model deployed. Feel free to deploy more models, just pay attention that not all are available on every region, and that there may be costs involved.

Chatting with OpenAI

After we have our AI endpoint set up in Azure, let's look at the code. We will need the Azure.AI.OpenAI NuGet package installed. This package references the OpenAI NuGet package, and what we are going to use is essentially from it, but we will be using some Azure-specific APIs. We will be using the OpenAI ChatClient class to make the requests.

Let's create a holder record (or whatever type you want, really) to store our settings, maybe to be read from appsettings.json:

public record AzureAIOptions
{
//the URL for the endpoint, see the previous section public required Uri Endpoint { get; init; }
//the API key, which you can get public required string ApiKey { get; init; }
//what deployment name to use, in the previous section we called it "gpt-4.1" public required string Deployment { get; init; } }

We will read it into a class instance from appsettings.json, from a section named "AzureAI":

{
"AzureAI": {
"Endpoint": "https://XXXXXXXXXX-eastus.cognitiveservices.azure.com",
"ApiKey": "xxxxxxxx",
"Deployment": "gpt-4.1"
}
}

The code to read it in a console app is like this: 

var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
    .Build();

var options = configuration
    .GetSection("AzureAI")
    .Get<AzureAIOptions>();

We can then instantiate our Azure client and retrieve the OpenAI chat client: 

AzureOpenAIClient azureClient = new(options!.Endpoint, new AzureKeyCredential(options.ApiKey));
var chatClient = azureClient.GetChatClient(options.Deployment);

And finally, run a prompt against it, from our newly obtained ChatClient (from OpenAI) instance: 

var completion = await chatClient.CompleteChatAsync(
    messages: [
        ChatMessage.CreateSystemMessage("<system prompt, giving context and general instructions>"),
        ChatMessage.CreateUserMessage("<user prompt, what to do>")
    ]);

You can find the source code for these types and methods at the .NET OpenAI repo.

Now, ChatMessage.CreateSystemMessage is for a system prompt, think of it as general instructions and guidelines, role, and constraints for the AI (the how/why), and ChatMessage.CreateUserMessage is for the actual user prompt, what you want to generate (the what). You do not need to pass anything but a user message (the prompt), but you're likely to get better results if you do. There are other message kinds too: assistanttool, and function, but I won't get into them now.

To get the results:

var resultText = completion.Value.Content.First().Text;

The problem with this is that you get the response, generated code, etc, all mixed up on the same string. Let’s see how we can improve it.

Generating Structured Code

This is all fine if we want to run prompts that only include a single file, but what if we need more than that? Say multiple files, of different types? Then we must tell OpenAI what is the format to return the response in, by means of a schema for the response.

If we use a system prompt like this:

Return ONLY valid JSON (no markdown).
Schema: { "files": [ { "path": "string", "description": "string", "content": "string" } ] }

We are telling it how we want to get the response, its schema, and OpenAI will know how to interpret it. In this case, as you can see, we expect to get back something like this:

{
  "files": [
    {
      "path": "Progam.cs",
      "description": "Main program file",
      "content": "<content of Program.cs>"
    },
    {
      "path": "Project.csproj",
      "description": "C# project file",
      "content": "<content of Project.csproj>"
    },
...
] }

Which we will turn into records by means of these helper methods:

public sealed record FileBundle(List<GeneratedFile> Files);
public sealed record GeneratedFile(string Path, string Description, string Content);

We will be using System.Text.Json for the deserialisation:

FileBundle ParseBundle(string json)
{
    return JsonSerializer.Deserialize<FileBundle>(json, new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    }) ?? throw new Exception("Invalid JSON bundle.");
}

If we want, we can output the results to the filesystem:

void WriteBundle(string outputRoot, FileBundle bundle)
{
    Directory.CreateDirectory(outputRoot);

    foreach (var file in bundle.Files)
    {
        var rel = file.Path.Replace('\\', '/').TrimStart('/');
        if (rel.Contains(".."))
            throw new Exception($"Refusing path traversal: {file.Path}");

        var filename = Path.Combine(outputRoot, rel);
        Directory.CreateDirectory(Path.GetDirectoryName(filename)!);
        File.WriteAllText(filename, file.Content, System.Text.Encoding.UTF8);
    }
}

To put it all together, picking on the previous example:

var completion = await chatClient.CompleteChatAsync(
    messages: [
        ChatMessage.CreateSystemMessage("Return ONLY valid JSON (no markdown). Schema: { \"files\": [ { \"path\": \"string\", \"description\": \"string\", \"content\": \"string\" } ] }. Use .NET 10 and C# 14. Do not use top level statements. Ensure code compiles and has no warnings. Include the project file in the results."),
        ChatMessage.CreateUserMessage("Generate a TODO minimal web API with in-memory storage")
    ]);

var resultText = completion.Value.Content.First().Text; var bundle = ParseBundle(resultText); WriteBundle(outputRoot: "Generated", bundle);

And here it is! You get all the generated files written down to the filesystem, to your chosen directory.

Conclusion

As you can see, it's easy to produce files using OpenAI. This is, I believe, a better approach to having a response that contains everything in an unstructured way.

If you want to see more examples, please have a look at this page.

I will be posting more on this subject, so stay tuned!

Comments

Popular posts from this blog

Modern Mapping with EF Core

C# Magical Syntax

.NET 10 Validation