diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 9998953bba..d7fbe3eda0 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -89,6 +89,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te app.MapOpenAIResponses("pirate"); app.MapOpenAIResponses("knights-and-knaves"); +app.MapOpenAIChatCompletions("pirate"); +app.MapOpenAIChatCompletions("knights-and-knaves"); + // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index a785990f1a..3a2d3560a9 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -10,7 +10,7 @@ namespace AgentWebChat.Web; -internal sealed class A2AAgentClient : IAgentClient +internal sealed class A2AAgentClient : AgentClientBase { private readonly ILogger _logger; private readonly Uri _uri; @@ -25,7 +25,7 @@ public A2AAgentClient(ILogger logger, Uri baseUri) this._uri = baseUri; } - public async IAsyncEnumerable RunStreamingAsync( + public async override IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -126,7 +126,7 @@ public async IAsyncEnumerable RunStreamingAsync( } } - public async Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + public async override Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) { this._logger.LogInformation("Retrieving agent card for {Agent}", agentName); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index cba03eb0c9..0926f087e8 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + $(NoWarn);CA1812 diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index 2347f31c64..5642aa0ff3 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -5,6 +5,7 @@ @inject ILogger Logger @inject A2AAgentClient A2AActorClient @inject OpenAIResponsesAgentClient OpenAIResponsesAgentClient +@inject OpenAIChatCompletionsAgentClient OpenAIChatCompletionsAgentClient @rendermode InteractiveServer @using System.Text @using System.Text.Json @@ -52,14 +53,18 @@
@switch (selectedProtocol) { case Protocol.OpenAIResponses: ֎ OpenAI Responses + break; + case Protocol.OpenAIChatCompletions: + ֎ OpenAI ChatCompletions break; case Protocol.A2A: default: @@ -903,7 +908,8 @@ private enum Protocol { A2A, // Agent-to-Agent protocol - OpenAIResponses + OpenAIResponses, + OpenAIChatCompletions } private sealed class Conversation @@ -1080,11 +1086,11 @@ try { - // Select the appropriate client based on protocol - IAgentClient agentClient = selectedProtocol switch + AgentClientBase agentClient = selectedProtocol switch { Protocol.OpenAIResponses => OpenAIResponsesAgentClient, + Protocol.OpenAIChatCompletions => OpenAIChatCompletionsAgentClient, Protocol.A2A or _ => A2AActorClient }; diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs index 13f1824c64..2d08ef5e45 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs @@ -9,7 +9,7 @@ namespace AgentWebChat.Web; /// /// Interface for clients that can interact with agents and provide streaming responses. /// -public interface IAgentClient +internal abstract class AgentClientBase { /// /// Runs an agent with the specified messages and returns a streaming response. @@ -19,7 +19,7 @@ public interface IAgentClient /// Optional thread identifier for conversation continuity. /// Cancellation token. /// An asynchronous enumerable of agent response updates. - IAsyncEnumerable RunStreamingAsync( + public abstract IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -31,7 +31,8 @@ IAsyncEnumerable RunStreamingAsync( /// The name of the agent. /// Cancellation token. /// The agent card if supported, null otherwise. - Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default); + public virtual Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + => Task.FromResult(null); } /// diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs new file mode 100644 index 0000000000..ae71a87678 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Runtime.CompilerServices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace AgentWebChat.Web; + +/// +/// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI ChatCompletions protocol. +/// +internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) : AgentClientBase +{ + public async override IAsyncEnumerable RunStreamingAsync( + string agentName, + IList messages, + string? threadId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + OpenAIClientOptions options = new() + { + Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(httpClient) + }; + + var openAiClient = new ChatClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); + await foreach (var update in openAiClient.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken)) + { + yield return new AgentRunResponseUpdate(update); + } + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs index 8ec81c0e48..524538bbf9 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel; +using System.ClientModel.Primitives; using System.Runtime.CompilerServices; -using A2A; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; @@ -13,16 +13,9 @@ namespace AgentWebChat.Web; /// /// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI Responses protocol. /// -internal sealed class OpenAIResponsesAgentClient : IAgentClient +internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentClientBase { - private readonly Uri _baseUri; - - public OpenAIResponsesAgentClient(string baseUri) - { - this._baseUri = new Uri(baseUri.TrimEnd('/')); - } - - public async IAsyncEnumerable RunStreamingAsync( + public async override IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -30,7 +23,8 @@ public async IAsyncEnumerable RunStreamingAsync( { OpenAIClientOptions options = new() { - Endpoint = new Uri(this._baseUri, $"/{agentName}/v1/") + Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(httpClient) }; var openAiClient = new OpenAIResponseClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); @@ -44,7 +38,4 @@ public async IAsyncEnumerable RunStreamingAsync( yield return new AgentRunResponseUpdate(update); } } - - public Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) - => Task.FromResult(null!); } diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs index 2cee27e269..665aaa1bba 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs @@ -23,7 +23,9 @@ builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); builder.Services.AddSingleton(sp => new A2AAgentClient(sp.GetRequiredService>(), a2aAddress)); -builder.Services.AddSingleton(sp => new OpenAIResponsesAgentClient("http://localhost:5390")); + +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); var app = builder.Build(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs new file mode 100644 index 0000000000..f32fcc8db8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Buffers; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +internal sealed class AIAgentChatCompletionsProcessor +{ + private readonly AIAgent _agent; + + public AIAgentChatCompletionsProcessor(AIAgent agent) + { + this._agent = agent; + } + + public async Task CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken) + { + AgentThread? agentThread = null; // not supported to resolve from conversationId + + var inputItems = chatCompletionOptions.GetMessages(); + var chatMessages = inputItems.AsChatMessages(); + + if (chatCompletionOptions.GetStream()) + { + return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages); + } + + var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false); + return new OpenAIChatCompletionResult(agentResponse); + } + + private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + // note: OpenAI SDK types provide their own serialization implementation + // so we cant simply return IResult wrap for the typed-object. + // instead writing to the response body can be done. + + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + var chatResponse = agentRunResponse.AsChatResponse(); + var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion(); + var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel; + Debug.Assert(openAIChatCompletionJsonModel is not null); + + var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false }); + openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable chatMessages) : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + { + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + // Set SSE headers + response.Headers.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache,no-store"; + response.Headers.Connection = "keep-alive"; + response.Headers.ContentEncoding = "identity"; + httpContext.Features.GetRequiredFeature().DisableBuffering(); + + return SseFormatter.WriteAsync( + source: this.GetStreamingResponsesAsync(cancellationToken), + destination: response.Body, + itemFormatter: (sseItem, bufferWriter) => + { + var sseDataJsonModel = (IJsonModel)sseItem.Data; + var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json); + bufferWriter.Write(json); + }, + cancellationToken); + } + + private async IAsyncEnumerable> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AgentThread? agentThread = null; + + var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken); + var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync(); + await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new SseItem(streamingChatCompletionUpdate); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs new file mode 100644 index 0000000000..2816e015e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Shared.Diagnostics; +using OpenAI.Chat; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] +internal static class ChatCompletionsOptionsExtensions +{ + private static readonly Func _getStreamNullable; + private static readonly Func> _getMessages; + + static ChatCompletionsOptionsExtensions() + { + // OpenAI SDK does not have a simple way to get the input as a c# object. + // However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object. + + // --- Stream (internal bool? Stream { get; set; }) --- + const string streamPropName = "Stream"; + var streamProp = typeof(ChatCompletionOptions).GetProperty(streamPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, streamPropName); + var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{streamPropName} getter not found."); + + _getStreamNullable = streamGetter.CreateDelegate>(); + + // --- Messages (internal IList Messages { get; set; }) --- + const string inputPropName = "Messages"; + var inputProp = typeof(ChatCompletionOptions).GetProperty(inputPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, inputPropName); + var inputGetter = inputProp.GetGetMethod(nonPublic: true) + ?? throw new MissingMethodException($"{inputPropName} getter not found."); + + _getMessages = inputGetter.CreateDelegate>>(); + } + + public static IList GetMessages(this ChatCompletionOptions options) + { + Throw.IfNull(options); + return _getMessages(options); + } + + public static bool GetStream(this ChatCompletionOptions options) + { + Throw.IfNull(options); + return _getStreamNullable(options) ?? false; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs new file mode 100644 index 0000000000..b331bd8522 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OpenAI.Chat; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +public static partial class EndpointRouteBuilderExtensions +{ + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The name of the AI agent service registered in the dependency injection container. This name is used to resolve the instance from the keyed services. + /// Custom route path for the chat completions endpoint. + public static void MapOpenAIChatCompletions( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("Route")] string? path = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + if (path is null) + { + ValidateAgentName(agentName); + } + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + + path ??= $"/{agentName}/v1/chat/completions"; + var chatCompletionsRouteGroup = endpoints.MapGroup(path); + MapChatCompletions(chatCompletionsRouteGroup, agent); + } + + private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent) + { + var endpointAgentName = agent.DisplayName; + var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent); + + routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) => + { + var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false); + + var chatCompletionOptions = new ChatCompletionOptions(); + var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel; + Debug.Assert(chatCompletionOptionsJsonModel is not null); + + chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json); + if (chatCompletionOptions is null) + { + return Results.BadRequest("Invalid request payload."); + } + + return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false); + }).WithName(endpointAgentName + "/CreateChatCompletion"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index ca58dfd27b..7e3e349f39 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -15,9 +15,9 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI; /// -/// Provides extension methods for mapping OpenAI Responses capabilities to an . +/// Provides extension methods for mapping OpenAI capabilities to an . /// -public static class EndpointRouteBuilderExtensions +public static partial class EndpointRouteBuilderExtensions { /// /// Maps OpenAI Responses API endpoints to the specified for the given .