mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
.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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user