Skip to content

Commit 463a4ab

Browse files
committed
Expose streaming conversion utility methods
We're already exposing the non-streaming response conversions. Expose the streaming ones as well.
1 parent b8012f4 commit 463a4ab

File tree

5 files changed

+155
-23
lines changed

5 files changed

+155
-23
lines changed

src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Threading;
67
using Microsoft.Extensions.AI;
78
using Microsoft.Shared.Diagnostics;
89

@@ -20,14 +21,28 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) =>
2021

2122
/// <summary>Creates a sequence of OpenAI <see cref="ChatMessage"/> instances from the specified input messages.</summary>
2223
/// <param name="messages">The input messages to convert.</param>
24+
/// <param name="options">The options employed while processing <paramref name="messages"/>.</param>
2325
/// <returns>A sequence of OpenAI chat messages.</returns>
24-
public static IEnumerable<ChatMessage> AsOpenAIChatMessages(this IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages) =>
25-
OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), chatOptions: null);
26+
public static IEnumerable<ChatMessage> AsOpenAIChatMessages(this IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, ChatOptions? options = null) =>
27+
OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), options);
2628

2729
/// <summary>Creates a Microsoft.Extensions.AI <see cref="ChatResponse"/> from a <see cref="ChatCompletion"/>.</summary>
2830
/// <param name="chatCompletion">The <see cref="ChatCompletion"/> to convert to a <see cref="ChatResponse"/>.</param>
2931
/// <param name="options">The options employed in the creation of the response.</param>
3032
/// <returns>A converted <see cref="ChatResponse"/>.</returns>
3133
public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) =>
3234
OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options);
35+
36+
/// <summary>
37+
/// Creates a sequence of Microsoft.Extensions.AI <see cref="ChatResponseUpdate"/> instances from the specified
38+
/// sequence of OpenAI <see cref="StreamingChatCompletionUpdate"/> instances.
39+
/// </summary>
40+
/// <param name="chatCompletionUpdates">The update instances.</param>
41+
/// <param name="options">The options employed in the creation of the response.</param>
42+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
43+
/// <returns>A sequence of converted <see cref="ChatResponseUpdate"/> instances.</returns>
44+
/// <exception cref="ArgumentNullException"><paramref name="chatCompletionUpdates"/> is <see langword="null"/>.</exception>
45+
public static IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
46+
this IAsyncEnumerable<StreamingChatCompletionUpdate> chatCompletionUpdates, ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) =>
47+
OpenAIChatClient.FromOpenAIStreamingChatCompletionAsync(Throw.IfNull(chatCompletionUpdates), options, cancellationToken);
3348
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Threading;
67
using Microsoft.Extensions.AI;
78
using Microsoft.Shared.Diagnostics;
89

@@ -20,14 +21,30 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) =>
2021

2122
/// <summary>Creates a sequence of OpenAI <see cref="ResponseItem"/> instances from the specified input messages.</summary>
2223
/// <param name="messages">The input messages to convert.</param>
24+
/// <param name="options">The options employed while processing <paramref name="messages"/>.</param>
2325
/// <returns>A sequence of OpenAI response items.</returns>
24-
public static IEnumerable<ResponseItem> AsOpenAIResponseItems(this IEnumerable<ChatMessage> messages) =>
25-
OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages));
26+
/// <exception cref="ArgumentNullException"><paramref name="messages"/> is <see langword="null"/>.</exception>
27+
public static IEnumerable<ResponseItem> AsOpenAIResponseItems(this IEnumerable<ChatMessage> messages, ChatOptions? options = null) =>
28+
OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages), options);
2629

