.Net ChatHistory Streaming Message Helper API (#4167)

- Added missing properties to OpenAIStreamingChatMessageContent
- Remove code duplication in Unit Tests
- Resolves #3961
This commit is contained in:
Roger Barreto
2023-12-13 17:45:44 +00:00
committed by GitHub
parent 0d434be233
commit a14d1e58b6
11 changed files with 195 additions and 36 deletions
+1
View File
@@ -23,6 +23,7 @@ You can use the following diagnostic IDs to ignore warnings or errors for a part
- SKEXP0011: OpenAI embedding service
- SKEXP0012: OpenAI image service
- SKEXP0013: OpenAI parameters
- SKEXP0014: OpenAI chat history extension
## Memory connectors
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Threading.Tasks;
using Azure.AI.OpenAI;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
namespace Microsoft.SemanticKernel;
/// <summary>
/// Chat history extensions.
/// </summary>
public static class ChatHistoryExtensions
{
/// <summary>
/// Add a message to the chat history at the end of the streamed message
/// </summary>
/// <param name="chatHistory">Target chat history</param>
/// <param name="streamingMessageContents"><see cref="IAsyncEnumerator{T}"/> list of streaming message contents</param>
/// <returns>Returns the original streaming results with some message processing</returns>
[Experimental("SKEXP0014")]
public static async IAsyncEnumerable<StreamingChatMessageContent> AddStreamingMessageAsync(this ChatHistory chatHistory, IAsyncEnumerable<OpenAIStreamingChatMessageContent> streamingMessageContents)
{
List<StreamingChatMessageContent> messageContents = new();
// Stream the response.
StringBuilder contentBuilder = new();
List<ChatCompletionsFunctionToolCall>? functionCallResponses = null;
Dictionary<string, object?>? metadata = null;
AuthorRole? streamedRole = default;
await foreach (var chatMessage in streamingMessageContents.ConfigureAwait(false))
{
if (metadata is null && chatMessage.Metadata is not null)
{
metadata = (Dictionary<string, object?>)chatMessage.Metadata;
}
if (chatMessage.Content is { Length: > 0 } contentUpdate)
{
contentBuilder.Append(contentUpdate);
}
if (chatMessage.ToolCallUpdate is ChatCompletionsFunctionToolCall functionToolCall)
{
if (functionToolCall.Id is not null &&
(functionCallResponses is not { Count: > 0 } || functionCallResponses[functionCallResponses.Count - 1].Id != functionToolCall.Id))
{
// If this update has a tool ID and that ID is not the same as the last one we saw,
// add this as a new function call.
(functionCallResponses ??= new()).Add(functionToolCall);
}
else if (functionCallResponses is { Count: > 0 })
{
// Augment the last function call with the new additional information.
ChatCompletionsFunctionToolCall last = functionCallResponses[functionCallResponses.Count - 1];
last.Name ??= functionToolCall.Name;
last.Arguments += functionToolCall.Arguments;
}
}
// Is always expected to have at least one chunk with the role provided from a streaming message
streamedRole ??= chatMessage.Role;
messageContents.Add(chatMessage);
yield return chatMessage;
}
if (messageContents.Count != 0)
{
chatHistory.AddMessage(new OpenAIChatMessageContent(
streamedRole ?? AuthorRole.Assistant,
contentBuilder.ToString(),
messageContents[0].ModelId!,
functionCallResponses ?? new List<ChatCompletionsFunctionToolCall>(),
metadata));
}
}
}
@@ -38,13 +38,23 @@ public sealed class OpenAIChatMessageContent : ChatMessageContent
/// <summary>
/// Initializes a new instance of the <see cref="OpenAIChatMessageContent"/> class.
/// </summary>
internal OpenAIChatMessageContent(ChatRole role, string content, string modelId, IReadOnlyList<ChatCompletionsToolCall> toolCalls, Dictionary<string, object?>? metadata = null)
internal OpenAIChatMessageContent(ChatRole role, string? content, string modelId, IReadOnlyList<ChatCompletionsToolCall> toolCalls, Dictionary<string, object?>? metadata = null)
: base(new AuthorRole(role.ToString()), content, modelId, content, System.Text.Encoding.UTF8, metadata ?? new Dictionary<string, object?>(1))
{
this.ToolCalls = toolCalls;
this.Metadata![ToolCallsProperty] = toolCalls;
}
/// <summary>
/// Initializes a new instance of the <see cref="OpenAIChatMessageContent"/> class.
/// </summary>
internal OpenAIChatMessageContent(AuthorRole role, string? content, string modelId, IReadOnlyList<ChatCompletionsToolCall> toolCalls, Dictionary<string, object?>? metadata = null)
: base(role, content, modelId, content, System.Text.Encoding.UTF8, metadata ?? new Dictionary<string, object?>(1))
{
this.ToolCalls = toolCalls;
this.Metadata![ToolCallsProperty] = toolCalls;
}
/// <summary>
/// A list of the tools called by the model.
/// </summary>
@@ -15,6 +15,11 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI;
/// </remarks>
public sealed class OpenAIStreamingChatMessageContent : StreamingChatMessageContent
{
/// <summary>
/// The reason why the completion finished.
/// </summary>
public CompletionsFinishReason? FinishReason { get; set; }
/// <summary>
/// Create a new instance of the <see cref="OpenAIStreamingChatMessageContent"/> class.
/// </summary>
@@ -37,6 +42,38 @@ public sealed class OpenAIStreamingChatMessageContent : StreamingChatMessageCont
metadata)
{
this.ToolCallUpdate = chatUpdate.ToolCallUpdate;
this.FinishReason = chatUpdate?.FinishReason;
}
/// <summary>
/// Create a new instance of the <see cref="OpenAIStreamingChatMessageContent"/> class.
/// </summary>
/// <param name="authorRole">Author role of the message</param>
/// <param name="content">Content of the message</param>
/// <param name="tootToolCallUpdate">Tool call update</param>
/// <param name="completionsFinishReason">Completion finish reason</param>
/// <param name="choiceIndex">Index of the choice</param>
/// <param name="modelId">The model ID used to generate the content</param>
/// <param name="metadata">Additional metadata</param>
internal OpenAIStreamingChatMessageContent(
AuthorRole? authorRole,
string? content,
ChatCompletionsToolCall? tootToolCallUpdate = null,
CompletionsFinishReason? completionsFinishReason = null,
int choiceIndex = 0,
string? modelId = null,
Dictionary<string, object?>? metadata = null)
: base(
authorRole,
content,
null,
choiceIndex,
modelId,
Encoding.UTF8,
metadata)
{
this.ToolCallUpdate = tootToolCallUpdate;
this.FinishReason = completionsFinishReason;
}
/// <summary>Gets any update information in the message about a tool call.</summary>
@@ -6,7 +6,7 @@
<RootNamespace>$(AssemblyName)</RootNamespace>
<TargetFramework>netstandard2.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>$(NoWarn);NU5104;SKEXP0013</NoWarn>
<NoWarn>$(NoWarn);NU5104;SKEXP0013,SKEXP0014</NoWarn>
</PropertyGroup>
<!-- IMPORT NUGET PACKAGE SHARED PROPERTIES -->
@@ -19,6 +19,10 @@
<Description>Semantic Kernel connectors for OpenAI and Azure OpenAI. Contains clients for text generation, chat completion, embedding and DALL-E text to image.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="SemanticKernel.Connectors.UnitTests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Plugins\Plugins.Memory\Plugins.Memory.csproj" />
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
@@ -10,7 +10,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
<NoWarn>CA2007,VSTHRD111,SKEXP0001,SKEXP0002,SKEXP0003,SKEXP0004,SKEXP0010,SKEXP0011,SKEXP0012,SKEXP0013,SKEXP0020,SKEXP0021,SKEXP0022,SKEXP0023,SKEXP0024,SKEXP0025,SKEXP0026,SKEXP0027,SKEXP0028,SKEXP0029,SKEXP0030,SKEXP0031,SKEXP0032,SKEXP0052</NoWarn>
<NoWarn>CA2007,VSTHRD111,SKEXP0001,SKEXP0002,SKEXP0003,SKEXP0004,SKEXP0010,SKEXP0011,SKEXP0012,SKEXP0013,SKEXP0014,SKEXP0020,SKEXP0021,SKEXP0022,SKEXP0023,SKEXP0024,SKEXP0025,SKEXP0026,SKEXP0027,SKEXP0028,SKEXP0029,SKEXP0030,SKEXP0031,SKEXP0032,SKEXP0052</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -32,7 +32,10 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/src/InternalUtilities.props" />
<ItemGroup>
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/test/AssertExtensions.cs"
Link="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Xunit;
namespace SemanticKernel.Connectors.UnitTests.OpenAI;
public class ChatHistoryExtensionsTests
{
[Fact]
public async Task ItCanAddMessageFromStreamingChatContentsAsync()
{
var metadata = new Dictionary<string, object?>()
{
{ "message", "something" },
};
var chatHistoryStreamingContents = new List<OpenAIStreamingChatMessageContent>
{
new(AuthorRole.User, "Hello ", metadata: metadata),
new(null, ", ", metadata: metadata),
new(null, "I ", metadata: metadata),
new(null, "am ", metadata : metadata),
new(null, "a ", metadata : metadata),
new(null, "test ", metadata : metadata),
}.ToAsyncEnumerable();
var chatHistory = new ChatHistory();
var finalContent = "Hello , I am a test ";
string processedContent = string.Empty;
await foreach (var chatMessageChunk in chatHistory.AddStreamingMessageAsync(chatHistoryStreamingContents))
{
processedContent += chatMessageChunk.Content;
}
Assert.Single(chatHistory);
Assert.Equal(finalContent, processedContent);
Assert.Equal(finalContent, chatHistory[0].Content);
Assert.Equal(AuthorRole.User, chatHistory[0].Role);
Assert.Equal(metadata["message"], chatHistory[0].Metadata!["message"]);
}
}
@@ -45,7 +45,7 @@ public class ChatMessageContent : KernelContent
[JsonConstructor]
public ChatMessageContent(
AuthorRole role,
string content,
string? content,
string? modelId = null,
object? innerContent = null,
Encoding? encoding = null,
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
@@ -217,7 +218,7 @@ public class KernelFunctionFromPromptTests
{
var mockService = new Mock<ITextGenerationService>();
var mockResult = mockService.Setup(s => s.GetStreamingTextContentsAsync(It.IsAny<string>(), It.IsAny<PromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>()))
.Returns(this.ToAsyncEnumerable<StreamingTextContent>(new List<StreamingTextContent>() { new("something") }));
.Returns((new List<StreamingTextContent>() { new("something") }).ToAsyncEnumerable());
IKernelBuilder builder = Kernel.CreateBuilder();
builder.Services.AddTransient<ITextGenerationService>((sp) => mockService.Object);
@@ -307,7 +308,7 @@ public class KernelFunctionFromPromptTests
var expectedContent = new StreamingTextContent("something");
var mockService = new Mock<ITextGenerationService>();
var mockResult = mockService.Setup(s => s.GetStreamingTextContentsAsync(It.IsAny<string>(), It.IsAny<PromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>()))
.Returns(this.ToAsyncEnumerable<StreamingTextContent>(new List<StreamingTextContent>() { expectedContent }));
.Returns((new List<StreamingTextContent>() { expectedContent }).ToAsyncEnumerable());
KernelBuilder builder = new();
builder.Services.AddTransient<ITextGenerationService>((sp) => mockService.Object);
@@ -332,7 +333,7 @@ public class KernelFunctionFromPromptTests
var expectedContent = new StreamingChatMessageContent(AuthorRole.Assistant, "Something");
var mockService = new Mock<IChatCompletionService>();
var mockResult = mockService.Setup(s => s.GetStreamingChatMessageContentsAsync(It.IsAny<ChatHistory>(), It.IsAny<PromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>()))
.Returns(this.ToAsyncEnumerable<StreamingChatMessageContent>(new List<StreamingChatMessageContent>() { expectedContent }));
.Returns((new List<StreamingChatMessageContent>() { expectedContent }).ToAsyncEnumerable());
KernelBuilder builder = new();
builder.Services.AddTransient<IChatCompletionService>((sp) => mockService.Object);
@@ -554,19 +555,6 @@ public class KernelFunctionFromPromptTests
// Assert
mockTextCompletion.Verify(m => m.GetTextContentsAsync("Prompt USE SHORT, CLEAR, COMPLETE SENTENCES.", It.IsAny<OpenAIPromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>()), Times.Once());
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
#pragma warning disable IDE1006 // Naming Styles
private async IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<T> enumeration)
#pragma warning restore IDE1006 // Naming Styles
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
foreach (var enumerationItem in enumeration)
{
yield return enumerationItem;
}
}
private sealed class FakeChatAsTextService : ITextGenerationService, IChatCompletionService
{
public IReadOnlyDictionary<string, object?> Attributes => throw new NotImplementedException();
@@ -649,23 +649,11 @@ public class KernelTests
private Mock<ITextGenerationService> SetupStreamingMocks(params StreamingTextContent[] streamingContents)
{
var mockTextCompletion = new Mock<ITextGenerationService>();
mockTextCompletion.Setup(m => m.GetStreamingTextContentsAsync(It.IsAny<string>(), It.IsAny<PromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>())).Returns(this.ToAsyncEnumerable(streamingContents));
mockTextCompletion.Setup(m => m.GetStreamingTextContentsAsync(It.IsAny<string>(), It.IsAny<PromptExecutionSettings>(), It.IsAny<Kernel>(), It.IsAny<CancellationToken>())).Returns(streamingContents.ToAsyncEnumerable());
return mockTextCompletion;
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
#pragma warning disable IDE1006 // Naming Styles
private async IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<T> enumeration)
#pragma warning restore IDE1006 // Naming Styles
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
foreach (var enumerationItem in enumeration)
{
yield return enumerationItem;
}
}
public class MyPlugin
{
[KernelFunction, Description("Return any value.")]
@@ -11,8 +11,6 @@
<NoWarn>CA2007,VSTHRD111,SKEXP0001,SKEXP0002,SKEXP0003,SKEXP0004,SKEXP0055</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/src/InternalUtilities/test/TestInternalUtilities.props" />
<ItemGroup>
<!-- Use newest available compiler to handle newer source-generating libraries used in the examples. -->
<!-- This can be removed once we no longer target the .NET 6 SDK in CI. -->
@@ -43,4 +41,8 @@
<ProjectReference Include="..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="$(RepoRoot)/dotnet/src/InternalUtilities/test/AssertExtensions.cs" Link="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
</Project>