Skip to content

Commit bb8ef46

Browse files
authored
.NET: Improve fidelity of OpenAI ChatCompletions Hosting (#1785)
* rename, support json serialization * wip * non-streaming * streaming? * proper streaming types * comments + fix audio parse * copilot suggestions * proper stopsequences type * build options as i could * annotations * proper generation of Id for chatcompletions * string length as in chatcompletions api ref * image url * support tools * rework API * introduce tests for chatcompletions * function calling / serialization tests / fixes * more tests and coverage * fix format * sort usings * nit * address PR comments * nits
1 parent 54db13c commit bb8ef46

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+5687
-406
lines changed

dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@
2020
// Configure the chat model and our agent.
2121
builder.AddKeyedChatClient("chat-model");
2222

23-
builder.AddAIAgent(
23+
var pirateAgentBuilder = builder.AddAIAgent(
2424
"pirate",
2525
instructions: "You are a pirate. Speak like a pirate",
2626
description: "An agent that speaks like a pirate.",
2727
chatClientServiceKey: "chat-model")
2828
.WithInMemoryThreadStore();
2929

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

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

8181
builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
8282
builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent();
83+
84+
builder.AddOpenAIChatCompletions();
8385
builder.AddOpenAIResponses();
8486

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

105107
app.MapOpenAIResponses();
106108

107-
app.MapOpenAIChatCompletions("pirate");
108-
app.MapOpenAIChatCompletions("knights-and-knaves");
109+
app.MapOpenAIChatCompletions(pirateAgentBuilder);
110+
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
109111

110112
// Map the agents HTTP endpoints
111113
app.MapAgentDiscovery("/agents");

dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,44 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3-
using System.Buffers;
4-
using System.ClientModel.Primitives;
3+
using System;
54
using System.Collections.Generic;
6-
using System.Diagnostics;
5+
using System.Linq;
76
using System.Net.ServerSentEvents;
87
using System.Runtime.CompilerServices;
98
using System.Text.Json;
109
using System.Threading;
1110
using System.Threading.Tasks;
12-
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
11+
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
12+
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
1313
using Microsoft.AspNetCore.Http;
1414
using Microsoft.AspNetCore.Http.Features;
15-
using OpenAI.Chat;
16-
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
15+
using Microsoft.Extensions.AI;
1716

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

20-
internal sealed class AIAgentChatCompletionsProcessor
19+
internal static class AIAgentChatCompletionsProcessor
2120
{
22-
private readonly AIAgent _agent;
23-
24-
public AIAgentChatCompletionsProcessor(AIAgent agent)
21+
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
2522
{
26-
this._agent = agent;
27-
}
23+
ArgumentNullException.ThrowIfNull(agent);
2824

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

33-
var inputItems = chatCompletionOptions.GetMessages();
34-
var chatMessages = inputItems.AsChatMessages();
35-
36-
if (chatCompletionOptions.GetStream())
28+
if (request.Stream == true)
3729
{
38-
return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages);
30+
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
3931
}
4032

41-
var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false);
42-
return new OpenAIChatCompletionResult(agentResponse);
33+
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
34+
return Results.Ok(response.ToChatCompletion(request));
4335
}
4436

45-
private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult
46-
{
47-
public async Task ExecuteAsync(HttpContext httpContext)
48-
{
49-
// note: OpenAI SDK types provide their own serialization implementation
50-
// so we cant simply return IResult wrap for the typed-object.
51-
// instead writing to the response body can be done.
52-
53-
var cancellationToken = httpContext.RequestAborted;
54-
var response = httpContext.Response;
55-
56-
var chatResponse = agentRunResponse.AsChatResponse();
57-
var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion();
58-
var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel<ChatCompletion>;
59-
Debug.Assert(openAIChatCompletionJsonModel is not null);
60-
61-
var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false });
62-
openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json);
63-
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
64-
}
65-
}
66-
67-
private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable<ChatMessage> chatMessages) : IResult
37+
private sealed class StreamingResponse(
38+
AIAgent agent,
39+
CreateChatCompletion request,
40+
IEnumerable<ChatMessage> chatMessages,
41+
ChatClientAgentRunOptions? options) : IResult
6842
{
6943
public Task ExecuteAsync(HttpContext httpContext)
7044
{
@@ -79,26 +53,99 @@ public Task ExecuteAsync(HttpContext httpContext)
7953
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
8054

8155
return SseFormatter.WriteAsync(
82-
source: this.GetStreamingResponsesAsync(cancellationToken),
56+
source: this.GetStreamingChunksAsync(cancellationToken),
8357
destination: response.Body,
8458
itemFormatter: (sseItem, bufferWriter) =>
8559
{
86-
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)sseItem.Data;
87-
var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json);
88-
bufferWriter.Write(json);
60+
using var writer = new Utf8JsonWriter(bufferWriter);
61+
JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk);
62+
writer.Flush();
8963
},
9064
cancellationToken);
9165
}
9266

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

97-
var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken);
98-
var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync();
99-
await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false))
73+
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
10074
{
101-
yield return new SseItem<StreamingChatCompletionUpdate>(streamingChatCompletionUpdate);
75+
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
76+
? chatResponseUpdate.FinishReason.ToString()
77+
: "stop";
78+
79+
var choiceChunks = new List<ChatCompletionChoiceChunk>();
80+
CompletionUsage? usageDetails = null;
81+
82+
createdAt ??= agentRunResponseUpdate.CreatedAt;
83+
84+
foreach (var content in agentRunResponseUpdate.Contents)
85+
{
86+
// usage content is handled separately
87+
if (content is UsageContent usageContent && usageContent.Details != null)
88+
{
89+
usageDetails = usageContent.Details.ToCompletionUsage();
90+
continue;
91+
}
92+
93+
ChatCompletionDelta? delta = content switch
94+
{
95+
TextContent textContent => new() { Content = textContent.Text },
96+
97+
// image
98+
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() },
99+
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() },
100+
101+
// audio
102+
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() },
103+
104+
// file
105+
DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() },
106+
HostedFileContent fileContent => new() { Content = fileContent.FileId },
107+
108+
// function call
109+
FunctionCallContent functionCallContent => new()
110+
{
111+
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
112+
},
113+
114+
// function result. ChatCompletions dont provide the results of function result per API reference
115+
FunctionResultContent functionResultContent => null,
116+
117+
// ignore
118+
_ => null
119+
};
120+
121+
if (delta is null)
122+
{
123+
// unsupported but expected content type.
124+
continue;
125+
}
126+
127+
delta.Role = agentRunResponseUpdate.Role?.Value ?? "user";
128+
129+
var choiceChunk = new ChatCompletionChoiceChunk
130+
{
131+
Index = 0,
132+
Delta = delta,
133+
FinishReason = finishReason
134+
};
135+
136+
choiceChunks.Add(choiceChunk);
137+
}
138+
139+
var chunk = new ChatCompletionChunk
140+
{
141+
Id = chunkId,
142+
Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
143+
Model = request.Model,
144+
Choices = choiceChunks,
145+
Usage = usageDetails
146+
};
147+
148+
yield return new(chunk);
102149
}
103150
}
104151
}

0 commit comments

Comments
 (0)