Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dotnet/eng/MSBuild/Shared.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
<ItemGroup Condition="'$(InjectSharedOriginPinning)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\OriginPinning\*.cs" LinkBase="Shared\OriginPinning" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedHostedAgents)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\HostedAgents\*.cs" LinkBase="Shared\HostedAgents" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedHttpHeaderValidation)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\Http\*.cs" LinkBase="Shared\Http" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;

internal static class AIAgentChatCompletionsProcessor
{
/// <summary>
/// Creates an OpenAI-style <c>invalid_request_error</c> response.
/// </summary>
/// <param name="statusCode">The HTTP status code of the response.</param>
/// <param name="message">The error message.</param>
/// <param name="code">The OpenAI error code.</param>
/// <returns>The error result.</returns>
/// <remarks>
/// <c>AddOpenAIChatCompletions</c> only registers the Chat Completions serialization metadata, which does not include
/// <see cref="ErrorResponse"/>, so the error is serialized with its own metadata to work when reflection-based
/// serialization is disabled.
/// </remarks>
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<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, OpenAIChatCompletionsMapOptions? mapOptions, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(agent);
Expand All @@ -35,15 +61,7 @@ public static async Task<IResult> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,13 +37,38 @@ public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpoint
/// <param name="path">Custom route path for the chat completions endpoint.</param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
/// <remarks>
/// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are
/// resolved from <see cref="HttpContext.RequestServices"/> for each request, so their configured DI lifetime is honored.
/// See <see cref="MapOpenAIChatCompletions(IEndpointRouteBuilder, AIAgent, string, OpenAIChatCompletionsMapOptions)"/>
/// for endpoint authorization and application-owned state requirements.
/// </remarks>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path, OpenAIChatCompletionsMapOptions? mapOptions = null)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(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<HttpContext, AIAgent?> agentResolver;
if (agentBuilder.Lifetime == ServiceLifetime.Singleton)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
agentResolver = _ => agent;
}
else
{
agentResolver = context => context.RequestServices.GetRequiredKeyedService<AIAgent>(agentName);
}

path ??= $"/{agentName}/v1/chat/completions";
return MapOpenAIChatCompletionsCore(
endpoints,
path,
agentName + "/CreateChatCompletion",
agentResolver,
mapOptions);
}

/// <summary>
Expand Down Expand Up @@ -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);
}

/// <summary>
/// Maps an OpenAI ChatCompletions API endpoint that selects the <see cref="AIAgent"/> to invoke for each request.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoint to.</param>
/// <param name="path">The route path for the chat completions endpoint.</param>
/// <param name="agentSelector">
/// A delegate invoked once per request that returns the agent to invoke, or <see langword="null"/> when
/// no agent matches the request. The delegate can use route values, claims, headers, and
/// <see cref="HttpContext.RequestServices"/> to select or resolve the agent.
/// </param>
/// <param name="mapOptions">Optional options controlling how incoming requests are mapped onto the agent run.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
/// <remarks>
/// When <paramref name="agentSelector"/> returns <see langword="null"/>, the endpoint responds with
/// <c>404 Not Found</c>. The selected agent must have a non-empty <see cref="AIAgent.Name"/>, which is its
/// stable logical identity. Selection is not an authorization boundary; see
/// <see cref="MapOpenAIChatCompletions(IEndpointRouteBuilder, AIAgent, string, OpenAIChatCompletionsMapOptions)"/>
/// for endpoint authorization and application-owned state requirements.
/// </remarks>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(
this IEndpointRouteBuilder endpoints,
[StringSyntax("Route")] string path,
Func<HttpContext, AIAgent?> 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<HttpContext, AIAgent?> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PropertyGroup>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedHostedAgents>true</InjectSharedHostedAgents>
</PropertyGroup>

<ItemGroup>
Expand Down
52 changes: 52 additions & 0 deletions dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.Agents.AI.Hosting;

/// <summary>
/// Provides validation shared by protocol hosting integrations that resolve agents for each request.
/// </summary>
internal static class HostedAgentResolution
{
/// <summary>
/// Verifies that a keyed <see cref="AIAgent"/> registration exists without resolving it.
/// </summary>
/// <param name="services">The application service provider.</param>
/// <param name="agentName">The name of the keyed agent registration.</param>
/// <exception cref="InvalidOperationException">No agent is registered with <paramref name="agentName"/>.</exception>
/// <remarks>
/// 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.
/// </remarks>
public static void EnsureAgentRegistered(IServiceProvider services, string agentName)
{
if (services.GetService<IServiceProviderIsKeyedService>() 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.");
}
}

/// <summary>
/// Gets the stable logical identity of an agent returned by a request-time agent selector.
/// </summary>
/// <param name="agent">The selected agent.</param>
/// <returns>The agent name, which identifies the agent's hosted services and persisted sessions.</returns>
/// <exception cref="InvalidOperationException">The agent does not have a name.</exception>
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!;
}
}
Loading
Loading