Showing posts with label C#. Show all posts
Showing posts with label C#. Show all posts

Saturday, September 19, 2026

Microsoft Agent Framework: Prompt Agents in Microsoft Foundry with .NET

In this post, let's have a look at prompt agents in Microsoft Foundry, and how we can create and run them with Microsoft Agent Framework.

Some time back I blogged about Getting Started with Microsoft Agent Framework 1.0.0 in .NET. There, the agent was created in code, it lived inside the process, and when the app stopped, so did the agent. Foundry was only providing the model. That's usually called an ephemeral agent.

A prompt agent is the other option and it's a Foundry feature rather than an Agent Framework one. The agent definition, that's the model, the instructions and the tool schemas, is stored in Foundry as a versioned resource. Your code doesn't own it anymore, it just runs it. It shows up in the Foundry portal, it has a playground, every update creates a new version, and any application can run it by name. Agent Framework is how we run it, which is the rest of this post.

One thing that stays the same is tools. Foundry stores only the tool schemas. Your application still executes the tools.

Setup

We need the following packages (latest as of today, it will change).
<PackageReference Include="Azure.AI.Projects.Agents" Version="3.0.0-beta.2" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.22.0-preview.260918.1" />

Creating the agent

The tools are the same three methods from the getting started post, plain C# methods with [Description] attributes.
// ResponseTool, used to declare the agent's function tools, is still experimental
#pragma warning disable OPENAI001

// Agent administration lives under the project, so this needs the project endpoint,
// not the resource one the earlier samples use
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL")!;
const string agentName = "weekend-planner-prompt";

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIProjectClient projectClient = new(new Uri(endpoint), credential);

AIFunction[] tools =
[
    AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
    AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
    AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
];

// Foundry stores the tool schemas, this app executes the tools
DeclarativeAgentDefinition definition = new(deploymentName)
{
    Instructions = """
        You help users plan their weekends and choose the best activities for the given weather.
        If an activity would be unpleasant in weather, don't suggest it.
        Include date of the weekend in response.
        """
};

foreach (AIFunction tool in tools)
{
    definition.Tools.Add(ResponseTool.CreateFunctionTool(
        tool.Name,
        BinaryData.FromString(tool.JsonSchema.GetRawText()),
        strictModeEnabled: false,
        functionDescription: tool.Description));
}

// Create the agent in Foundry, every run creates a new version of it
ProjectsAgentVersion agentVersion = 
await projectClient.AgentAdministrationClient.CreateAgentVersionAsync( agentName, new ProjectsAgentVersionCreationOptions(definition)); Console.WriteLine($"[Agent] Created '{agentVersion.Name}' version '{agentVersion.Version}'.");
Two things to note here. The tool schema comes straight from AIFunction.JsonSchema, so the [Description] attributes we already write for Agent Framework are what ends up stored in Foundry. And ResponseTool is still experimental, so we need to suppress OPENAI001.

Creating and deleting an agent is the one part that isn't Agent Framework. Every path in the docs goes through AgentAdministrationClient from Azure.AI.Projects.Agents, and that's what we use here.

Once this is executed, we can see our Prompt Agent in Microsoft Foundry.
Prompt Agent

Running the agent in Foundry Playground

The agent is in the project now, with a playground, so that is the first thing to try. And it stops at the first tool call and asks you to enter function output as JSON.
Prompt Agent in Playground
That is the whole point of a prompt agent. Foundry has the tool schemas, it does not have the tools, so the playground has nothing to run and asks you to play the part of the application. Paste what our code would have returned, "2026-09-26" for GetCurrentDate, and it carries on and asks for the next one.

Which is a good reminder that the playground is just another client here. To get actual answers, something has to run the tools, and that is what we do next.

Running the agent

And this is the nice part. Running a prompt agent is exactly the same as running the in-code agent, we just hand over the agent version instead of a model and instructions.
string userInput = "What should I do this weekend in Auckland?";

// Run the version that was just created, the tools are passed in from here
AIAgent agent = projectClient.AsAIAgent(agentVersion, tools: [.. tools]);
AgentResponse response = await agent.RunAsync(userInput);

