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); + } +}