Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1a38df0
rename, support json serialization
DeagleGross Oct 28, 2025
9848e2c
wip
DeagleGross Oct 28, 2025
96a6ea5
non-streaming
DeagleGross Oct 29, 2025
9d830bf
streaming?
DeagleGross Oct 29, 2025
67bf382
proper streaming types
DeagleGross Oct 29, 2025
f270126
comments + fix audio parse
DeagleGross Oct 29, 2025
a20d1d7
copilot suggestions
DeagleGross Oct 30, 2025
ce809b0
proper stopsequences type
DeagleGross Oct 30, 2025
fd7a579
build options as i could
DeagleGross Oct 30, 2025
85324d2
annotations
DeagleGross Oct 30, 2025
3fda43c
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Oct 30, 2025
1c4c770
proper generation of Id for chatcompletions
DeagleGross Oct 30, 2025
5b9a971
string length as in chatcompletions api ref
DeagleGross Oct 30, 2025
bc3570b
image url
DeagleGross Oct 30, 2025
2c7546f
support tools
DeagleGross Oct 30, 2025
f8fedde
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Oct 31, 2025
e2e2cae
rework API
DeagleGross Oct 31, 2025
9b6b63b
introduce tests for chatcompletions
DeagleGross Oct 31, 2025
ac3047f
function calling / serialization tests / fixes
DeagleGross Oct 31, 2025
7c24f45
more tests and coverage
DeagleGross Oct 31, 2025
6b0df69
fix format
DeagleGross Oct 31, 2025
4e50e7c
sort usings
DeagleGross Oct 31, 2025
ce986f9
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 3, 2025
db7f238
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 3, 2025
916bce8
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 4, 2025
886fd0f
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 4, 2025
9d4cf6f
nit
DeagleGross Nov 4, 2025
6d6456d
address PR comments
DeagleGross Nov 4, 2025
2685515
nits
DeagleGross Nov 5, 2025
3ced668
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 5, 2025
d158a63
Merge branch 'main' into dmkorolev/chatcompletions-fidelity
DeagleGross Nov 5, 2025
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
10 changes: 6 additions & 4 deletions dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");

builder.AddAIAgent(
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
description: "An agent that speaks like a pirate.",
chatClientServiceKey: "chat-model")
.WithInMemoryThreadStore();

builder.AddAIAgent("knights-and-knaves", (sp, key) =>
var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");

Expand Down Expand Up @@ -80,6 +80,8 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te

builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();

builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();

var app = builder.Build();
Expand All @@ -104,8 +106,8 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te

app.MapOpenAIResponses();

app.MapOpenAIChatCompletions("pirate");
app.MapOpenAIChatCompletions("knights-and-knaves");
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);

// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,70 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Buffers;
using System.ClientModel.Primitives;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
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.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using OpenAI.Chat;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using Microsoft.Extensions.AI;

namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;

internal sealed class AIAgentChatCompletionsProcessor
internal static class AIAgentChatCompletionsProcessor
{
private readonly AIAgent _agent;

public AIAgentChatCompletionsProcessor(AIAgent agent)
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
{
this._agent = agent;
}
ArgumentNullException.ThrowIfNull(agent);

public async Task<IResult> CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken)
{
AgentThread? agentThread = null; // not supported to resolve from conversationId
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
var chatClientAgentRunOptions = request.BuildOptions();

var inputItems = chatCompletionOptions.GetMessages();
var chatMessages = inputItems.AsChatMessages();

if (chatCompletionOptions.GetStream())
if (request.Stream == true)
{
return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages);
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
}

var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false);
return new OpenAIChatCompletionResult(agentResponse);
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(response.ToChatCompletion(request));
}

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<ChatCompletion>;
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<ChatMessage> chatMessages) : IResult
private sealed class StreamingResponse(
AIAgent agent,
CreateChatCompletion request,
IEnumerable<ChatMessage> chatMessages,
ChatClientAgentRunOptions? options) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
Expand All @@ -79,26 +53,99 @@ public Task ExecuteAsync(HttpContext httpContext)
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();

return SseFormatter.WriteAsync(
source: this.GetStreamingResponsesAsync(cancellationToken),
source: this.GetStreamingChunksAsync(cancellationToken),
destination: response.Body,
itemFormatter: (sseItem, bufferWriter) =>
{
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)sseItem.Data;
var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json);
bufferWriter.Write(json);
using var writer = new Utf8JsonWriter(bufferWriter);
JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk);
writer.Flush();
},
cancellationToken);
}

private async IAsyncEnumerable<SseItem<StreamingChatCompletionUpdate>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
private async IAsyncEnumerable<SseItem<ChatCompletionChunk>> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AgentThread? agentThread = null;
// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
DateTimeOffset? createdAt = null;
var chunkId = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13);

var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken);
var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync();
await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false))
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
{
yield return new SseItem<StreamingChatCompletionUpdate>(streamingChatCompletionUpdate);
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
? chatResponseUpdate.FinishReason.ToString()
: "stop";

var choiceChunks = new List<ChatCompletionChoiceChunk>();
CompletionUsage? usageDetails = null;

createdAt ??= agentRunResponseUpdate.CreatedAt;

foreach (var content in agentRunResponseUpdate.Contents)
{
// usage content is handled separately
if (content is UsageContent usageContent && usageContent.Details != null)
{
usageDetails = usageContent.Details.ToCompletionUsage();
continue;
}

ChatCompletionDelta? delta = content switch
{
TextContent textContent => new() { Content = textContent.Text },

// image
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() },
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() },

// audio
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() },

// file
DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() },
HostedFileContent fileContent => new() { Content = fileContent.FileId },

// function call
FunctionCallContent functionCallContent => new()
{
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
},

// function result. ChatCompletions dont provide the results of function result per API reference
FunctionResultContent functionResultContent => null,

// ignore
_ => null
};

if (delta is null)
{
// unsupported but expected content type.
continue;
}

delta.Role = agentRunResponseUpdate.Role?.Value ?? "user";

var choiceChunk = new ChatCompletionChoiceChunk
{
Index = 0,
Delta = delta,
FinishReason = finishReason
};

choiceChunks.Add(choiceChunk);
}

var chunk = new ChatCompletionChunk
{
Id = chunkId,
Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
Choices = choiceChunks,
Usage = usageDetails
};

yield return new(chunk);
}
}
}
Expand Down
Loading
Loading