Console.WriteLine();
Console.WriteLine("--- Agent Response ---");
Console.WriteLine(response.ToString());
The instructions aren't in the run call anymore, they are in Foundry. The tools are, because Foundry only knows their schemas.
[Agent] Created 'weekend-planner-prompt' version '1'.
[Tool] Getting current date
[Tool] Getting weather for 'Auckland'.
[Tool] Getting activities for 'Auckland' on '2026-09-26'.
[Tool] Getting activities for 'Auckland' on '2026-09-27'.

--- Agent Response ---
Weekend in Auckland: Saturday 26 Sep 2026 - Sunday 27 Sep 2026.
Forecast: ~18C and rainy, so indoor plans will work better.
// Omitted for brevity

Running an agent someone else created

Because the agent is a resource now, we don't need to be the one who created it. We can ask Foundry for it by name and run the latest version.
// Or resolve an existing agent by name and run its latest version, no instructions needed here
ProjectsAgentRecord agentRecord = 
await projectClient.AgentAdministrationClient.GetAgentAsync(agentName); AIAgent latestAgent = projectClient.AsAIAgent(agentRecord, tools: [.. tools]);
There is no model, no instructions and no version pinned in this code. All it provides is the tool implementations and the input. Someone can update the instructions in the portal, and the next run picks them up without a code change or a deployment.

Finally, since every run of the sample creates a version, let's clean up after ourselves.
// Delete the agent, remove this to keep it in the project
await projectClient.AgentAdministrationClient.DeleteAgentAsync(agentName);

So which one to use

If the agent ships with your application and nothing else needs to call it, creating it in code is simpler, there is nothing to create, version or delete. If you want the agent to be a thing in its own right, something with versions, a playground, and other applications running it by name, then a prompt agent is what you want.

The complete sample is here.

More read:
   Microsoft Agent Framework: Microsoft Foundry

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, September 15, 2026

C#: Using TimeProvider and FakeTimeProvider

In this post, let's have a look at TimeProvider in C#. 

I have seen this abstract class around for a while (it was introduced with .NET 8), but I haven't really used it up until very recently. I just love it.

Before TimeProvider, whenever I needed to control time in tests, I had to write my own abstraction, something like IDateTimeProvider wrapping DateTime.UtcNow. TimeProvider is the built-in abstraction for that. It's in the System namespace and exposes members like GetUtcNow(), GetLocalNow(), GetTimestamp() and CreateTimer().

To fake the implementation like in tests, we can install the following NuGet package.
dotnet add package Microsoft.Extensions.TimeProvider.Testing
Now let's see the code.
using Microsoft.Extensions.Time.Testing;

TimeProvider timeProvider = TimeProvider.System;
Console.WriteLine($"System date: {timeProvider.GetUtcNow():yyyy-MM-dd}");

FakeTimeProvider fakeTimeProvider = new(DateTimeOffset.Parse("2500-01-01Z")); 
Console.WriteLine($"Fake date: {fakeTimeProvider.GetUtcNow():yyyy-MM-dd}");
And the output:
System date: 2026-09-15
Fake date: 2500-01-01
TimeProvider.System is the concrete implementation that gives the actual system time. FakeTimeProvider derives from TimeProvider, so it can be passed anywhere a TimeProvider is expected, and it returns whatever time we set it to.

So in the application code, I can depend on TimeProvider and register TimeProvider.System, and in tests pass in a FakeTimeProvider . FakeTimeProvider also has methods like Advance(TimeSpan) and SetUtcNow(DateTimeOffset) to move time forward, which also fires any timers and completes any Task.Delay created with that provider.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Friday, September 11, 2026

EF Core 11.0: What's New with Migrations

.NET 11 Release Candidate 1 is out, and that means we are getting really close to the finish line. Next month we should get RC2, and then in November the GA release at .NET Conf 2026. You can read more about RC1 here: Announcing .NET 11 Release Candidate 1.

In this post, let's have a look at some of the nice improvements that are coming to EF Core 11.0 Migrations. There are quite a few of them, but I am going to focus on the following three.
  • Excluding foreign key constraints from migrations
  • Create and apply migrations in one step
  • Connection and offline options for migrations remove
