diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props
index 10db0a47ef4..3c8f9670284 100644
--- a/dotnet/eng/MSBuild/Shared.props
+++ b/dotnet/eng/MSBuild/Shared.props
@@ -38,6 +38,9 @@
+
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs
index b70932b13d5..1a5603ee22b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs
@@ -20,6 +20,32 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
internal static class AIAgentChatCompletionsProcessor
{
+ ///
+ /// Creates an OpenAI-style invalid_request_error response.
+ ///
+ /// The HTTP status code of the response.
+ /// The error message.
+ /// The OpenAI error code.
+ /// The error result.
+ ///
+ /// AddOpenAIChatCompletions only registers the Chat Completions serialization metadata, which does not include
+ /// , so the error is serialized with its own metadata to work when reflection-based
+ /// serialization is disabled.
+ ///
+ public static IResult CreateErrorResult(int statusCode, string message, string code) =>
+ Results.Json(
+ new ErrorResponse
+ {
+ Error = new ErrorDetails
+ {
+ Message = message,
+ Type = "invalid_request_error",
+ Code = code
+ }
+ },
+ OpenAIHostingJsonContext.Default.ErrorResponse,
+ statusCode: statusCode);
+
public static async Task CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, OpenAIChatCompletionsMapOptions? mapOptions, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(agent);
@@ -35,15 +61,7 @@ public static async Task CreateChatCompletionAsync(AIAgent agent, Creat
}
catch (NotSupportedException ex)
{
- return Results.BadRequest(new ErrorResponse
- {
- Error = new ErrorDetails
- {
- Message = ex.Message,
- Type = "invalid_request_error",
- Code = "unsupported_parameter"
- }
- });
+ return CreateErrorResult(StatusCodes.Status400BadRequest, ex.Message, "unsupported_parameter");
}
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs
index 8745d510320..b382d226478 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs
@@ -8,6 +8,7 @@
using Microsoft.Agents.AI.Hosting.OpenAI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
@@ -36,13 +37,38 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint
/// Custom route path for the chat completions endpoint.
/// Optional options controlling how incoming requests are mapped onto the agent run.
///
+ /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are
+ /// resolved from for each request, so their configured DI lifetime is honored.
/// See
/// for endpoint authorization and application-owned state requirements.
///
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null)
{
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name);
- return MapOpenAIChatCompletions(endpoints, agent, path, mapOptions);
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ string agentName = agentBuilder.Name;
+ ValidateAgentName(agentName);
+ HostedAgentResolution.EnsureAgentRegistered(endpoints.ServiceProvider, agentName);
+
+ Func agentResolver;
+ if (agentBuilder.Lifetime == ServiceLifetime.Singleton)
+ {
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+ agentResolver = _ => agent;
+ }
+ else
+ {
+ agentResolver = context => context.RequestServices.GetRequiredKeyedService(agentName);
+ }
+
+ path ??= $"/{agentName}/v1/chat/completions";
+ return MapOpenAIChatCompletionsCore(
+ endpoints,
+ path,
+ agentName + "/CreateChatCompletion",
+ agentResolver,
+ mapOptions);
}
///
@@ -84,12 +110,78 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(
ValidateAgentName(agent.Name);
path ??= $"/{agent.Name}/v1/chat/completions";
+ return MapOpenAIChatCompletionsCore(endpoints, path, agent.Name + "/CreateChatCompletion", _ => agent, mapOptions);
+ }
+
+ ///
+ /// Maps an OpenAI ChatCompletions API endpoint that selects the to invoke for each request.
+ ///
+ /// The to add the OpenAI ChatCompletions endpoint to.
+ /// The route path for the chat completions endpoint.
+ ///
+ /// A delegate invoked once per request that returns the agent to invoke, or when
+ /// no agent matches the request. The delegate can use route values, claims, headers, and
+ /// to select or resolve the agent.
+ ///
+ /// Optional options controlling how incoming requests are mapped onto the agent run.
+ /// An for further endpoint configuration.
+ ///
+ /// When returns , the endpoint responds with
+ /// 404 Not Found. The selected agent must have a non-empty , which is its
+ /// stable logical identity. Selection is not an authorization boundary; see
+ ///
+ /// for endpoint authorization and application-owned state requirements.
+ ///
+ public static IEndpointConventionBuilder MapOpenAIChatCompletions(
+ this IEndpointRouteBuilder endpoints,
+ [StringSyntax("Route")] string path,
+ Func agentSelector,
+ OpenAIChatCompletionsMapOptions? mapOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ ArgumentNullException.ThrowIfNull(agentSelector);
+
+ return MapOpenAIChatCompletionsCore(
+ endpoints,
+ path,
+ endpointName: null,
+ context =>
+ {
+ if (agentSelector(context) is not { } agent)
+ {
+ return null;
+ }
+
+ _ = HostedAgentResolution.GetSelectedAgentName(agent);
+ return agent;
+ },
+ mapOptions);
+ }
+
+ private static RouteGroupBuilder MapOpenAIChatCompletionsCore(
+ IEndpointRouteBuilder endpoints,
+ string path,
+ string? endpointName,
+ Func agentResolver,
+ OpenAIChatCompletionsMapOptions? mapOptions)
+ {
var group = endpoints.MapGroup(path);
- var endpointAgentName = agent.Name ?? agent.Id;
- group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
- => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false))
- .WithName(endpointAgentName + "/CreateChatCompletion");
+ var endpoint = group.MapPost("/", async ([FromBody] CreateChatCompletion request, HttpContext context, CancellationToken cancellationToken) =>
+ {
+ if (agentResolver(context) is not { } agent)
+ {
+ return AIAgentChatCompletionsProcessor.CreateErrorResult(StatusCodes.Status404NotFound, "No agent matches the request.", "agent_not_found");
+ }
+
+ return await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, mapOptions, cancellationToken).ConfigureAwait(false);
+ });
+
+ if (endpointName is not null)
+ {
+ endpoint.WithName(endpointName);
+ }
MarkFeatureUsed();
return group;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
index baf2152635c..b99905c72e1 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj
@@ -14,6 +14,7 @@
true
true
+ true
diff --git a/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs
new file mode 100644
index 00000000000..42d9638d61a
--- /dev/null
+++ b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Agents.AI.Hosting;
+
+///
+/// Provides validation shared by protocol hosting integrations that resolve agents for each request.
+///
+internal static class HostedAgentResolution
+{
+ ///
+ /// Verifies that a keyed registration exists without resolving it.
+ ///
+ /// The application service provider.
+ /// The name of the keyed agent registration.
+ /// No agent is registered with .
+ ///
+ /// Resolving the agent from the application service provider would capture scoped or transient registrations,
+ /// so the check is skipped when the provider cannot report keyed registrations.
+ ///
+ public static void EnsureAgentRegistered(IServiceProvider services, string agentName)
+ {
+ if (services.GetService() is { } isKeyedService &&
+ !isKeyedService.IsKeyedService(typeof(AIAgent), agentName))
+ {
+ throw new InvalidOperationException(
+ $"No {nameof(AIAgent)} is registered with the name '{agentName}'. " +
+ $"Register it with AddAIAgent(\"{agentName}\", ...) before mapping the endpoint.");
+ }
+ }
+
+ ///
+ /// Gets the stable logical identity of an agent returned by a request-time agent selector.
+ ///
+ /// The selected agent.
+ /// The agent name, which identifies the agent's hosted services and persisted sessions.
+ /// The agent does not have a name.
+ public static string GetSelectedAgentName(AIAgent agent)
+ {
+ string? agentName = agent.Name;
+ if (string.IsNullOrWhiteSpace(agentName))
+ {
+ throw new InvalidOperationException(
+ $"The agent selector returned an {nameof(AIAgent)} without a {nameof(AIAgent.Name)}. " +
+ "Selected agents must have a stable name, which identifies their hosted services and persisted sessions across requests.");
+ }
+
+ return agentName!;
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs
new file mode 100644
index 00000000000..abd9550bb1f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsAgentResolutionTests.cs
@@ -0,0 +1,261 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Json;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Moq;
+
+namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests;
+
+///
+/// Verifies how OpenAI Chat Completions endpoints resolve and select agents for each request.
+///
+public sealed class OpenAIChatCompletionsAgentResolutionTests
+{
+ private const string RequestJson = """{"model":"test-model","messages":[{"role":"user","content":"hello"}]}""";
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithScopedHostedAgent_ResolvesAgentPerRequestAsync()
+ {
+ // Arrange
+ int createdAgents = 0;
+ WebApplicationBuilder builder = CreateBuilder();
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent(
+ "assistant",
+ (_, name) =>
+ {
+ Interlocked.Increment(ref createdAgents);
+ return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Scoped response"), name: name);
+ },
+ ServiceLifetime.Scoped);
+
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions(agentBuilder);
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ string firstResponse = await PostAsync(client, "/assistant/v1/chat/completions");
+ string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions");
+
+ // Assert
+ Assert.Equal(2, createdAgents);
+ Assert.Contains("Scoped response", firstResponse, StringComparison.Ordinal);
+ Assert.Contains("Scoped response", secondResponse, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithSingletonHostedAgent_ResolvesAgentOnceAtMapTimeAsync()
+ {
+ // Arrange
+ int createdAgents = 0;
+ WebApplicationBuilder builder = CreateBuilder();
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent(
+ "assistant",
+ (_, name) =>
+ {
+ Interlocked.Increment(ref createdAgents);
+ return new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Singleton response"), name: name);
+ });
+
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions(agentBuilder);
+ int createdAtMapTime = createdAgents;
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ await PostAsync(client, "/assistant/v1/chat/completions");
+ string secondResponse = await PostAsync(client, "/assistant/v1/chat/completions");
+
+ // Assert
+ Assert.Equal(1, createdAtMapTime);
+ Assert.Equal(1, createdAgents);
+ Assert.Contains("Singleton response", secondResponse, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithUnregisteredHostedAgent_ThrowsAtMapTimeAsync()
+ {
+ // Arrange
+ Mock agentBuilder = new();
+ agentBuilder.Setup(b => b.Name).Returns("missing");
+ await using WebApplication app = CreateBuilder().Build();
+
+ // Act & Assert
+ InvalidOperationException exception = Assert.Throws(() => app.MapOpenAIChatCompletions(agentBuilder.Object));
+ Assert.Contains("missing", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithAgentSelector_SelectsAgentFromRouteValueAsync()
+ {
+ // Arrange
+ WebApplicationBuilder builder = CreateBuilder();
+ builder.AddAIAgent("alpha", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Alpha response"), name: name), ServiceLifetime.Scoped);
+ builder.AddAIAgent("beta", (_, name) => new ChatClientAgent(new TestHelpers.SimpleMockChatClient("Beta response"), name: name), ServiceLifetime.Transient);
+
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute);
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ string alphaResponse = await PostAsync(client, "/agents/alpha/v1/chat/completions");
+ string betaResponse = await PostAsync(client, "/agents/beta/v1/chat/completions");
+
+ // Assert
+ Assert.Contains("Alpha response", alphaResponse, StringComparison.Ordinal);
+ Assert.Contains("Beta response", betaResponse, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningNull_ReturnsNotFoundAsync()
+ {
+ // Arrange
+ WebApplicationBuilder builder = CreateBuilder();
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions("/agents/{agentId}/v1/chat/completions", SelectAgentFromRoute);
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ using HttpResponseMessage response = await SendAsync(client, "/agents/unknown/v1/chat/completions");
+ string responseBody = await response.Content.ReadAsStringAsync();
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Contains("agent_not_found", responseBody, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningNullAndReflectionDisabled_ReturnsNotFoundErrorAsync()
+ {
+ // Arrange
+ WebApplicationBuilder builder = CreateBuilder(disableReflectionSerialization: true);
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions("/v1/chat/completions", _ => null);
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ using HttpResponseMessage response = await SendAsync(client, "/v1/chat/completions");
+ JsonElement error = await ReadErrorAsync(response);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ Assert.Equal("agent_not_found", error.GetProperty("code").GetString());
+ Assert.Equal("invalid_request_error", error.GetProperty("type").GetString());
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithUnsupportedRunOptionAndReflectionDisabled_ReturnsBadRequestErrorAsync()
+ {
+ // Arrange
+ WebApplicationBuilder builder = CreateBuilder(disableReflectionSerialization: true);
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions(
+ new ChatClientAgent(new TestHelpers.SimpleMockChatClient(), name: "assistant"),
+ "/v1/chat/completions",
+ new OpenAIChatCompletionsMapOptions { RunOptionsFactory = _ => throw new NotSupportedException("Unsupported setting.") });
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ using HttpResponseMessage response = await SendAsync(client, "/v1/chat/completions");
+ JsonElement error = await ReadErrorAsync(response);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal("unsupported_parameter", error.GetProperty("code").GetString());
+ Assert.Equal("Unsupported setting.", error.GetProperty("message").GetString());
+ }
+
+ [Fact]
+ public async Task MapOpenAIChatCompletions_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync()
+ {
+ // Arrange
+ WebApplicationBuilder builder = CreateBuilder();
+ await using WebApplication app = builder.Build();
+ app.MapOpenAIChatCompletions("/v1/chat/completions", _ => new ChatClientAgent(new TestHelpers.SimpleMockChatClient()));
+ await app.StartAsync();
+ using HttpClient client = app.GetTestClient();
+
+ // Act
+ using HttpResponseMessage response = await SendAsync(client, "/v1/chat/completions");
+ string responseBody = await response.Content.ReadAsStringAsync();
+
+ // Assert
+ Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
+ Assert.Contains("stable name", responseBody, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void MapOpenAIChatCompletions_WithNullAgentSelector_ThrowsArgumentNullException()
+ {
+ // Arrange
+ Mock endpoints = new();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ endpoints.Object.MapOpenAIChatCompletions("/v1/chat/completions", null!));
+ }
+
+ private static AIAgent? SelectAgentFromRoute(HttpContext context) =>
+ context.GetRouteValue("agentId") is string agentId
+ ? context.RequestServices.GetKeyedService(agentId)
+ : null;
+
+ private static WebApplicationBuilder CreateBuilder(bool disableReflectionSerialization = false)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Development });
+ builder.WebHost.UseTestServer();
+ builder.AddOpenAIChatCompletions();
+ if (disableReflectionSerialization)
+ {
+ // With reflection-based serialization disabled, the resolver registered by AddOpenAIChatCompletions only
+ // provides the source-generated Chat Completions metadata. The test process has reflection enabled, which
+ // that resolver would fall back to, so register the source-generated metadata alone.
+ builder.Services.Configure(options =>
+ {
+ options.SerializerOptions.TypeInfoResolverChain.Clear();
+ options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonContext.Default);
+ });
+ }
+
+ return builder;
+ }
+
+ private static async Task ReadErrorAsync(HttpResponseMessage response)
+ {
+ string responseBody = await response.Content.ReadAsStringAsync();
+ using JsonDocument document = JsonDocument.Parse(responseBody);
+ return document.RootElement.GetProperty("error").Clone();
+ }
+
+ private static async Task PostAsync(HttpClient client, string path)
+ {
+ using HttpResponseMessage response = await SendAsync(client, path);
+ string responseBody = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, responseBody);
+ return responseBody;
+ }
+
+ private static async Task SendAsync(HttpClient client, string path)
+ {
+ using StringContent content = new(RequestJson, Encoding.UTF8, "application/json");
+ return await client.PostAsync(new Uri(path, UriKind.Relative), content);
+ }
+}