2730
/// <summary>Creates a Microsoft.Extensions.AI <see cref="ChatResponse"/> from an <see cref="OpenAIResponse"/>.</summary>
2831
/// <param name="response">The <see cref="OpenAIResponse"/> to convert to a <see cref="ChatResponse"/>.</param>
2932
/// <param name="options">The options employed in the creation of the response.</param>
3033
/// <returns>A converted <see cref="ChatResponse"/>.</returns>
34+
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
3135
public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) =>
3236
OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options);
37+
38+
/// <summary>
39+
/// Creates a sequence of Microsoft.Extensions.AI <see cref="ChatResponseUpdate"/> instances from the specified
40+
/// sequence of OpenAI <see cref="StreamingResponseUpdate"/> instances.
41+
/// </summary>
42+
/// <param name="responseUpdates">The update instances.</param>
43+
/// <param name="options">The options employed in the creation of the response.</param>
44+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
45+
/// <returns>A sequence of converted <see cref="ChatResponseUpdate"/> instances.</returns>
46+
/// <exception cref="ArgumentNullException"><paramref name="responseUpdates"/> is <see langword="null"/>.</exception>
47+
public static IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
48+
this IAsyncEnumerable<StreamingResponseUpdate> responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) =>
49+
OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, cancellationToken);
3350
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ private static List<ChatMessageContentPart> ToOpenAIChatContent(IList<AIContent>
289289
return null;
290290
}
291291

292-
private static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingChatCompletionAsync(
292+
internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingChatCompletionAsync(
293293
IAsyncEnumerable<StreamingChatCompletionUpdate> updates,
294294
ChatCompletionOptions? options,
295295
[EnumeratorCancellation] CancellationToken cancellationToken)

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public async Task<ChatResponse> GetResponseAsync(
7272
_ = Throw.IfNull(messages);
7373

7474
// Convert the inputs into what OpenAIResponseClient expects.
75-
var openAIResponseItems = ToOpenAIResponseItems(messages);
75+
var openAIResponseItems = ToOpenAIResponseItems(messages, options);
7676
var openAIOptions = ToOpenAIResponseCreationOptions(options);
7777

7878
// Make the call to the OpenAIResponseClient.
@@ -147,6 +147,15 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R
147147
message.Contents.Add(fcc);
148148
break;
149149

150+
case FunctionCallOutputResponseItem functionCallOutput:
151+
message.Contents.Add(new FunctionResultContent(
152+
functionCallOutput.CallId,
153+
functionCallOutput.FunctionOutput)
154+
{
155+
RawRepresentation = outputItem,
156+
});
157+
break;
158+
150159
default:
151160
message.Contents.Add(new()
152161
{
@@ -166,16 +175,22 @@ internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, R
166175
}
167176

168177
/// <inheritdoc />
169-
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
170-
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
178+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
179+
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
171180
{
172181
_ = Throw.IfNull(messages);
173182

174-
// Convert the inputs into what OpenAIResponseClient expects.
175-
var openAIResponseItems = ToOpenAIResponseItems(messages);
183+
var openAIResponseItems = ToOpenAIResponseItems(messages, options);
176184
var openAIOptions = ToOpenAIResponseCreationOptions(options);
177185

178-
// Make the call to the OpenAIResponseClient and process the streaming results.
186+
var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken);
187+
188+
return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken);
189+
}
190+
191+
internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingResponseUpdatesAsync(
192+
IAsyncEnumerable<StreamingResponseUpdate> streamingResponseUpdates, ResponseCreationOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken = default)
193+
{
179194
DateTimeOffset? createdAt = null;
180195
string? responseId = null;
181196
string? conversationId = null;
@@ -184,14 +199,15 @@ public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
184199
ChatRole? lastRole = null;
185200
Dictionary<int, MessageResponseItem> outputIndexToMessages = [];
186201
Dictionary<int, FunctionCallInfo>? functionCallInfos = null;
187-
await foreach (var streamingUpdate in _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false))
202+
203+
await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false))
188204
{
189205
switch (streamingUpdate)
190206
{
191207
case StreamingResponseCreatedUpdate createdUpdate:
192208
createdAt = createdUpdate.Response.CreatedAt;
193209
responseId = createdUpdate.Response.Id;
194-
conversationId = openAIOptions.StoredOutputEnabled is false ? null : responseId;
210+
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
195211
modelId = createdUpdate.Response.Model;
196212
goto default;
197213

@@ -478,8 +494,10 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
478494
}
479495

480496
/// <summary>Convert a sequence of <see cref="ChatMessage"/>s to <see cref="ResponseItem"/>s.</summary>
481-
internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<ChatMessage> inputs)
497+
internal static IEnumerable<ResponseItem> ToOpenAIResponseItems(IEnumerable<ChatMessage> inputs, ChatOptions? options)
482498
{
499+
_ = options; // currently unused
500+
483501
foreach (ChatMessage input in inputs)
484502
{
485503
if (input.Role == ChatRole.System ||

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.ComponentModel;
77
using System.Linq;
88
using System.Text.Json;
9+
using System.Threading.Tasks;
910
using OpenAI.Assistants;
1011
using OpenAI.Chat;
1112
using OpenAI.Realtime;
@@ -77,8 +78,10 @@ private static void ValidateSchemaParameters(BinaryData parameters)
7778
Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString());
7879
}
7980

80-
[Fact]
81-
public void AsOpenAIChatMessages_ProducesExpectedOutput()
81+
[Theory]
82+
[InlineData(false)]
83+
[InlineData(true)]
84+
public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions)
8285
{
8386
Assert.Throws<ArgumentNullException>("messages", () => ((IEnumerable<ChatMessage>)null!).AsOpenAIChatMessages());
8487

@@ -99,17 +102,31 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput()
99102
new(ChatRole.Assistant, "The answer is 42."),
100103
];
101104