First, the project file. I am using the latest RC as of today, it will change.
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0-rc.1.26425.128" />
  </ItemGroup>

</Project>

And make sure you have the matching version of the dotnet-ef tool.
dotnet tool update -g dotnet-ef --version 11.0.0-rc.1.26425.128

Excluding foreign key constraints from migrations


Say you are working with a legacy database that doesn't have foreign key constraints, or you have some data synchronization process where referential integrity constraints get in the way of the synchronization order. You still want EF Core to know about the relationship, but you don't want migrations to create the constraint in the database.

With EF Core 11.0, we now have ExcludeForeignKeyFromMigrations() for exactly that. Consider the following entities.
public class Customer
{
    public int Id { get; set; }

    public string Name { get; set; }
}

public class Order
{
    public int Id { get; set; }

    public string OrderNumber { get; set; }

    public Customer Customer { get; set; }

    public int CustomerId { get; set; }
}
And here is the DbContext. The relationship is configured as usual, and we just call ExcludeForeignKeyFromMigrations() at the end.
public class MyDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    public DbSet<Order> Orders { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"<ConnectionString>")
            .LogTo(Console.WriteLine, LogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasOne(x => x.Customer)
            .WithMany()
            .HasForeignKey(x => x.CustomerId)
            .ExcludeForeignKeyFromMigrations();
    }
}
When we add a migration, the Orders table no longer has a ForeignKey constraint. Note that an index is still created on the foreign key column.
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateTable(
        name: "Customers",
        columns: table => new
        {
            Id = table.Column<int>(type: "int", nullable: false)
                .Annotation("SqlServer:Identity", "1, 1"),
            Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Customers", x => x.Id);
        });

    migrationBuilder.CreateTable(
        name: "Orders",
        columns: table => new
        {
            Id = table.Column<int>(type: "int", nullable: false)
                .Annotation("SqlServer:Identity", "1, 1"),
            OrderNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
            CustomerId = table.Column<int>(type: "int", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Orders", x => x.Id);
        });

    migrationBuilder.CreateIndex(
        name: "IX_Orders_CustomerId",
        table: "Orders",
        column: "CustomerId");
}
The relationship itself is fully supported in EF Core for queries, change tracking etc. Only the constraint in the database is suppressed.
using var context = new MyDbContext();

Customer customer = new() 
{
Name = "John Doe"
}; await context.Customers.AddAsync(customer); await context.Orders.AddRangeAsync( new Order { OrderNumber = "ORD-001", Customer = customer }, new Order { OrderNumber = "ORD-002", Customer = customer }); await context.SaveChangesAsync(); foreach (Order order in await context.Orders .Include(x => x.Customer) .ToListAsync()) { Console.WriteLine($"Order: '{order.OrderNumber}', Customer: '{order.Customer.Name}'."); } // Query //SELECT[o].[Id], [o].[CustomerId], [o].[OrderNumber], [c].[Id], [c].[Name] //FROM[Orders] AS[o] //INNER JOIN[Customers] AS [c] ON[o].[CustomerId] = [c].[Id] // Output // Order: 'ORD-001', Customer: 'John Doe'. // Order: 'ORD-002', Customer: 'John Doe'

Create and apply migrations in one step


Up until now, create and apply migrations has always been two commands: dotnet ef migrations add followed by dotnet ef database update. With EF Core 11.0, dotnet ef database update has a new --add option that scaffolds the migration, compiles it at runtime using Roslyn, and applies it to the database, all in one go.
dotnet ef database update InitialCreate --add
And the output (trimmed):
Build started...
Build succeeded.
Creating and applying migration 'InitialCreate'.
...
Applying migration '20260911004844_InitialCreate'.
...
      CREATE TABLE [Customers] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NOT NULL,
          CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
      );
...
      CREATE TABLE [Orders] (
          [Id] int NOT NULL IDENTITY,
          [OrderNumber] nvarchar(max) NOT NULL,
          [CustomerId] int NOT NULL,
          CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
      );
...
      CREATE INDEX [IX_Orders_CustomerId] ON [Orders] ([CustomerId]);