102-
var convertedMessages = messages.AsOpenAIChatMessages().ToArray();
105+
ChatOptions? options = withOptions ? new ChatOptions { Instructions = "You talk like a parrot." } : null;
106+
107+
var convertedMessages = messages.AsOpenAIChatMessages(options).ToArray();
103108

104-
Assert.Equal(5, convertedMessages.Length);
109+
int index = 0;
110+
if (withOptions)
111+
{
112+
Assert.Equal(6, convertedMessages.Length);
113+
114+
index = 1;
115+
SystemChatMessage instructionsMessage = Assert.IsType<SystemChatMessage>(convertedMessages[0]);
116+
Assert.Equal("You talk like a parrot.", Assert.Single(instructionsMessage.Content).Text);
117+
}
118+
else
119+
{
120+
Assert.Equal(5, convertedMessages.Length);
121+
}
105122

106-
SystemChatMessage m0 = Assert.IsType<SystemChatMessage>(convertedMessages[0]);
123+
SystemChatMessage m0 = Assert.IsType<SystemChatMessage>(convertedMessages[index]);
107124
Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text);
108125

109-
UserChatMessage m1 = Assert.IsType<UserChatMessage>(convertedMessages[1]);
126+
UserChatMessage m1 = Assert.IsType<UserChatMessage>(convertedMessages[index + 1]);
110127
Assert.Equal("Hello", Assert.Single(m1.Content).Text);
111128

112-
AssistantChatMessage m2 = Assert.IsType<AssistantChatMessage>(convertedMessages[2]);
129+
AssistantChatMessage m2 = Assert.IsType<AssistantChatMessage>(convertedMessages[index + 2]);
113130
Assert.Single(m2.Content);
114131
Assert.Equal("Hi there!", m2.Content[0].Text);
115132
var tc = Assert.Single(m2.ToolCalls);
@@ -121,11 +138,11 @@ public void AsOpenAIChatMessages_ProducesExpectedOutput()
121138
["param2"] = 42
122139
}), JsonSerializer.Deserialize<JsonElement>(tc.FunctionArguments.ToMemory().Span)));
123140

124-
ToolChatMessage m3 = Assert.IsType<ToolChatMessage>(convertedMessages[3]);
141+
ToolChatMessage m3 = Assert.IsType<ToolChatMessage>(convertedMessages[index + 3]);
125142
Assert.Equal("callid123", m3.ToolCallId);
126143
Assert.Equal("theresult", Assert.Single(m3.Content).Text);
127144