...
Migration '20260911004844_InitialCreate' was successfully created and applied.
The migration files are still written to disk, so you can commit them to source control as usual. All the options you would use with dotnet ef migrations add are supported as well.

If you are using the Package Manager Console, you can use the -Add parameter.
Update-Database -Migration InitialCreate -Add

Connection and offline options for migrations remove


dotnet ef migrations remove and dotnet ef database drop now accept a --connection option, so we can pass in the connection string directly instead of relying on whatever is configured in the DbContext.
# Remove migration using a specific connection
dotnet ef migrations remove --connection "<Connection String>"

# Drop a specific database using a connection string
dotnet ef database drop --connection "<Connection String>" --force
And migrations remove has a new --offline option. Previously, migrations remove always connected to the database to check whether the migration had been applied. With --offline, that check is skipped entirely, which is useful when the database isn't reachable or when you know the migration hasn't been applied.
dotnet ef migrations add InitialCreate
dotnet ef migrations remove --offline
And the output:
Build started...
Build succeeded.
Removing migration '20260911004851_AddOrderDate'.
Reverting the model snapshot.
Done.
Note that --offline and --force can't be used together. --force reverts the migration if it has been applied, and to know that, it needs a database connection.
# The --offline and --force options cannot be used together.
dotnet ef migrations remove --offline --force
In the Package Manager Console, use the -Connection and -Offline parameters.
Remove-Migration -Connection "<Connection String>"
Remove-Migration -Offline
Drop-Database -Connection "<Connection String>" -Force
There are more migration improvements in EF Core 11.0 like the latest migration ID being recorded in the model snapshot, a configuration file for dotnet ef, -NoBuild for PMC commands, and wildcard context support. Do check them out.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, August 20, 2026

Use of AZURE_TOKEN_CREDENTIALS in Azure.Identity

In this post, let's have a look at the AZURE_TOKEN_CREDENTIALS environment variable in Azure.Identity. I only came across it recently, and it has removed something that used to annoy me every single day.

We all know why we should be authenticating to Azure services with Microsoft Entra ID rather than with keys, connection strings or passwords. 
TokenCredential credential = new DefaultAzureCredential();
When I am working on something locally, what I used to do is grant my own Azure account access to the development resource, sign in through Visual Studio or the Azure CLI, and let DefaultAzureCredential pick that identity up. 

But this can be a pain when running locally. DefaultAzureCredential is a chain. It attempts credentials one after the other, in order, and the first one that returns a token wins. The deployed service credentials (EnvironmentCredential, WorkloadIdentityCredential, ManagedIdentityCredential) come first and then the developer tool credentials like VisualStudioCredential, AzureCliCredential, AzureDeveloperCliCredential etc.

In our local machine, deployed credentials obviously won't work, but still it will get tried. ManagedIdentityCredential is the worst among them, because it actually goes out to the Azure Instance Metadata Service (IMDS) endpoint at 169.254.169.254 and only gives up once that socket fails or times out. So every run and every debug session pays for these guaranteed failures.

Let's look by an example. I am using latest Azure.Identity package as of today.
<PackageReference Include="Azure.Identity" Version="1.21.0" />
Here is the basic code.
using Azure.Core;
using Azure.Core.Diagnostics;
using Azure.Identity;
using System.Diagnostics.Tracing;

using AzureEventSourceListener listener = new((eventArgs, message) =>
{
    // Helper method to print out the provider
    PrintProvider(eventArgs, message);
}, EventLevel.Informational);

TokenCredential credential = new DefaultAzureCredential();

// Omitted: some call that exercises the credentials

Console.WriteLine("\nDone");
When I run this, I can see something like below:
Trying   : DefaultAzureCredential
Trying   : EnvironmentCredential
Trying   : WorkloadIdentityCredential
Trying   : ManagedIdentityCredential
Trying   : VisualStudioCredential
Selected : VisualStudioCredential

Done
You can see all the credentials it's trying and it takes unnecessary time. For a long time my workaround was to branch on the environment, and when running in Development, switch off the credentials that were never going to work anyway, using the Exclude prefixed properties on DefaultAzureCredentialOptions, something like below.
TokenCredential credential = new DefaultAzureCredential();