128-
AssistantChatMessage m4 = Assert.IsType<AssistantChatMessage>(convertedMessages[4]);
145+
AssistantChatMessage m4 = Assert.IsType<AssistantChatMessage>(convertedMessages[index + 4]);
129146
Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text);
130147
}
131148

@@ -216,4 +233,69 @@ public void AsChatResponse_ConvertsOpenAIChatCompletion()
216233
Assert.Equal("http://example.com/image.png", Assert.IsType<UriContent>(message.Contents[1]).Uri.ToString());
217234
Assert.Equal("functionName", Assert.IsType<FunctionCallContent>(message.Contents[2]).Name);
218235
}
236+
237+
[Fact]
238+
public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates()
239+
{
240+
Assert.Throws<ArgumentNullException>("chatCompletionUpdates", () => ((IAsyncEnumerable<StreamingChatCompletionUpdate>)null!).AsChatResponseUpdatesAsync());
241+
242+
List<ChatResponseUpdate> updates = [];
243+
await foreach (var update in CreateUpdates().AsChatResponseUpdatesAsync())
244+
{
245+
updates.Add(update);
246+
}
247+
248+
ChatResponse response = updates.ToChatResponse();
249+
250+
Assert.Equal("id", response.ResponseId);
251+
Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason);
252+
Assert.Equal("model123", response.ModelId);
253+
Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt);
254+
Assert.NotNull(response.Usage);
255+
Assert.Equal(1, response.Usage.InputTokenCount);
256+
Assert.Equal(2, response.Usage.OutputTokenCount);
257+
Assert.Equal(3, response.Usage.TotalTokenCount);
258+
259+
ChatMessage message = Assert.Single(response.Messages);
260+
Assert.Equal(ChatRole.Assistant, message.Role);
261+
262+
Assert.Equal(3, message.Contents.Count);
263+
Assert.Equal("Hello, world!", Assert.IsType<TextContent>(message.Contents[0]).Text);
264+
Assert.Equal("http://example.com/image.png", Assert.IsType<UriContent>(message.Contents[1]).Uri.ToString());
265+
Assert.Equal("functionName", Assert.IsType<FunctionCallContent>(message.Contents[2]).Name);
266+
267+
static async IAsyncEnumerable<StreamingChatCompletionUpdate> CreateUpdates()
268+
{
269+
await Task.Yield();
270+
yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate(
271+
"id",
272+
new ChatMessageContent(
273+
ChatMessageContentPart.CreateTextPart("Hello, world!"),
274+
ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))),
275+
null,
276+
[OpenAIChatModelFactory.StreamingChatToolCallUpdate(0, "id", ChatToolCallKind.Function, "functionName", BinaryData.FromString("test"))],
277+
ChatMessageRole.Assistant,
278+
null, null, null, OpenAI.Chat.ChatFinishReason.ToolCalls, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
279+
"model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3));
280+
}
281+
}
282+
283+
[Fact]
284+
public void AsChatResponse_ConvertsOpenAIResponse()
285+
{
286+
Assert.Throws<ArgumentNullException>("response", () => ((OpenAIResponse)null!).AsChatResponse());
287+
288+
// The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance,
289+
// as all constructors/factory methods currently are internal. Update this test when such functionality is available.
290+
}
291+
292+
[Fact]
293+
public void AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates()
294+
{
295+
Assert.Throws<ArgumentNullException>("responseUpdates", () => ((IAsyncEnumerable<StreamingResponseUpdate>)null!).AsChatResponseUpdatesAsync());
296+
297+
// The OpenAI library currently doesn't provide any way to create a StreamingResponseUpdate instance,
298+
// as all constructors/factory methods currently are internal. Update this test when such functionality is available.
299+
}
300+
219301
}

0 commit comments

Comments
 (0)