// Environment check, could be ASPNETCORE_ENVIRONMENT, AZURE_FUNCTIONS_ENVIRONMENT etc.
bool isDevelopment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Development";
if (isDevelopment)
{
    credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
    {
        ExcludeEnvironmentCredential = true,
        ExcludeWorkloadIdentityCredential = true,
        ExcludeManagedIdentityCredential = true,
    });
}
With this, it will print something like this.
Trying   : DefaultAzureCredential
Trying   : VisualStudioCredential
Selected : VisualStudioCredential

Done
It works, but it's a pain to do this in each project I am working on.

Since Azure.Identity 1.14.0, all of the above can be replaced with an environment variable. Set AZURE_TOKEN_CREDENTIALS to dev and the chain drops every deployed service credential, leaving only the developer tool ones.
{
  "profiles": {
    "ConsoleApp1": {
      "commandName": "Project",
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Development",
        "AZURE_TOKEN_CREDENTIALS": "dev"
      }
    }
  }
}

And the code then goes back to being the single line.
TokenCredential credential = new DefaultAzureCredential();
Exactly the same outcome as the Exclude block, with no branching, nothing to maintain. It is purely configuration.

One thing to keep in mind is that if AZURE_TOKEN_CREDENTIALS isn't set at all, DefaultAzureCredential quietly falls back to the full chain. So if someone clones the repo without the launch profile, or the variable gets dropped somewhere along the way, you are silently back to where you started. If you would rather fail fast, since Azure.Identity 1.16.0 there is a constructor overload that takes the environment variable name and requires it to be set to a valid value.
TokenCredential credential =
    new DefaultAzureCredential(DefaultAzureCredential.DefaultEnvironmentVariableName);
Now if it's missing, you get a clear error instead of a silent fallback.
Unhandled exception. 
System.InvalidOperationException: Environment variable 'AZURE_TOKEN_CREDENTIALS' is not set or is empty.
And if you typo the value, it throws as well and lists every value it accepts, which is quite handy.

There is a prod value too, which does the opposite and keeps only the deployed service credentials. And from Azure.Identity 1.15.0 onwards you can go further and name a single credential, which reduces the chain to just that one. The comparison is case insensitive.
"AZURE_TOKEN_CREDENTIALS": "VisualStudioCredential"
A small feature, but if you have been quietly copying that Exclude block around for years like I have, it is a life saver.

Happy Coding.

Regards,
Jaliya

Thursday, August 13, 2026

Using Azure AI Foundry resource for .NET Azure.AI.TextAnalytics and Azure.AI.Translation.Text Clients

In this post, let's see how we can move .NET clients from consuming Azure AI Language and Azure AI Translator resource onto a single Azure AI Foundry resource.

I had some .NET code using TextAnalyticsClient (Azure.AI.TextAnalytics) and TextTranslationClient (Azure.AI.Translation.Text). These were pointing to two different Azure AI resources.
// API Kind: TextAnalytics
https://lang-demo-service-001.cognitiveservices.azure.com

// API Kind: TextTranslation
https://trsl-demo-service-001.cognitiveservices.azure.com
These are the NuGet packages (latest as of today, it will change).
<ItemGroup>
  <PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
  <PackageReference Include="Azure.AI.Translation.Text" Version="2.0.0" />
  <PackageReference Include="Azure.Identity" Version="1.21.0" />
</ItemGroup>
And this is the existing code.
using Azure;
using Azure.AI.TextAnalytics;
using Azure.AI.Translation.Text;
using Azure.Identity;

DefaultAzureCredential credentials = new DefaultAzureCredential();

// API Kind: TextAnalytics
string languageEndpoint = "https://lang-demo-service-001.cognitiveservices.azure.com";

TextAnalyticsClient textAnalyticsClient = new(new Uri(languageEndpoint), credentials);
Azure.AI.TextAnalytics.DetectedLanguage response =
    await textAnalyticsClient.DetectLanguageAsync("Hello");
Console.WriteLine(response.Name);

// API Kind: TextTranslation
string translatorEndpoint = "https://trsl-demo-service-001.cognitiveservices.azure.com";

TextTranslationClient textTranslationClient = new(credentials, new Uri(translatorEndpoint));
Response<IReadOnlyList<TranslatedTextItem>> translateTextResponse =
    await textTranslationClient.TranslateAsync("fr", "Hello", "en");

TranslatedTextItem? translatedTextItem = translateTextResponse.Value.FirstOrDefault();
Console.WriteLine(translatedTextItem?.Translations.FirstOrDefault()?.Text);
I didn't want to maintain more services when we can have single Microsoft Foundry resource (API Kind: AIServices). It's a multi-service resource, it bundles Language, Translator, Speech, Vision and more, so it's one resource, one credential and one endpoint in configuration.

I thought that should be a simple endpoint change.
string apiEndpoint = "https://aif-demo-service-001.services.ai.azure.com";

DefaultAzureCredential credentials = new DefaultAzureCredential();

// Language
TextAnalyticsClient textAnalyticsClient = new(new Uri(apiEndpoint), credentials);

// Translation
TextTranslationClient textTranslationClient = new(credentials, new Uri(apiEndpoint));

// Omitted for brevity
TextAnalyticsClient seemed to work, it wrote out English. But TextTranslationClient didn't.
English

Unhandled exception. Azure.RequestFailedException: Resource not found Status: 404(Resource Not Found) ErrorCode: 404 Content: { "error":{ "code":"404","message": "Resource not found"} } Headers: apim - request - id: REDACTED Strict-Transport-Security: REDACTED X-Content-Type-Options: REDACTED Date: Tue, 11 Aug 2026 08:57:57 GMT Content-Length: 56 Content - Type: application / json at Azure.AI.Translation.Text.ClientPipelineExtensions.ProcessMessageAsync(HttpPipeline pipeline, HttpMessage message, RequestContext context) at Azure.AI.Translation.Text.TextTranslationClient.TranslateAsync(RequestContent content, String clientTraceId, RequestContext context) at Azure.AI.Translation.Text.TextTranslationClient.TranslateAsync(IEnumerable`1 inputs, CancellationToken cancellationToken) at Program.< Main >$(String[] args) in C: \Users\Jaliya\Desktop\ConsoleApp1\ConsoleApp1\Program.cs:line 45 at Program.<Main>(String[] args)
My first thought was that Translator simply isn't served on the services.ai.azure.com endpoint. That's not it, a raw request against the very same endpoint works.
$token = az account get-access-token `
    --resource https://cognitiveservices.azure.com `
    --query accessToken -o tsv

curl -X POST "https://aif-demo-service-001.services.ai.azure.com/translator/text/v3.0/translate?api-version=3.0&to=fr" `
     -H "Authorization: Bearer $token" `
     -H "Content-Type: application/json" `
     -d "[{'Text':'Hello'}]"

# 200 OK
#[
#    {
#        "detectedLanguage": {
#            "language": "en",
#            "score": 1.0
#        },
#        "translations": [
#            {
#                "text": "Bonjour",
#                "to": "fr"
#            }
#        ]
#    }
#]     
So the host is fine, and note the route, Translator lives under /translator/text. May be something fishy with the SDK.

Looking at the source of Azure.AI.Translation.Text, this is how it decides whether to add that route prefix.
private const string PLATFORM_HOST = "cognitiveservices";

internal static bool IsPlatformHost(this Uri uri)
{
    return uri.Host?.Contains(PLATFORM_HOST) == true;
}
Which is then used in the constructor.
private const string PLATFORM_PATH = "/translator/text";

if (endpoint.IsPlatformHost())
{
    this._endpoint = new Uri(endpoint, PLATFORM_PATH);
}
There it is. The prefix is added only when the endpoint host contains the literal string cognitiveservices.

My old endpoint was trsl-demo-service-001.cognitiveservices.azure.com, which contains it, so the SDK built /translator/text/translate. The new one is aif-demo-service-001.services.ai.azure.com, which doesn't, so the SDK treated it like a global endpoint and posted to /translate. Hence the 404.

The fix is to pass the prefix ourselves when constructing the Uri.
using Azure;
using Azure.AI.Translation.Text;
using Azure.Identity;

string apiEndpoint = "https://aif-inf-sl-tenant1-dev-001.services.ai.azure.com";

DefaultAzureCredential credentials = new DefaultAzureCredential();

// Translation
TextTranslationClient textTranslationClient = new(credentials, new Uri($"{apiEndpoint}/translator/text"));
Response<IReadOnlyList<TranslatedTextItem>> translateTextResponse =
    await textTranslationClient.TranslateAsync("fr", "Hello", "en");

TranslatedTextItem? translatedTextItem = translateTextResponse.Value.FirstOrDefault();
Console.WriteLine(translatedTextItem?.Translations.FirstOrDefault()?.Text);
And now both work off the single endpoint.
English
Bonjour
One thing I wanted to confirm, what happens if a future version of the SDK starts recognizing services.ai.azure.com? Would it add the prefix on top of mine and put me back at a 404? No, because PLATFORM_PATH is rooted, so new Uri(endpoint, PLATFORM_PATH) replaces the path instead of appending to it.
Uri uri = new Uri(new Uri("https://aif-demo-service-001.services.ai.azure.com/translator/text"), "/translator/text");
Console.WriteLine(uri.ToString());

// https://aif-demo-service-001.services.ai.azure.com/translator/text
I have raised this with the SDK team as azure-sdk-for-net#61912, so the behavior might change.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Sunday, August 9, 2026

Support for C# 15 Union Types in ASP.NET Core

In this post, let's have a look at how Union types in C# 15 flow through an ASP.NET Core minimal API and how they are described in OpenAPI. This is a follow up to my previous post C# 15: Brand New Union Types, so I will reuse the same Shape union.

Everything here runs on .NET 11 Preview 6 (latest as of today, it will change) and the Microsoft.AspNetCore.OpenApi package for the built in OpenAPI document generation.

Also using latest language features.
<LangVersion>preview</LangVersion>
Here is a single endpoint that returns a Shape.
using Microsoft.AspNetCore.Http.HttpResults;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

WebApplication app = builder.Build();

app.MapOpenApi();

app
    .MapGet("/shapes/{id}", Results<Ok<Shape>, NotFound> (int id) =>
    {
        Dictionary<int, Shape> shapes = new()
        {
            { 1, new Circle(2) },
            { 2, new Rectangle(3, 4) },
            { 3, new Triangle(4, 5) }
        };

        return shapes.TryGetValue(id, out var shape)
            ? TypedResults.Ok(shape)
            : TypedResults.NotFound();
    })
    .WithName("GetShape");

app.Run();

public record class Circle(double Radius);

public record class Rectangle(double Width, double Height);

public record class Triangle(double Base, double Height);

public union Shape(Circle, Rectangle, Triangle);
With the union exposed, an endpoint that returns a union is described with an anyOf schema listing each case type. Here is the OpenAPI document.
{
  "paths": {
    "/shapes/{id}": {
      "get": {
        "operationId": "GetShape",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Shape" }
              }
            }
          },
          "404": { "description": "Not Found" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      // The union is an "anyOf" over its case types.
      "Shape": {
        "type": "object",
        "anyOf": [
          { "$ref": "#/components/schemas/Circle" },
          { "$ref": "#/components/schemas/Rectangle" },
          { "$ref": "#/components/schemas/Triangle" }
        ]
      },
      // Each case reuses its own standalone component, no "$type" discriminator.
      "Circle": {
        "type": "object",
        "required": [ "radius" ],
        "properties": { 
"radius": { "type": "number", "format": "double" } } }, "Rectangle": { "type": "object", "required": [ "width", "height" ], "properties": { "width": { "type": "number", "format": "double" }, "height": { "type": "number", "format": "double" } } }, "Triangle": { "type": "object", "required": [ "base", "height" ], "properties": { "base": { "type": "number", "format": "double" }, "height": { "type": "number", "format": "double" } } } } } }
Note that unlike polymorphic types, union cases don't carry a $type discriminator.

When you run this, you can see something like this:
Union Types in ASP.NET Core
A few limits apply in this preview. Only JSON request bodies and responses are supported. Binding a union from the query string, route values, headers, or form fields is not yet available (dotnet/aspnetcore #66648).

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya