diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/.editorconfig b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/.editorconfig new file mode 100644 index 000000000000..394eef685f21 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/.editorconfig @@ -0,0 +1,6 @@ +# Suppressing errors for Test projects under dotnet folder +[*.cs] +dotnet_diagnostic.CA2007.severity = none # Do not directly await a Task +dotnet_diagnostic.VSTHRD111.severity = none # Use .ConfigureAwait(bool) is hidden by default, set to none to prevent IDE from changing on autosave +dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member +dotnet_diagnostic.IDE1006.severity = warning # Naming rule violations diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAIPromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAIPromptExecutionSettingsTests.cs new file mode 100644 index 000000000000..0cf1c4e2a9e3 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAIPromptExecutionSettingsTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests; + +/// +/// Unit tests of AzureOpenAIPromptExecutionSettingsTests +/// +public class AzureOpenAIPromptExecutionSettingsTests +{ + [Fact] + public void ItCreatesOpenAIExecutionSettingsWithCorrectDefaults() + { + // Arrange + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(null, 128); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(1, executionSettings.Temperature); + Assert.Equal(1, executionSettings.TopP); + Assert.Equal(0, executionSettings.FrequencyPenalty); + Assert.Equal(0, executionSettings.PresencePenalty); + Assert.Equal(1, executionSettings.ResultsPerPrompt); + Assert.Null(executionSettings.StopSequences); + Assert.Null(executionSettings.TokenSelectionBiases); + Assert.Null(executionSettings.TopLogprobs); + Assert.Null(executionSettings.Logprobs); + Assert.Null(executionSettings.AzureChatExtensionsOptions); + Assert.Equal(128, executionSettings.MaxTokens); + } + + [Fact] + public void ItUsesExistingOpenAIExecutionSettings() + { + // Arrange + AzureOpenAIPromptExecutionSettings actualSettings = new() + { + Temperature = 0.7, + TopP = 0.7, + FrequencyPenalty = 0.7, + PresencePenalty = 0.7, + ResultsPerPrompt = 2, + StopSequences = new string[] { "foo", "bar" }, + ChatSystemPrompt = "chat system prompt", + MaxTokens = 128, + Logprobs = true, + TopLogprobs = 5, + TokenSelectionBiases = new Dictionary() { { 1, 2 }, { 3, 4 } }, + }; + + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(actualSettings); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(actualSettings, executionSettings); + } + + [Fact] + public void ItCanUseOpenAIExecutionSettings() + { + // Arrange + PromptExecutionSettings actualSettings = new() + { + ExtensionData = new Dictionary() { + { "max_tokens", 1000 }, + { "temperature", 0 } + } + }; + + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(actualSettings, null); + + // Assert + Assert.NotNull(executionSettings); + Assert.Equal(1000, executionSettings.MaxTokens); + Assert.Equal(0, executionSettings.Temperature); + } + + [Fact] + public void ItCreatesOpenAIExecutionSettingsFromExtraPropertiesSnakeCase() + { + // Arrange + PromptExecutionSettings actualSettings = new() + { + ExtensionData = new Dictionary() + { + { "temperature", 0.7 }, + { "top_p", 0.7 }, + { "frequency_penalty", 0.7 }, + { "presence_penalty", 0.7 }, + { "results_per_prompt", 2 }, + { "stop_sequences", new [] { "foo", "bar" } }, + { "chat_system_prompt", "chat system prompt" }, + { "max_tokens", 128 }, + { "token_selection_biases", new Dictionary() { { 1, 2 }, { 3, 4 } } }, + { "seed", 123456 }, + { "logprobs", true }, + { "top_logprobs", 5 }, + } + }; + + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(actualSettings, null); + + // Assert + AssertExecutionSettings(executionSettings); + } + + [Fact] + public void ItCreatesOpenAIExecutionSettingsFromExtraPropertiesAsStrings() + { + // Arrange + PromptExecutionSettings actualSettings = new() + { + ExtensionData = new Dictionary() + { + { "temperature", "0.7" }, + { "top_p", "0.7" }, + { "frequency_penalty", "0.7" }, + { "presence_penalty", "0.7" }, + { "results_per_prompt", "2" }, + { "stop_sequences", new [] { "foo", "bar" } }, + { "chat_system_prompt", "chat system prompt" }, + { "max_tokens", "128" }, + { "token_selection_biases", new Dictionary() { { "1", "2" }, { "3", "4" } } }, + { "seed", 123456 }, + { "logprobs", true }, + { "top_logprobs", 5 } + } + }; + + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(actualSettings, null); + + // Assert + AssertExecutionSettings(executionSettings); + } + + [Fact] + public void ItCreatesOpenAIExecutionSettingsFromJsonSnakeCase() + { + // Arrange + var json = """ + { + "temperature": 0.7, + "top_p": 0.7, + "frequency_penalty": 0.7, + "presence_penalty": 0.7, + "results_per_prompt": 2, + "stop_sequences": [ "foo", "bar" ], + "chat_system_prompt": "chat system prompt", + "token_selection_biases": { "1": 2, "3": 4 }, + "max_tokens": 128, + "seed": 123456, + "logprobs": true, + "top_logprobs": 5 + } + """; + var actualSettings = JsonSerializer.Deserialize(json); + + // Act + AzureOpenAIPromptExecutionSettings executionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(actualSettings); + + // Assert + AssertExecutionSettings(executionSettings); + } + + [Theory] + [InlineData("", "")] + [InlineData("System prompt", "System prompt")] + public void ItUsesCorrectChatSystemPrompt(string chatSystemPrompt, string expectedChatSystemPrompt) + { + // Arrange & Act + var settings = new AzureOpenAIPromptExecutionSettings { ChatSystemPrompt = chatSystemPrompt }; + + // Assert + Assert.Equal(expectedChatSystemPrompt, settings.ChatSystemPrompt); + } + + [Fact] + public void PromptExecutionSettingsCloneWorksAsExpected() + { + // Arrange + string configPayload = """ + { + "max_tokens": 60, + "temperature": 0.5, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } + """; + var executionSettings = JsonSerializer.Deserialize(configPayload); + + // Act + var clone = executionSettings!.Clone(); + + // Assert + Assert.NotNull(clone); + Assert.Equal(executionSettings.ModelId, clone.ModelId); + Assert.Equivalent(executionSettings.ExtensionData, clone.ExtensionData); + } + + [Fact] + public void PromptExecutionSettingsFreezeWorksAsExpected() + { + // Arrange + string configPayload = """ + { + "max_tokens": 60, + "temperature": 0.5, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "stop_sequences": [ "DONE" ], + "token_selection_biases": { "1": 2, "3": 4 } + } + """; + var executionSettings = JsonSerializer.Deserialize(configPayload); + + // Act + executionSettings!.Freeze(); + + // Assert + Assert.True(executionSettings.IsFrozen); + Assert.Throws(() => executionSettings.ModelId = "gpt-4"); + Assert.Throws(() => executionSettings.ResultsPerPrompt = 2); + Assert.Throws(() => executionSettings.Temperature = 1); + Assert.Throws(() => executionSettings.TopP = 1); + Assert.Throws(() => executionSettings.StopSequences?.Add("STOP")); + Assert.Throws(() => executionSettings.TokenSelectionBiases?.Add(5, 6)); + + executionSettings!.Freeze(); // idempotent + Assert.True(executionSettings.IsFrozen); + } + + [Fact] + public void FromExecutionSettingsWithDataDoesNotIncludeEmptyStopSequences() + { + // Arrange + var executionSettings = new AzureOpenAIPromptExecutionSettings { StopSequences = [] }; + + // Act +#pragma warning disable CS0618 // AzureOpenAIChatCompletionWithData is deprecated in favor of OpenAIPromptExecutionSettings.AzureChatExtensionsOptions + var executionSettingsWithData = AzureOpenAIPromptExecutionSettings.FromExecutionSettingsWithData(executionSettings); +#pragma warning restore CS0618 + // Assert + Assert.Null(executionSettingsWithData.StopSequences); + } + + private static void AssertExecutionSettings(AzureOpenAIPromptExecutionSettings executionSettings) + { + Assert.NotNull(executionSettings); + Assert.Equal(0.7, executionSettings.Temperature); + Assert.Equal(0.7, executionSettings.TopP); + Assert.Equal(0.7, executionSettings.FrequencyPenalty); + Assert.Equal(0.7, executionSettings.PresencePenalty); + Assert.Equal(2, executionSettings.ResultsPerPrompt); + Assert.Equal(new string[] { "foo", "bar" }, executionSettings.StopSequences); + Assert.Equal("chat system prompt", executionSettings.ChatSystemPrompt); + Assert.Equal(new Dictionary() { { 1, 2 }, { 3, 4 } }, executionSettings.TokenSelectionBiases); + Assert.Equal(128, executionSettings.MaxTokens); + Assert.Equal(123456, executionSettings.Seed); + Assert.Equal(true, executionSettings.Logprobs); + Assert.Equal(5, executionSettings.TopLogprobs); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAITestHelper.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAITestHelper.cs new file mode 100644 index 000000000000..9df4aae40c2d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureOpenAITestHelper.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests; + +/// +/// Helper for AzureOpenAI test purposes. +/// +internal static class AzureOpenAITestHelper +{ + /// + /// Reads test response from file for mocking purposes. + /// + /// Name of the file with test response. + internal static string GetTestResponse(string fileName) + { + return File.ReadAllText($"./TestData/{fileName}"); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureToolCallBehaviorTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureToolCallBehaviorTests.cs new file mode 100644 index 000000000000..525dabcd26d2 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/AzureToolCallBehaviorTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using static Microsoft.SemanticKernel.Connectors.AzureOpenAI.AzureToolCallBehavior; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests; + +/// +/// Unit tests for +/// +public sealed class AzureToolCallBehaviorTests +{ + [Fact] + public void EnableKernelFunctionsReturnsCorrectKernelFunctionsInstance() + { + // Arrange & Act + var behavior = AzureToolCallBehavior.EnableKernelFunctions; + + // Assert + Assert.IsType(behavior); + Assert.Equal(0, behavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void AutoInvokeKernelFunctionsReturnsCorrectKernelFunctionsInstance() + { + // Arrange & Act + const int DefaultMaximumAutoInvokeAttempts = 128; + var behavior = AzureToolCallBehavior.AutoInvokeKernelFunctions; + + // Assert + Assert.IsType(behavior); + Assert.Equal(DefaultMaximumAutoInvokeAttempts, behavior.MaximumAutoInvokeAttempts); + } + + [Fact] + public void EnableFunctionsReturnsEnabledFunctionsInstance() + { + // Arrange & Act + List functions = [new("Plugin", "Function", "description", [], null)]; + var behavior = AzureToolCallBehavior.EnableFunctions(functions); + + // Assert + Assert.IsType(behavior); + } + + [Fact] + public void RequireFunctionReturnsRequiredFunctionInstance() + { + // Arrange & Act + var behavior = AzureToolCallBehavior.RequireFunction(new("Plugin", "Function", "description", [], null)); + + // Assert + Assert.IsType(behavior); + } + + [Fact] + public void KernelFunctionsConfigureOptionsWithNullKernelDoesNotAddTools() + { + // Arrange + var kernelFunctions = new KernelFunctions(autoInvoke: false); + var chatCompletionsOptions = new ChatCompletionsOptions(); + + // Act + kernelFunctions.ConfigureOptions(null, chatCompletionsOptions); + + // Assert + Assert.Empty(chatCompletionsOptions.Tools); + } + + [Fact] + public void KernelFunctionsConfigureOptionsWithoutFunctionsDoesNotAddTools() + { + // Arrange + var kernelFunctions = new KernelFunctions(autoInvoke: false); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var kernel = Kernel.CreateBuilder().Build(); + + // Act + kernelFunctions.ConfigureOptions(kernel, chatCompletionsOptions); + + // Assert + Assert.Null(chatCompletionsOptions.ToolChoice); + Assert.Empty(chatCompletionsOptions.Tools); + } + + [Fact] + public void KernelFunctionsConfigureOptionsWithFunctionsAddsTools() + { + // Arrange + var kernelFunctions = new KernelFunctions(autoInvoke: false); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var kernel = Kernel.CreateBuilder().Build(); + + var plugin = this.GetTestPlugin(); + + kernel.Plugins.Add(plugin); + + // Act + kernelFunctions.ConfigureOptions(kernel, chatCompletionsOptions); + + // Assert + Assert.Equal(ChatCompletionsToolChoice.Auto, chatCompletionsOptions.ToolChoice); + + this.AssertTools(chatCompletionsOptions); + } + + [Fact] + public void EnabledFunctionsConfigureOptionsWithoutFunctionsDoesNotAddTools() + { + // Arrange + var enabledFunctions = new EnabledFunctions([], autoInvoke: false); + var chatCompletionsOptions = new ChatCompletionsOptions(); + + // Act + enabledFunctions.ConfigureOptions(null, chatCompletionsOptions); + + // Assert + Assert.Null(chatCompletionsOptions.ToolChoice); + Assert.Empty(chatCompletionsOptions.Tools); + } + + [Fact] + public void EnabledFunctionsConfigureOptionsWithAutoInvokeAndNullKernelThrowsException() + { + // Arrange + var functions = this.GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToAzureOpenAIFunction()); + var enabledFunctions = new EnabledFunctions(functions, autoInvoke: true); + var chatCompletionsOptions = new ChatCompletionsOptions(); + + // Act & Assert + var exception = Assert.Throws(() => enabledFunctions.ConfigureOptions(null, chatCompletionsOptions)); + Assert.Equal($"Auto-invocation with {nameof(EnabledFunctions)} is not supported when no kernel is provided.", exception.Message); + } + + [Fact] + public void EnabledFunctionsConfigureOptionsWithAutoInvokeAndEmptyKernelThrowsException() + { + // Arrange + var functions = this.GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToAzureOpenAIFunction()); + var enabledFunctions = new EnabledFunctions(functions, autoInvoke: true); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var kernel = Kernel.CreateBuilder().Build(); + + // Act & Assert + var exception = Assert.Throws(() => enabledFunctions.ConfigureOptions(kernel, chatCompletionsOptions)); + Assert.Equal($"The specified {nameof(EnabledFunctions)} function MyPlugin-MyFunction is not available in the kernel.", exception.Message); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EnabledFunctionsConfigureOptionsWithKernelAndPluginsAddsTools(bool autoInvoke) + { + // Arrange + var plugin = this.GetTestPlugin(); + var functions = plugin.GetFunctionsMetadata().Select(function => function.ToAzureOpenAIFunction()); + var enabledFunctions = new EnabledFunctions(functions, autoInvoke); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var kernel = Kernel.CreateBuilder().Build(); + + kernel.Plugins.Add(plugin); + + // Act + enabledFunctions.ConfigureOptions(kernel, chatCompletionsOptions); + + // Assert + Assert.Equal(ChatCompletionsToolChoice.Auto, chatCompletionsOptions.ToolChoice); + + this.AssertTools(chatCompletionsOptions); + } + + [Fact] + public void RequiredFunctionsConfigureOptionsWithAutoInvokeAndNullKernelThrowsException() + { + // Arrange + var function = this.GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToAzureOpenAIFunction()).First(); + var requiredFunction = new RequiredFunction(function, autoInvoke: true); + var chatCompletionsOptions = new ChatCompletionsOptions(); + + // Act & Assert + var exception = Assert.Throws(() => requiredFunction.ConfigureOptions(null, chatCompletionsOptions)); + Assert.Equal($"Auto-invocation with {nameof(RequiredFunction)} is not supported when no kernel is provided.", exception.Message); + } + + [Fact] + public void RequiredFunctionsConfigureOptionsWithAutoInvokeAndEmptyKernelThrowsException() + { + // Arrange + var function = this.GetTestPlugin().GetFunctionsMetadata().Select(function => function.ToAzureOpenAIFunction()).First(); + var requiredFunction = new RequiredFunction(function, autoInvoke: true); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var kernel = Kernel.CreateBuilder().Build(); + + // Act & Assert + var exception = Assert.Throws(() => requiredFunction.ConfigureOptions(kernel, chatCompletionsOptions)); + Assert.Equal($"The specified {nameof(RequiredFunction)} function MyPlugin-MyFunction is not available in the kernel.", exception.Message); + } + + [Fact] + public void RequiredFunctionConfigureOptionsAddsTools() + { + // Arrange + var plugin = this.GetTestPlugin(); + var function = plugin.GetFunctionsMetadata()[0].ToAzureOpenAIFunction(); + var chatCompletionsOptions = new ChatCompletionsOptions(); + var requiredFunction = new RequiredFunction(function, autoInvoke: true); + var kernel = new Kernel(); + kernel.Plugins.Add(plugin); + + // Act + requiredFunction.ConfigureOptions(kernel, chatCompletionsOptions); + + // Assert + Assert.NotNull(chatCompletionsOptions.ToolChoice); + + this.AssertTools(chatCompletionsOptions); + } + + private KernelPlugin GetTestPlugin() + { + var function = KernelFunctionFactory.CreateFromMethod( + (string parameter1, string parameter2) => "Result1", + "MyFunction", + "Test Function", + [new KernelParameterMetadata("parameter1"), new KernelParameterMetadata("parameter2")], + new KernelReturnParameterMetadata { ParameterType = typeof(string), Description = "Function Result" }); + + return KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + } + + private void AssertTools(ChatCompletionsOptions chatCompletionsOptions) + { + Assert.Single(chatCompletionsOptions.Tools); + + var tool = chatCompletionsOptions.Tools[0] as ChatCompletionsFunctionToolDefinition; + + Assert.NotNull(tool); + + Assert.Equal("MyPlugin-MyFunction", tool.Name); + Assert.Equal("Test Function", tool.Description); + Assert.Equal("{\"type\":\"object\",\"required\":[],\"properties\":{\"parameter1\":{\"type\":\"string\"},\"parameter2\":{\"type\":\"string\"}}}", tool.Parameters.ToString()); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs new file mode 100644 index 000000000000..69c314bdcb46 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatCompletion/AzureOpenAIChatCompletionServiceTests.cs @@ -0,0 +1,958 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Moq; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.ChatCompletion; + +/// +/// Unit tests for +/// +public sealed class AzureOpenAIChatCompletionServiceTests : IDisposable +{ + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + private readonly Mock _mockLoggerFactory; + + public AzureOpenAIChatCompletionServiceTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + this._mockLoggerFactory = new Mock(); + + var mockLogger = new Mock(); + + mockLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + this._mockLoggerFactory.Setup(l => l.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithApiKeyWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithTokenCredentialWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var credentials = DelegatedTokenCredential.Create((_, _) => new AccessToken()); + var service = includeLoggerFactory ? + new AzureOpenAIChatCompletionService("deployment", "https://endpoint", credentials, "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAIChatCompletionService("deployment", "https://endpoint", credentials, "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorWithOpenAIClientWorksCorrectly(bool includeLoggerFactory) + { + // Arrange & Act + var client = new OpenAIClient("key"); + var service = includeLoggerFactory ? + new AzureOpenAIChatCompletionService("deployment", client, "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAIChatCompletionService("deployment", client, "model-id"); + + // Assert + Assert.NotNull(service); + Assert.Equal("model-id", service.Attributes["ModelId"]); + } + + [Fact] + public async Task GetTextContentsWorksCorrectlyAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + // Act + var result = await service.GetTextContentsAsync("Prompt"); + + // Assert + Assert.True(result.Count > 0); + Assert.Equal("Test chat response", result[0].Text); + + var usage = result[0].Metadata?["Usage"] as CompletionsUsage; + + Assert.NotNull(usage); + Assert.Equal(55, usage.PromptTokens); + Assert.Equal(100, usage.CompletionTokens); + Assert.Equal(155, usage.TotalTokens); + } + + [Fact] + public async Task GetChatMessageContentsWithEmptyChoicesThrowsExceptionAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"response-id\",\"object\":\"chat.completion\",\"created\":1704208954,\"model\":\"gpt-4\",\"choices\":[],\"usage\":{\"prompt_tokens\":55,\"completion_tokens\":100,\"total_tokens\":155},\"system_fingerprint\":null}") + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetChatMessageContentsAsync([])); + + Assert.Equal("Chat completions not found", exception.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(129)] + public async Task GetChatMessageContentsWithInvalidResultsPerPromptValueThrowsExceptionAsync(int resultsPerPrompt) + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings { ResultsPerPrompt = resultsPerPrompt }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => service.GetChatMessageContentsAsync([], settings)); + + Assert.Contains("The value must be in range between", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetChatMessageContentsHandlesSettingsCorrectlyAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings() + { + MaxTokens = 123, + Temperature = 0.6, + TopP = 0.5, + FrequencyPenalty = 1.6, + PresencePenalty = 1.2, + ResultsPerPrompt = 5, + Seed = 567, + TokenSelectionBiases = new Dictionary { { 2, 3 } }, + StopSequences = ["stop_sequence"], + Logprobs = true, + TopLogprobs = 5, + AzureChatExtensionsOptions = new AzureChatExtensionsOptions + { + Extensions = + { + new AzureSearchChatExtensionConfiguration + { + SearchEndpoint = new Uri("http://test-search-endpoint"), + IndexName = "test-index-name" + } + } + } + }; + + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("User Message"); + chatHistory.AddUserMessage([new ImageContent(new Uri("https://image")), new TextContent("User Message")]); + chatHistory.AddSystemMessage("System Message"); + chatHistory.AddAssistantMessage("Assistant Message"); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + // Act + var result = await service.GetChatMessageContentsAsync(chatHistory, settings); + + // Assert + var requestContent = this._messageHandlerStub.RequestContents[0]; + + Assert.NotNull(requestContent); + + var content = JsonSerializer.Deserialize(Encoding.UTF8.GetString(requestContent)); + + var messages = content.GetProperty("messages"); + + var userMessage = messages[0]; + var userMessageCollection = messages[1]; + var systemMessage = messages[2]; + var assistantMessage = messages[3]; + + Assert.Equal("user", userMessage.GetProperty("role").GetString()); + Assert.Equal("User Message", userMessage.GetProperty("content").GetString()); + + Assert.Equal("user", userMessageCollection.GetProperty("role").GetString()); + var contentItems = userMessageCollection.GetProperty("content"); + Assert.Equal(2, contentItems.GetArrayLength()); + Assert.Equal("https://image/", contentItems[0].GetProperty("image_url").GetProperty("url").GetString()); + Assert.Equal("image_url", contentItems[0].GetProperty("type").GetString()); + Assert.Equal("User Message", contentItems[1].GetProperty("text").GetString()); + Assert.Equal("text", contentItems[1].GetProperty("type").GetString()); + + Assert.Equal("system", systemMessage.GetProperty("role").GetString()); + Assert.Equal("System Message", systemMessage.GetProperty("content").GetString()); + + Assert.Equal("assistant", assistantMessage.GetProperty("role").GetString()); + Assert.Equal("Assistant Message", assistantMessage.GetProperty("content").GetString()); + + Assert.Equal(123, content.GetProperty("max_tokens").GetInt32()); + Assert.Equal(0.6, content.GetProperty("temperature").GetDouble()); + Assert.Equal(0.5, content.GetProperty("top_p").GetDouble()); + Assert.Equal(1.6, content.GetProperty("frequency_penalty").GetDouble()); + Assert.Equal(1.2, content.GetProperty("presence_penalty").GetDouble()); + Assert.Equal(5, content.GetProperty("n").GetInt32()); + Assert.Equal(567, content.GetProperty("seed").GetInt32()); + Assert.Equal(3, content.GetProperty("logit_bias").GetProperty("2").GetInt32()); + Assert.Equal("stop_sequence", content.GetProperty("stop")[0].GetString()); + Assert.True(content.GetProperty("logprobs").GetBoolean()); + Assert.Equal(5, content.GetProperty("top_logprobs").GetInt32()); + + var dataSources = content.GetProperty("data_sources"); + Assert.Equal(1, dataSources.GetArrayLength()); + Assert.Equal("azure_search", dataSources[0].GetProperty("type").GetString()); + + var dataSourceParameters = dataSources[0].GetProperty("parameters"); + Assert.Equal("http://test-search-endpoint/", dataSourceParameters.GetProperty("endpoint").GetString()); + Assert.Equal("test-index-name", dataSourceParameters.GetProperty("index_name").GetString()); + } + + [Theory] + [MemberData(nameof(ResponseFormats))] + public async Task GetChatMessageContentsHandlesResponseFormatCorrectlyAsync(object responseFormat, string? expectedResponseType) + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings + { + ResponseFormat = responseFormat + }; + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + // Act + var result = await service.GetChatMessageContentsAsync([], settings); + + // Assert + var requestContent = this._messageHandlerStub.RequestContents[0]; + + Assert.NotNull(requestContent); + + var content = JsonSerializer.Deserialize(Encoding.UTF8.GetString(requestContent)); + + Assert.Equal(expectedResponseType, content.GetProperty("response_format").GetProperty("type").GetString()); + } + + [Theory] + [MemberData(nameof(ToolCallBehaviors))] + public async Task GetChatMessageContentsWorksCorrectlyAsync(AzureToolCallBehavior behavior) + { + // Arrange + var kernel = Kernel.CreateBuilder().Build(); + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = behavior }; + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + // Act + var result = await service.GetChatMessageContentsAsync([], settings, kernel); + + // Assert + Assert.True(result.Count > 0); + Assert.Equal("Test chat response", result[0].Content); + + var usage = result[0].Metadata?["Usage"] as CompletionsUsage; + + Assert.NotNull(usage); + Assert.Equal(55, usage.PromptTokens); + Assert.Equal(100, usage.CompletionTokens); + Assert.Equal(155, usage.TotalTokens); + + Assert.Equal("stop", result[0].Metadata?["FinishReason"]); + } + + [Fact] + public async Task GetChatMessageContentsWithFunctionCallAsync() + { + // Arrange + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function1 = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + var function2 = KernelFunctionFactory.CreateFromMethod((string argument) => + { + functionCallCount++; + throw new ArgumentException("Some exception"); + }, "FunctionWithException"); + + kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2])); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_multiple_function_calls_test_response.json")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act + var result = await service.GetChatMessageContentsAsync([], settings, kernel); + + // Assert + Assert.True(result.Count > 0); + Assert.Equal("Test chat response", result[0].Content); + + Assert.Equal(2, functionCallCount); + } + + [Fact] + public async Task GetChatMessageContentsWithFunctionCallMaximumAutoInvokeAttemptsAsync() + { + // Arrange + const int DefaultMaximumAutoInvokeAttempts = 128; + const int ModelResponsesCount = 129; + + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("MyPlugin", [function])); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + var responses = new List(); + + for (var i = 0; i < ModelResponsesCount; i++) + { + responses.Add(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_single_function_call_test_response.json")) }); + } + + this._messageHandlerStub.ResponsesToReturn = responses; + + // Act + var result = await service.GetChatMessageContentsAsync([], settings, kernel); + + // Assert + Assert.Equal(DefaultMaximumAutoInvokeAttempts, functionCallCount); + } + + [Fact] + public async Task GetChatMessageContentsWithRequiredFunctionCallAsync() + { + // Arrange + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + var openAIFunction = plugin.GetFunctionsMetadata().First().ToAzureOpenAIFunction(); + + kernel.Plugins.Add(plugin); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.RequireFunction(openAIFunction, autoInvoke: true) }; + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_single_function_call_test_response.json")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act + var result = await service.GetChatMessageContentsAsync([], settings, kernel); + + // Assert + Assert.Equal(1, functionCallCount); + + var requestContents = this._messageHandlerStub.RequestContents; + + Assert.Equal(2, requestContents.Count); + + requestContents.ForEach(Assert.NotNull); + + var firstContent = Encoding.UTF8.GetString(requestContents[0]!); + var secondContent = Encoding.UTF8.GetString(requestContents[1]!); + + var firstContentJson = JsonSerializer.Deserialize(firstContent); + var secondContentJson = JsonSerializer.Deserialize(secondContent); + + Assert.Equal(1, firstContentJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("MyPlugin-GetCurrentWeather", firstContentJson.GetProperty("tool_choice").GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("none", secondContentJson.GetProperty("tool_choice").GetString()); + } + + [Fact] + public async Task GetStreamingTextContentsWorksCorrectlyAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_test_response.txt"))); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }); + + // Act & Assert + var enumerator = service.GetStreamingTextContentsAsync("Prompt").GetAsyncEnumerator(); + + await enumerator.MoveNextAsync(); + Assert.Equal("Test chat streaming response", enumerator.Current.Text); + + await enumerator.MoveNextAsync(); + Assert.Equal("stop", enumerator.Current.Metadata?["FinishReason"]); + } + + [Fact] + public async Task GetStreamingChatMessageContentsWorksCorrectlyAsync() + { + // Arrange + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_test_response.txt"))); + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + }); + + // Act & Assert + var enumerator = service.GetStreamingChatMessageContentsAsync([]).GetAsyncEnumerator(); + + await enumerator.MoveNextAsync(); + Assert.Equal("Test chat streaming response", enumerator.Current.Content); + + await enumerator.MoveNextAsync(); + Assert.Equal("stop", enumerator.Current.Metadata?["FinishReason"]); + } + + [Fact] + public async Task GetStreamingChatMessageContentsWithFunctionCallAsync() + { + // Arrange + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function1 = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + var function2 = KernelFunctionFactory.CreateFromMethod((string argument) => + { + functionCallCount++; + throw new ArgumentException("Some exception"); + }, "FunctionWithException"); + + kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2])); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_multiple_function_calls_test_response.txt")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_test_response.txt")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act & Assert + var enumerator = service.GetStreamingChatMessageContentsAsync([], settings, kernel).GetAsyncEnumerator(); + + await enumerator.MoveNextAsync(); + Assert.Equal("Test chat streaming response", enumerator.Current.Content); + Assert.Equal("tool_calls", enumerator.Current.Metadata?["FinishReason"]); + + await enumerator.MoveNextAsync(); + Assert.Equal("tool_calls", enumerator.Current.Metadata?["FinishReason"]); + + // Keep looping until the end of stream + while (await enumerator.MoveNextAsync()) + { + } + + Assert.Equal(2, functionCallCount); + } + + [Fact] + public async Task GetStreamingChatMessageContentsWithFunctionCallMaximumAutoInvokeAttemptsAsync() + { + // Arrange + const int DefaultMaximumAutoInvokeAttempts = 128; + const int ModelResponsesCount = 129; + + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("MyPlugin", [function])); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + var responses = new List(); + + for (var i = 0; i < ModelResponsesCount; i++) + { + responses.Add(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_single_function_call_test_response.txt")) }); + } + + this._messageHandlerStub.ResponsesToReturn = responses; + + // Act & Assert + await foreach (var chunk in service.GetStreamingChatMessageContentsAsync([], settings, kernel)) + { + Assert.Equal("Test chat streaming response", chunk.Content); + } + + Assert.Equal(DefaultMaximumAutoInvokeAttempts, functionCallCount); + } + + [Fact] + public async Task GetStreamingChatMessageContentsWithRequiredFunctionCallAsync() + { + // Arrange + int functionCallCount = 0; + + var kernel = Kernel.CreateBuilder().Build(); + var function = KernelFunctionFactory.CreateFromMethod((string location) => + { + functionCallCount++; + return "Some weather"; + }, "GetCurrentWeather"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + var openAIFunction = plugin.GetFunctionsMetadata().First().ToAzureOpenAIFunction(); + + kernel.Plugins.Add(plugin); + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient, this._mockLoggerFactory.Object); + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.RequireFunction(openAIFunction, autoInvoke: true) }; + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_single_function_call_test_response.txt")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_test_response.txt")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act & Assert + var enumerator = service.GetStreamingChatMessageContentsAsync([], settings, kernel).GetAsyncEnumerator(); + + // Function Tool Call Streaming (One Chunk) + await enumerator.MoveNextAsync(); + Assert.Equal("Test chat streaming response", enumerator.Current.Content); + Assert.Equal("tool_calls", enumerator.Current.Metadata?["FinishReason"]); + + // Chat Completion Streaming (1st Chunk) + await enumerator.MoveNextAsync(); + Assert.Null(enumerator.Current.Metadata?["FinishReason"]); + + // Chat Completion Streaming (2nd Chunk) + await enumerator.MoveNextAsync(); + Assert.Equal("stop", enumerator.Current.Metadata?["FinishReason"]); + + Assert.Equal(1, functionCallCount); + + var requestContents = this._messageHandlerStub.RequestContents; + + Assert.Equal(2, requestContents.Count); + + requestContents.ForEach(Assert.NotNull); + + var firstContent = Encoding.UTF8.GetString(requestContents[0]!); + var secondContent = Encoding.UTF8.GetString(requestContents[1]!); + + var firstContentJson = JsonSerializer.Deserialize(firstContent); + var secondContentJson = JsonSerializer.Deserialize(secondContent); + + Assert.Equal(1, firstContentJson.GetProperty("tools").GetArrayLength()); + Assert.Equal("MyPlugin-GetCurrentWeather", firstContentJson.GetProperty("tool_choice").GetProperty("function").GetProperty("name").GetString()); + + Assert.Equal("none", secondContentJson.GetProperty("tool_choice").GetString()); + } + + [Fact] + public async Task GetChatMessageContentsUsesPromptAndSettingsCorrectlyAsync() + { + // Arrange + const string Prompt = "This is test prompt"; + const string SystemMessage = "This is test system message"; + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings() { ChatSystemPrompt = SystemMessage }; + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + IKernelBuilder builder = Kernel.CreateBuilder(); + builder.Services.AddTransient((sp) => service); + Kernel kernel = builder.Build(); + + // Act + var result = await kernel.InvokePromptAsync(Prompt, new(settings)); + + // Assert + Assert.Equal("Test chat response", result.ToString()); + + var requestContentByteArray = this._messageHandlerStub.RequestContents[0]; + + Assert.NotNull(requestContentByteArray); + + var requestContent = JsonSerializer.Deserialize(Encoding.UTF8.GetString(requestContentByteArray)); + + var messages = requestContent.GetProperty("messages"); + + Assert.Equal(2, messages.GetArrayLength()); + + Assert.Equal(SystemMessage, messages[0].GetProperty("content").GetString()); + Assert.Equal("system", messages[0].GetProperty("role").GetString()); + + Assert.Equal(Prompt, messages[1].GetProperty("content").GetString()); + Assert.Equal("user", messages[1].GetProperty("role").GetString()); + } + + [Fact] + public async Task GetChatMessageContentsWithChatMessageContentItemCollectionAndSettingsCorrectlyAsync() + { + // Arrange + const string Prompt = "This is test prompt"; + const string SystemMessage = "This is test system message"; + const string AssistantMessage = "This is assistant message"; + const string CollectionItemPrompt = "This is collection item prompt"; + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings() { ChatSystemPrompt = SystemMessage }; + + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(Prompt); + chatHistory.AddAssistantMessage(AssistantMessage); + chatHistory.AddUserMessage( + [ + new TextContent(CollectionItemPrompt), + new ImageContent(new Uri("https://image")) + ]); + + // Act + var result = await service.GetChatMessageContentsAsync(chatHistory, settings); + + // Assert + Assert.True(result.Count > 0); + Assert.Equal("Test chat response", result[0].Content); + + var requestContentByteArray = this._messageHandlerStub.RequestContents[0]; + + Assert.NotNull(requestContentByteArray); + + var requestContent = JsonSerializer.Deserialize(Encoding.UTF8.GetString(requestContentByteArray)); + + var messages = requestContent.GetProperty("messages"); + + Assert.Equal(4, messages.GetArrayLength()); + + Assert.Equal(SystemMessage, messages[0].GetProperty("content").GetString()); + Assert.Equal("system", messages[0].GetProperty("role").GetString()); + + Assert.Equal(Prompt, messages[1].GetProperty("content").GetString()); + Assert.Equal("user", messages[1].GetProperty("role").GetString()); + + Assert.Equal(AssistantMessage, messages[2].GetProperty("content").GetString()); + Assert.Equal("assistant", messages[2].GetProperty("role").GetString()); + + var contentItems = messages[3].GetProperty("content"); + Assert.Equal(2, contentItems.GetArrayLength()); + Assert.Equal(CollectionItemPrompt, contentItems[0].GetProperty("text").GetString()); + Assert.Equal("text", contentItems[0].GetProperty("type").GetString()); + Assert.Equal("https://image/", contentItems[1].GetProperty("image_url").GetProperty("url").GetString()); + Assert.Equal("image_url", contentItems[1].GetProperty("type").GetString()); + } + + [Fact] + public async Task FunctionCallsShouldBePropagatedToCallersViaChatMessageItemsOfTypeFunctionCallContentAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_multiple_function_calls_test_response.json")) + }); + + var sut = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage("Fake prompt"); + + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.EnableKernelFunctions }; + + // Act + var result = await sut.GetChatMessageContentAsync(chatHistory, settings); + + // Assert + Assert.NotNull(result); + Assert.Equal(5, result.Items.Count); + + var getCurrentWeatherFunctionCall = result.Items[0] as FunctionCallContent; + Assert.NotNull(getCurrentWeatherFunctionCall); + Assert.Equal("GetCurrentWeather", getCurrentWeatherFunctionCall.FunctionName); + Assert.Equal("MyPlugin", getCurrentWeatherFunctionCall.PluginName); + Assert.Equal("1", getCurrentWeatherFunctionCall.Id); + Assert.Equal("Boston, MA", getCurrentWeatherFunctionCall.Arguments?["location"]?.ToString()); + + var functionWithExceptionFunctionCall = result.Items[1] as FunctionCallContent; + Assert.NotNull(functionWithExceptionFunctionCall); + Assert.Equal("FunctionWithException", functionWithExceptionFunctionCall.FunctionName); + Assert.Equal("MyPlugin", functionWithExceptionFunctionCall.PluginName); + Assert.Equal("2", functionWithExceptionFunctionCall.Id); + Assert.Equal("value", functionWithExceptionFunctionCall.Arguments?["argument"]?.ToString()); + + var nonExistentFunctionCall = result.Items[2] as FunctionCallContent; + Assert.NotNull(nonExistentFunctionCall); + Assert.Equal("NonExistentFunction", nonExistentFunctionCall.FunctionName); + Assert.Equal("MyPlugin", nonExistentFunctionCall.PluginName); + Assert.Equal("3", nonExistentFunctionCall.Id); + Assert.Equal("value", nonExistentFunctionCall.Arguments?["argument"]?.ToString()); + + var invalidArgumentsFunctionCall = result.Items[3] as FunctionCallContent; + Assert.NotNull(invalidArgumentsFunctionCall); + Assert.Equal("InvalidArguments", invalidArgumentsFunctionCall.FunctionName); + Assert.Equal("MyPlugin", invalidArgumentsFunctionCall.PluginName); + Assert.Equal("4", invalidArgumentsFunctionCall.Id); + Assert.Null(invalidArgumentsFunctionCall.Arguments); + Assert.NotNull(invalidArgumentsFunctionCall.Exception); + Assert.Equal("Error: Function call arguments were invalid JSON.", invalidArgumentsFunctionCall.Exception.Message); + Assert.NotNull(invalidArgumentsFunctionCall.Exception.InnerException); + + var intArgumentsFunctionCall = result.Items[4] as FunctionCallContent; + Assert.NotNull(intArgumentsFunctionCall); + Assert.Equal("IntArguments", intArgumentsFunctionCall.FunctionName); + Assert.Equal("MyPlugin", intArgumentsFunctionCall.PluginName); + Assert.Equal("5", intArgumentsFunctionCall.Id); + Assert.Equal("36", intArgumentsFunctionCall.Arguments?["age"]?.ToString()); + } + + [Fact] + public async Task FunctionCallsShouldBeReturnedToLLMAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var sut = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + var items = new ChatMessageContentItemCollection + { + new FunctionCallContent("GetCurrentWeather", "MyPlugin", "1", new KernelArguments() { ["location"] = "Boston, MA" }), + new FunctionCallContent("GetWeatherForecast", "MyPlugin", "2", new KernelArguments() { ["location"] = "Boston, MA" }) + }; + + ChatHistory chatHistory = + [ + new ChatMessageContent(AuthorRole.Assistant, items) + ]; + + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.EnableKernelFunctions }; + + // Act + await sut.GetChatMessageContentAsync(chatHistory, settings); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + + var messages = optionsJson.GetProperty("messages"); + Assert.Equal(1, messages.GetArrayLength()); + + var assistantMessage = messages[0]; + Assert.Equal("assistant", assistantMessage.GetProperty("role").GetString()); + + Assert.Equal(2, assistantMessage.GetProperty("tool_calls").GetArrayLength()); + + var tool1 = assistantMessage.GetProperty("tool_calls")[0]; + Assert.Equal("1", tool1.GetProperty("id").GetString()); + Assert.Equal("function", tool1.GetProperty("type").GetString()); + + var function1 = tool1.GetProperty("function"); + Assert.Equal("MyPlugin-GetCurrentWeather", function1.GetProperty("name").GetString()); + Assert.Equal("{\"location\":\"Boston, MA\"}", function1.GetProperty("arguments").GetString()); + + var tool2 = assistantMessage.GetProperty("tool_calls")[1]; + Assert.Equal("2", tool2.GetProperty("id").GetString()); + Assert.Equal("function", tool2.GetProperty("type").GetString()); + + var function2 = tool2.GetProperty("function"); + Assert.Equal("MyPlugin-GetWeatherForecast", function2.GetProperty("name").GetString()); + Assert.Equal("{\"location\":\"Boston, MA\"}", function2.GetProperty("arguments").GetString()); + } + + [Fact] + public async Task FunctionResultsCanBeProvidedToLLMAsOneResultPerChatMessageAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var sut = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + var chatHistory = new ChatHistory + { + new ChatMessageContent(AuthorRole.Tool, + [ + new FunctionResultContent(new FunctionCallContent("GetCurrentWeather", "MyPlugin", "1", new KernelArguments() { ["location"] = "Boston, MA" }), "rainy"), + ]), + new ChatMessageContent(AuthorRole.Tool, + [ + new FunctionResultContent(new FunctionCallContent("GetWeatherForecast", "MyPlugin", "2", new KernelArguments() { ["location"] = "Boston, MA" }), "sunny") + ]) + }; + + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.EnableKernelFunctions }; + + // Act + await sut.GetChatMessageContentAsync(chatHistory, settings); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + + var messages = optionsJson.GetProperty("messages"); + Assert.Equal(2, messages.GetArrayLength()); + + var assistantMessage = messages[0]; + Assert.Equal("tool", assistantMessage.GetProperty("role").GetString()); + Assert.Equal("rainy", assistantMessage.GetProperty("content").GetString()); + Assert.Equal("1", assistantMessage.GetProperty("tool_call_id").GetString()); + + var assistantMessage2 = messages[1]; + Assert.Equal("tool", assistantMessage2.GetProperty("role").GetString()); + Assert.Equal("sunny", assistantMessage2.GetProperty("content").GetString()); + Assert.Equal("2", assistantMessage2.GetProperty("tool_call_id").GetString()); + } + + [Fact] + public async Task FunctionResultsCanBeProvidedToLLMAsManyResultsInOneChatMessageAsync() + { + // Arrange + this._messageHandlerStub.ResponsesToReturn.Add(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }); + + var sut = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + + var chatHistory = new ChatHistory + { + new ChatMessageContent(AuthorRole.Tool, + [ + new FunctionResultContent(new FunctionCallContent("GetCurrentWeather", "MyPlugin", "1", new KernelArguments() { ["location"] = "Boston, MA" }), "rainy"), + new FunctionResultContent(new FunctionCallContent("GetWeatherForecast", "MyPlugin", "2", new KernelArguments() { ["location"] = "Boston, MA" }), "sunny") + ]) + }; + + var settings = new AzureOpenAIPromptExecutionSettings() { ToolCallBehavior = AzureToolCallBehavior.EnableKernelFunctions }; + + // Act + await sut.GetChatMessageContentAsync(chatHistory, settings); + + // Assert + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + + var messages = optionsJson.GetProperty("messages"); + Assert.Equal(2, messages.GetArrayLength()); + + var assistantMessage = messages[0]; + Assert.Equal("tool", assistantMessage.GetProperty("role").GetString()); + Assert.Equal("rainy", assistantMessage.GetProperty("content").GetString()); + Assert.Equal("1", assistantMessage.GetProperty("tool_call_id").GetString()); + + var assistantMessage2 = messages[1]; + Assert.Equal("tool", assistantMessage2.GetProperty("role").GetString()); + Assert.Equal("sunny", assistantMessage2.GetProperty("content").GetString()); + Assert.Equal("2", assistantMessage2.GetProperty("tool_call_id").GetString()); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + public static TheoryData ToolCallBehaviors => new() + { + AzureToolCallBehavior.EnableKernelFunctions, + AzureToolCallBehavior.AutoInvokeKernelFunctions + }; + + public static TheoryData ResponseFormats => new() + { + { new FakeChatCompletionsResponseFormat(), null }, + { "json_object", "json_object" }, + { "text", "text" } + }; + + private sealed class FakeChatCompletionsResponseFormat : ChatCompletionsResponseFormat; +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatHistoryExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatHistoryExtensionsTests.cs new file mode 100644 index 000000000000..a0579f6d6c72 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/ChatHistoryExtensionsTests.cs @@ -0,0 +1,45 @@ +// 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.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests; +public class ChatHistoryExtensionsTests +{ + [Fact] + public async Task ItCanAddMessageFromStreamingChatContentsAsync() + { + var metadata = new Dictionary() + { + { "message", "something" }, + }; + + var chatHistoryStreamingContents = new List + { + 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"]); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj index 703061c403a2..5952d571a09f 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Connectors.AzureOpenAI.UnitTests.csproj @@ -38,4 +38,10 @@ + + + Always + + + diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIChatMessageContentTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIChatMessageContentTests.cs new file mode 100644 index 000000000000..304e62bc9aeb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIChatMessageContentTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +/// +/// Unit tests for class. +/// +public sealed class AzureOpenAIChatMessageContentTests +{ + [Fact] + public void ConstructorsWorkCorrectly() + { + // Arrange + List toolCalls = [new FakeChatCompletionsToolCall("id")]; + + // Act + var content1 = new AzureOpenAIChatMessageContent(new ChatRole("user"), "content1", "model-id1", toolCalls) { AuthorName = "Fred" }; + var content2 = new AzureOpenAIChatMessageContent(AuthorRole.User, "content2", "model-id2", toolCalls); + + // Assert + this.AssertChatMessageContent(AuthorRole.User, "content1", "model-id1", toolCalls, content1, "Fred"); + this.AssertChatMessageContent(AuthorRole.User, "content2", "model-id2", toolCalls, content2); + } + + [Fact] + public void GetOpenAIFunctionToolCallsReturnsCorrectList() + { + // Arrange + List toolCalls = [ + new ChatCompletionsFunctionToolCall("id1", "name", string.Empty), + new ChatCompletionsFunctionToolCall("id2", "name", string.Empty), + new FakeChatCompletionsToolCall("id3"), + new FakeChatCompletionsToolCall("id4")]; + + var content1 = new AzureOpenAIChatMessageContent(AuthorRole.User, "content", "model-id", toolCalls); + var content2 = new AzureOpenAIChatMessageContent(AuthorRole.User, "content", "model-id", []); + + // Act + var actualToolCalls1 = content1.GetOpenAIFunctionToolCalls(); + var actualToolCalls2 = content2.GetOpenAIFunctionToolCalls(); + + // Assert + Assert.Equal(2, actualToolCalls1.Count); + Assert.Equal("id1", actualToolCalls1[0].Id); + Assert.Equal("id2", actualToolCalls1[1].Id); + + Assert.Empty(actualToolCalls2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MetadataIsInitializedCorrectly(bool readOnlyMetadata) + { + // Arrange + IReadOnlyDictionary metadata = readOnlyMetadata ? + new CustomReadOnlyDictionary(new Dictionary { { "key", "value" } }) : + new Dictionary { { "key", "value" } }; + + List toolCalls = [ + new ChatCompletionsFunctionToolCall("id1", "name", string.Empty), + new ChatCompletionsFunctionToolCall("id2", "name", string.Empty), + new FakeChatCompletionsToolCall("id3"), + new FakeChatCompletionsToolCall("id4")]; + + // Act + var content1 = new AzureOpenAIChatMessageContent(AuthorRole.User, "content1", "model-id1", [], metadata); + var content2 = new AzureOpenAIChatMessageContent(AuthorRole.User, "content2", "model-id2", toolCalls, metadata); + + // Assert + Assert.NotNull(content1.Metadata); + Assert.Single(content1.Metadata); + + Assert.NotNull(content2.Metadata); + Assert.Equal(2, content2.Metadata.Count); + Assert.Equal("value", content2.Metadata["key"]); + + Assert.IsType>(content2.Metadata["ChatResponseMessage.FunctionToolCalls"]); + + var actualToolCalls = content2.Metadata["ChatResponseMessage.FunctionToolCalls"] as List; + Assert.NotNull(actualToolCalls); + + Assert.Equal(2, actualToolCalls.Count); + Assert.Equal("id1", actualToolCalls[0].Id); + Assert.Equal("id2", actualToolCalls[1].Id); + } + + private void AssertChatMessageContent( + AuthorRole expectedRole, + string expectedContent, + string expectedModelId, + IReadOnlyList expectedToolCalls, + AzureOpenAIChatMessageContent actualContent, + string? expectedName = null) + { + Assert.Equal(expectedRole, actualContent.Role); + Assert.Equal(expectedContent, actualContent.Content); + Assert.Equal(expectedName, actualContent.AuthorName); + Assert.Equal(expectedModelId, actualContent.ModelId); + Assert.Same(expectedToolCalls, actualContent.ToolCalls); + } + + private sealed class FakeChatCompletionsToolCall(string id) : ChatCompletionsToolCall(id) + { } + + private sealed class CustomReadOnlyDictionary(IDictionary dictionary) : IReadOnlyDictionary // explicitly not implementing IDictionary<> + { + public TValue this[TKey key] => dictionary[key]; + public IEnumerable Keys => dictionary.Keys; + public IEnumerable Values => dictionary.Values; + public int Count => dictionary.Count; + public bool ContainsKey(TKey key) => dictionary.ContainsKey(key); + public IEnumerator> GetEnumerator() => dictionary.GetEnumerator(); + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => dictionary.TryGetValue(key, out value); + IEnumerator IEnumerable.GetEnumerator() => dictionary.GetEnumerator(); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIFunctionToolCallTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIFunctionToolCallTests.cs new file mode 100644 index 000000000000..8f16c6ea7db2 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIFunctionToolCallTests.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +/// +/// Unit tests for class. +/// +public sealed class AzureOpenAIFunctionToolCallTests +{ + [Theory] + [InlineData("MyFunction", "MyFunction")] + [InlineData("MyPlugin_MyFunction", "MyPlugin_MyFunction")] + public void FullyQualifiedNameReturnsValidName(string toolCallName, string expectedName) + { + // Arrange + var toolCall = new ChatCompletionsFunctionToolCall("id", toolCallName, string.Empty); + var openAIFunctionToolCall = new AzureOpenAIFunctionToolCall(toolCall); + + // Act & Assert + Assert.Equal(expectedName, openAIFunctionToolCall.FullyQualifiedName); + Assert.Same(openAIFunctionToolCall.FullyQualifiedName, openAIFunctionToolCall.FullyQualifiedName); + } + + [Fact] + public void ToStringReturnsCorrectValue() + { + // Arrange + var toolCall = new ChatCompletionsFunctionToolCall("id", "MyPlugin_MyFunction", "{\n \"location\": \"San Diego\",\n \"max_price\": 300\n}"); + var openAIFunctionToolCall = new AzureOpenAIFunctionToolCall(toolCall); + + // Act & Assert + Assert.Equal("MyPlugin_MyFunction(location:San Diego, max_price:300)", openAIFunctionToolCall.ToString()); + } + + [Fact] + public void ConvertToolCallUpdatesWithEmptyIndexesReturnsEmptyToolCalls() + { + // Arrange + var toolCallIdsByIndex = new Dictionary(); + var functionNamesByIndex = new Dictionary(); + var functionArgumentBuildersByIndex = new Dictionary(); + + // Act + var toolCalls = AzureOpenAIFunctionToolCall.ConvertToolCallUpdatesToChatCompletionsFunctionToolCalls( + ref toolCallIdsByIndex, + ref functionNamesByIndex, + ref functionArgumentBuildersByIndex); + + // Assert + Assert.Empty(toolCalls); + } + + [Fact] + public void ConvertToolCallUpdatesWithNotEmptyIndexesReturnsNotEmptyToolCalls() + { + // Arrange + var toolCallIdsByIndex = new Dictionary { { 3, "test-id" } }; + var functionNamesByIndex = new Dictionary { { 3, "test-function" } }; + var functionArgumentBuildersByIndex = new Dictionary { { 3, new("test-argument") } }; + + // Act + var toolCalls = AzureOpenAIFunctionToolCall.ConvertToolCallUpdatesToChatCompletionsFunctionToolCalls( + ref toolCallIdsByIndex, + ref functionNamesByIndex, + ref functionArgumentBuildersByIndex); + + // Assert + Assert.Single(toolCalls); + + var toolCall = toolCalls[0]; + + Assert.Equal("test-id", toolCall.Id); + Assert.Equal("test-function", toolCall.Name); + Assert.Equal("test-argument", toolCall.Arguments); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIPluginCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIPluginCollectionExtensionsTests.cs new file mode 100644 index 000000000000..bbfb636196d3 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIPluginCollectionExtensionsTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +/// +/// Unit tests for class. +/// +public sealed class AzureOpenAIPluginCollectionExtensionsTests +{ + [Fact] + public void TryGetFunctionAndArgumentsWithNonExistingFunctionReturnsFalse() + { + // Arrange + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin"); + var plugins = new KernelPluginCollection([plugin]); + + var toolCall = new ChatCompletionsFunctionToolCall("id", "MyPlugin_MyFunction", string.Empty); + + // Act + var result = plugins.TryGetFunctionAndArguments(toolCall, out var actualFunction, out var actualArguments); + + // Assert + Assert.False(result); + Assert.Null(actualFunction); + Assert.Null(actualArguments); + } + + [Fact] + public void TryGetFunctionAndArgumentsWithoutArgumentsReturnsTrue() + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod(() => "Result", "MyFunction"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + + var plugins = new KernelPluginCollection([plugin]); + var toolCall = new ChatCompletionsFunctionToolCall("id", "MyPlugin-MyFunction", string.Empty); + + // Act + var result = plugins.TryGetFunctionAndArguments(toolCall, out var actualFunction, out var actualArguments); + + // Assert + Assert.True(result); + Assert.Equal(function.Name, actualFunction?.Name); + Assert.Null(actualArguments); + } + + [Fact] + public void TryGetFunctionAndArgumentsWithArgumentsReturnsTrue() + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod(() => "Result", "MyFunction"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function]); + + var plugins = new KernelPluginCollection([plugin]); + var toolCall = new ChatCompletionsFunctionToolCall("id", "MyPlugin-MyFunction", "{\n \"location\": \"San Diego\",\n \"max_price\": 300\n,\n \"null_argument\": null\n}"); + + // Act + var result = plugins.TryGetFunctionAndArguments(toolCall, out var actualFunction, out var actualArguments); + + // Assert + Assert.True(result); + Assert.Equal(function.Name, actualFunction?.Name); + + Assert.NotNull(actualArguments); + + Assert.Equal("San Diego", actualArguments["location"]); + Assert.Equal("300", actualArguments["max_price"]); + + Assert.Null(actualArguments["null_argument"]); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIStreamingTextContentTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIStreamingTextContentTests.cs new file mode 100644 index 000000000000..a58df5676aca --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureOpenAIStreamingTextContentTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +/// +/// Unit tests for class. +/// +public sealed class AzureOpenAIStreamingTextContentTests +{ + [Fact] + public void ToByteArrayWorksCorrectly() + { + // Arrange + var expectedBytes = Encoding.UTF8.GetBytes("content"); + var content = new AzureOpenAIStreamingTextContent("content", 0, "model-id"); + + // Act + var actualBytes = content.ToByteArray(); + + // Assert + Assert.Equal(expectedBytes, actualBytes); + } + + [Theory] + [InlineData(null, "")] + [InlineData("content", "content")] + public void ToStringWorksCorrectly(string? content, string expectedString) + { + // Arrange + var textContent = new AzureOpenAIStreamingTextContent(content!, 0, "model-id"); + + // Act + var actualString = textContent.ToString(); + + // Assert + Assert.Equal(expectedString, actualString); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/RequestFailedExceptionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/RequestFailedExceptionExtensionsTests.cs new file mode 100644 index 000000000000..9fb65039116d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/RequestFailedExceptionExtensionsTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using Azure; +using Azure.Core; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +/// +/// Unit tests for class. +/// +public sealed class RequestFailedExceptionExtensionsTests +{ + [Theory] + [InlineData(0, null)] + [InlineData(500, HttpStatusCode.InternalServerError)] + public void ToHttpOperationExceptionWithStatusReturnsValidException(int responseStatus, HttpStatusCode? httpStatusCode) + { + // Arrange + var exception = new RequestFailedException(responseStatus, "Error Message"); + + // Act + var actualException = exception.ToHttpOperationException(); + + // Assert + Assert.IsType(actualException); + Assert.Equal(httpStatusCode, actualException.StatusCode); + Assert.Equal("Error Message", actualException.Message); + Assert.Same(exception, actualException.InnerException); + } + + [Fact] + public void ToHttpOperationExceptionWithContentReturnsValidException() + { + // Arrange + using var response = new FakeResponse("Response Content", 500); + var exception = new RequestFailedException(response); + + // Act + var actualException = exception.ToHttpOperationException(); + + // Assert + Assert.IsType(actualException); + Assert.Equal(HttpStatusCode.InternalServerError, actualException.StatusCode); + Assert.Equal("Response Content", actualException.ResponseContent); + Assert.Same(exception, actualException.InnerException); + } + + #region private + + private sealed class FakeResponse(string responseContent, int status) : Response + { + private readonly string _responseContent = responseContent; + private readonly IEnumerable _headers = []; + + public override BinaryData Content => BinaryData.FromString(this._responseContent); + public override int Status { get; } = status; + public override string ReasonPhrase => "Reason Phrase"; + public override Stream? ContentStream { get => null; set => throw new NotImplementedException(); } + public override string ClientRequestId { get => "Client Request Id"; set => throw new NotImplementedException(); } + + public override void Dispose() { } + protected override bool ContainsHeader(string name) => throw new NotImplementedException(); + protected override IEnumerable EnumerateHeaders() => this._headers; +#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes). + protected override bool TryGetHeader(string name, out string? value) => throw new NotImplementedException(); + protected override bool TryGetHeaderValues(string name, out IEnumerable? values) => throw new NotImplementedException(); +#pragma warning restore CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes). + } + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AutoFunctionInvocationFilterTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AutoFunctionInvocationFilterTests.cs new file mode 100644 index 000000000000..270b055d730c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AutoFunctionInvocationFilterTests.cs @@ -0,0 +1,629 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.FunctionCalling; + +public sealed class AutoFunctionInvocationFilterTests : IDisposable +{ + private readonly MultipleHttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public AutoFunctionInvocationFilterTests() + { + this._messageHandlerStub = new MultipleHttpMessageHandlerStub(); + + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Fact] + public async Task FiltersAreExecutedCorrectlyAsync() + { + // Arrange + int filterInvocations = 0; + int functionInvocations = 0; + int[] expectedRequestSequenceNumbers = [0, 0, 1, 1]; + int[] expectedFunctionSequenceNumbers = [0, 1, 0, 1]; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + Kernel? contextKernel = null; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + contextKernel = context.Kernel; + + if (context.ChatHistory.Last() is AzureOpenAIChatMessageContent content) + { + Assert.Equal(2, content.ToolCalls.Count); + } + + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + filterInvocations++; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal(4, filterInvocations); + Assert.Equal(4, functionInvocations); + Assert.Equal(expectedRequestSequenceNumbers, requestSequenceNumbers); + Assert.Equal(expectedFunctionSequenceNumbers, functionSequenceNumbers); + Assert.Same(kernel, contextKernel); + Assert.Equal("Test chat response", result.ToString()); + } + + [Fact] + public async Task FiltersAreExecutedCorrectlyOnStreamingAsync() + { + // Arrange + int filterInvocations = 0; + int functionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { functionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + if (context.ChatHistory.Last() is AzureOpenAIChatMessageContent content) + { + Assert.Equal(2, content.ToolCalls.Count); + } + + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + filterInvocations++; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new AzureOpenAIPromptExecutionSettings { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { } + + // Assert + Assert.Equal(4, filterInvocations); + Assert.Equal(4, functionInvocations); + Assert.Equal([0, 0, 1, 1], requestSequenceNumbers); + Assert.Equal([0, 1, 0, 1], functionSequenceNumbers); + } + + [Fact] + public async Task DifferentWaysOfAddingFiltersWorkCorrectlyAsync() + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod(() => "Result"); + var executionOrder = new List(); + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var filter1 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter1-Invoking"); + await next(context); + }); + + var filter2 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter2-Invoking"); + await next(context); + }); + + var builder = Kernel.CreateBuilder(); + + builder.Plugins.Add(plugin); + + builder.Services.AddSingleton((serviceProvider) => + { + return new AzureOpenAIChatCompletionService("test-deployment", "https://endpoint", "test-api-key", "test-model-id", this._httpClient); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + + // Case #1 - Add filter to services + builder.Services.AddSingleton(filter1); + + var kernel = builder.Build(); + + // Case #2 - Add filter to kernel + kernel.AutoFunctionInvocationFilters.Add(filter2); + + var result = await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal("Filter1-Invoking", executionOrder[0]); + Assert.Equal("Filter2-Invoking", executionOrder[1]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task MultipleFiltersAreExecutedInOrderAsync(bool isStreaming) + { + // Arrange + var function = KernelFunctionFactory.CreateFromMethod(() => "Result"); + var executionOrder = new List(); + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => parameter, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var filter1 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter1-Invoking"); + await next(context); + executionOrder.Add("Filter1-Invoked"); + }); + + var filter2 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter2-Invoking"); + await next(context); + executionOrder.Add("Filter2-Invoked"); + }); + + var filter3 = new AutoFunctionInvocationFilter(async (context, next) => + { + executionOrder.Add("Filter3-Invoking"); + await next(context); + executionOrder.Add("Filter3-Invoked"); + }); + + var builder = Kernel.CreateBuilder(); + + builder.Plugins.Add(plugin); + + builder.Services.AddSingleton((serviceProvider) => + { + return new AzureOpenAIChatCompletionService("test-deployment", "https://endpoint", "test-api-key", "test-model-id", this._httpClient); + }); + + builder.Services.AddSingleton(filter1); + builder.Services.AddSingleton(filter2); + builder.Services.AddSingleton(filter3); + + var kernel = builder.Build(); + + var arguments = new KernelArguments(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + }); + + // Act + if (isStreaming) + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", arguments)) + { } + } + else + { + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + await kernel.InvokePromptAsync("Test prompt", arguments); + } + + // Assert + Assert.Equal("Filter1-Invoking", executionOrder[0]); + Assert.Equal("Filter2-Invoking", executionOrder[1]); + Assert.Equal("Filter3-Invoking", executionOrder[2]); + Assert.Equal("Filter3-Invoked", executionOrder[3]); + Assert.Equal("Filter2-Invoked", executionOrder[4]); + Assert.Equal("Filter1-Invoked", executionOrder[5]); + } + + [Fact] + public async Task FilterCanOverrideArgumentsAsync() + { + // Arrange + const string NewValue = "NewValue"; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + context.Arguments!["parameter"] = NewValue; + await next(context); + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal("NewValue", result.ToString()); + } + + [Fact] + public async Task FilterCanHandleExceptionAsync() + { + // Arrange + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { throw new KernelException("Exception from Function1"); }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => "Result from Function2", "Function2"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + try + { + await next(context); + } + catch (KernelException exception) + { + Assert.Equal("Exception from Function1", exception.Message); + context.Result = new FunctionResult(context.Result, "Result from filter"); + } + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + var chatCompletion = new AzureOpenAIChatCompletionService("test-deployment", "https://endpoint", "test-api-key", "test-model-id", this._httpClient); + + var executionSettings = new AzureOpenAIPromptExecutionSettings { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + var chatHistory = new ChatHistory(); + + // Act + var result = await chatCompletion.GetChatMessageContentsAsync(chatHistory, executionSettings, kernel); + + var firstFunctionResult = chatHistory[^2].Content; + var secondFunctionResult = chatHistory[^1].Content; + + // Assert + Assert.Equal("Result from filter", firstFunctionResult); + Assert.Equal("Result from Function2", secondFunctionResult); + } + + [Fact] + public async Task FilterCanHandleExceptionOnStreamingAsync() + { + // Arrange + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { throw new KernelException("Exception from Function1"); }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => "Result from Function2", "Function2"); + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + try + { + await next(context); + } + catch (KernelException) + { + context.Result = new FunctionResult(context.Result, "Result from filter"); + } + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var chatCompletion = new AzureOpenAIChatCompletionService("test-deployment", "https://endpoint", "test-api-key", "test-model-id", this._httpClient); + + var chatHistory = new ChatHistory(); + var executionSettings = new AzureOpenAIPromptExecutionSettings { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + // Act + await foreach (var item in chatCompletion.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel)) + { } + + var firstFunctionResult = chatHistory[^2].Content; + var secondFunctionResult = chatHistory[^1].Content; + + // Assert + Assert.Equal("Result from filter", firstFunctionResult); + Assert.Equal("Result from Function2", secondFunctionResult); + } + + [Fact] + public async Task FiltersCanSkipFunctionExecutionAsync() + { + // Arrange + int filterInvocations = 0; + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Filter delegate is invoked only for second function, the first one should be skipped. + if (context.Function.Name == "Function2") + { + await next(context); + } + + filterInvocations++; + }); + + using var response1 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("filters_multiple_function_calls_test_response.json")) }; + using var response2 = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) }; + + this._messageHandlerStub.ResponsesToReturn = [response1, response2]; + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal(2, filterInvocations); + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(1, secondFunctionInvocations); + } + + [Fact] + public async Task PreFilterCanTerminateOperationAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Terminating before first function, so all functions won't be invoked. + context.Terminate = true; + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + } + + [Fact] + public async Task PreFilterCanTerminateOperationOnStreamingAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + // Terminating before first function, so all functions won't be invoked. + context.Terminate = true; + + await next(context); + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new AzureOpenAIPromptExecutionSettings { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { } + + // Assert + Assert.Equal(0, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + } + + [Fact] + public async Task PostFilterCanTerminateOperationAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + // Terminating after first function, so second function won't be invoked. + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingResponses(); + + // Act + var result = await kernel.InvokePromptAsync("Test prompt", new(new AzureOpenAIPromptExecutionSettings + { + ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions + })); + + // Assert + Assert.Equal(1, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + Assert.Equal([0], requestSequenceNumbers); + Assert.Equal([0], functionSequenceNumbers); + + // Results of function invoked before termination should be returned + var lastMessageContent = result.GetValue(); + Assert.NotNull(lastMessageContent); + + Assert.Equal("function1-value", lastMessageContent.Content); + Assert.Equal(AuthorRole.Tool, lastMessageContent.Role); + } + + [Fact] + public async Task PostFilterCanTerminateOperationOnStreamingAsync() + { + // Arrange + int firstFunctionInvocations = 0; + int secondFunctionInvocations = 0; + List requestSequenceNumbers = []; + List functionSequenceNumbers = []; + + var function1 = KernelFunctionFactory.CreateFromMethod((string parameter) => { firstFunctionInvocations++; return parameter; }, "Function1"); + var function2 = KernelFunctionFactory.CreateFromMethod((string parameter) => { secondFunctionInvocations++; return parameter; }, "Function2"); + + var plugin = KernelPluginFactory.CreateFromFunctions("MyPlugin", [function1, function2]); + + var kernel = this.GetKernelWithFilter(plugin, async (context, next) => + { + requestSequenceNumbers.Add(context.RequestSequenceIndex); + functionSequenceNumbers.Add(context.FunctionSequenceIndex); + + await next(context); + + // Terminating after first function, so second function won't be invoked. + context.Terminate = true; + }); + + this._messageHandlerStub.ResponsesToReturn = GetFunctionCallingStreamingResponses(); + + var executionSettings = new AzureOpenAIPromptExecutionSettings { ToolCallBehavior = AzureToolCallBehavior.AutoInvokeKernelFunctions }; + + List streamingContent = []; + + // Act + await foreach (var item in kernel.InvokePromptStreamingAsync("Test prompt", new(executionSettings))) + { + streamingContent.Add(item); + } + + // Assert + Assert.Equal(1, firstFunctionInvocations); + Assert.Equal(0, secondFunctionInvocations); + Assert.Equal([0], requestSequenceNumbers); + Assert.Equal([0], functionSequenceNumbers); + + // Results of function invoked before termination should be returned + Assert.Equal(3, streamingContent.Count); + + var lastMessageContent = streamingContent[^1] as StreamingChatMessageContent; + Assert.NotNull(lastMessageContent); + + Assert.Equal("function1-value", lastMessageContent.Content); + Assert.Equal(AuthorRole.Tool, lastMessageContent.Role); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + #region private + +#pragma warning disable CA2000 // Dispose objects before losing scope + private static List GetFunctionCallingResponses() + { + return [ + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("filters_multiple_function_calls_test_response.json")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("filters_multiple_function_calls_test_response.json")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) } + ]; + } + + private static List GetFunctionCallingStreamingResponses() + { + return [ + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("filters_streaming_multiple_function_calls_test_response.txt")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("filters_streaming_multiple_function_calls_test_response.txt")) }, + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_streaming_test_response.txt")) } + ]; + } +#pragma warning restore CA2000 + + private Kernel GetKernelWithFilter( + KernelPlugin plugin, + Func, Task>? onAutoFunctionInvocation) + { + var builder = Kernel.CreateBuilder(); + var filter = new AutoFunctionInvocationFilter(onAutoFunctionInvocation); + + builder.Plugins.Add(plugin); + builder.Services.AddSingleton(filter); + + builder.Services.AddSingleton((serviceProvider) => + { + return new AzureOpenAIChatCompletionService("test-deployment", "https://endpoint", "test-api-key", "test-model-id", this._httpClient); + }); + + return builder.Build(); + } + + private sealed class AutoFunctionInvocationFilter( + Func, Task>? onAutoFunctionInvocation) : IAutoFunctionInvocationFilter + { + private readonly Func, Task>? _onAutoFunctionInvocation = onAutoFunctionInvocation; + + public Task OnAutoFunctionInvocationAsync(AutoFunctionInvocationContext context, Func next) => + this._onAutoFunctionInvocation?.Invoke(context, next) ?? Task.CompletedTask; + } + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AzureOpenAIFunctionTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AzureOpenAIFunctionTests.cs new file mode 100644 index 000000000000..bd268ef67991 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/AzureOpenAIFunctionTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.FunctionCalling; + +public sealed class AzureOpenAIFunctionTests +{ + [Theory] + [InlineData(null, null, "", "")] + [InlineData("name", "description", "name", "description")] + public void ItInitializesOpenAIFunctionParameterCorrectly(string? name, string? description, string expectedName, string expectedDescription) + { + // Arrange & Act + var schema = KernelJsonSchema.Parse("{\"type\": \"object\" }"); + var functionParameter = new AzureOpenAIFunctionParameter(name, description, true, typeof(string), schema); + + // Assert + Assert.Equal(expectedName, functionParameter.Name); + Assert.Equal(expectedDescription, functionParameter.Description); + Assert.True(functionParameter.IsRequired); + Assert.Equal(typeof(string), functionParameter.ParameterType); + Assert.Same(schema, functionParameter.Schema); + } + + [Theory] + [InlineData(null, "")] + [InlineData("description", "description")] + public void ItInitializesOpenAIFunctionReturnParameterCorrectly(string? description, string expectedDescription) + { + // Arrange & Act + var schema = KernelJsonSchema.Parse("{\"type\": \"object\" }"); + var functionParameter = new AzureOpenAIFunctionReturnParameter(description, typeof(string), schema); + + // Assert + Assert.Equal(expectedDescription, functionParameter.Description); + Assert.Equal(typeof(string), functionParameter.ParameterType); + Assert.Same(schema, functionParameter.Schema); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithNoPluginName() + { + // Arrange + AzureOpenAIFunction sut = KernelFunctionFactory.CreateFromMethod(() => { }, "myfunc", "This is a description of the function.").Metadata.ToAzureOpenAIFunction(); + + // Act + FunctionDefinition result = sut.ToFunctionDefinition(); + + // Assert + Assert.Equal(sut.FunctionName, result.Name); + Assert.Equal(sut.Description, result.Description); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithNullParameters() + { + // Arrange + AzureOpenAIFunction sut = new("plugin", "function", "description", null, null); + + // Act + var result = sut.ToFunctionDefinition(); + + // Assert + Assert.Equal("{\"type\":\"object\",\"required\":[],\"properties\":{}}", result.Parameters.ToString()); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionWithPluginName() + { + // Arrange + AzureOpenAIFunction sut = KernelPluginFactory.CreateFromFunctions("myplugin", new[] + { + KernelFunctionFactory.CreateFromMethod(() => { }, "myfunc", "This is a description of the function.") + }).GetFunctionsMetadata()[0].ToAzureOpenAIFunction(); + + // Act + FunctionDefinition result = sut.ToFunctionDefinition(); + + // Assert + Assert.Equal("myplugin-myfunc", result.Name); + Assert.Equal(sut.Description, result.Description); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParameterType() + { + string expectedParameterSchema = """{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "type": "string", "description": "String param 1" }, "param2": { "type": "integer", "description": "Int param 2" } } } """; + + KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] + { + KernelFunctionFactory.CreateFromMethod( + [return: Description("My test Result")] ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => "", + "TestFunction", + "My test function") + }); + + AzureOpenAIFunction sut = plugin.GetFunctionsMetadata()[0].ToAzureOpenAIFunction(); + + FunctionDefinition functionDefinition = sut.ToFunctionDefinition(); + + var exp = JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)); + var act = JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.Parameters)); + + Assert.NotNull(functionDefinition); + Assert.Equal("Tests-TestFunction", functionDefinition.Name); + Assert.Equal("My test function", functionDefinition.Description); + Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.Parameters))); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParameterType() + { + string expectedParameterSchema = """{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "type": "string", "description": "String param 1" }, "param2": { "type": "integer", "description": "Int param 2" } } } """; + + KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[] + { + KernelFunctionFactory.CreateFromMethod( + [return: Description("My test Result")] ([Description("String param 1")] string param1, [Description("Int param 2")] int param2) => { }, + "TestFunction", + "My test function") + }); + + AzureOpenAIFunction sut = plugin.GetFunctionsMetadata()[0].ToAzureOpenAIFunction(); + + FunctionDefinition functionDefinition = sut.ToFunctionDefinition(); + + Assert.NotNull(functionDefinition); + Assert.Equal("Tests-TestFunction", functionDefinition.Name); + Assert.Equal("My test function", functionDefinition.Description); + Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.Parameters))); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithNoParameterTypes() + { + // Arrange + AzureOpenAIFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: [new KernelParameterMetadata("param1")]).Metadata.ToAzureOpenAIFunction(); + + // Act + FunctionDefinition result = f.ToFunctionDefinition(); + ParametersData pd = JsonSerializer.Deserialize(result.Parameters.ToString())!; + + // Assert + Assert.NotNull(pd.properties); + Assert.Single(pd.properties); + Assert.Equal( + JsonSerializer.Serialize(KernelJsonSchema.Parse("""{ "type":"string" }""")), + JsonSerializer.Serialize(pd.properties.First().Value.RootElement)); + } + + [Fact] + public void ItCanConvertToFunctionDefinitionsWithNoParameterTypesButWithDescriptions() + { + // Arrange + AzureOpenAIFunction f = KernelFunctionFactory.CreateFromMethod( + () => { }, + parameters: [new KernelParameterMetadata("param1") { Description = "something neat" }]).Metadata.ToAzureOpenAIFunction(); + + // Act + FunctionDefinition result = f.ToFunctionDefinition(); + ParametersData pd = JsonSerializer.Deserialize(result.Parameters.ToString())!; + + // Assert + Assert.NotNull(pd.properties); + Assert.Single(pd.properties); + Assert.Equal( + JsonSerializer.Serialize(KernelJsonSchema.Parse("""{ "type":"string", "description":"something neat" }""")), + JsonSerializer.Serialize(pd.properties.First().Value.RootElement)); + } + +#pragma warning disable CA1812 // uninstantiated internal class + private sealed class ParametersData + { + public string? type { get; set; } + public string[]? required { get; set; } + public Dictionary? properties { get; set; } + } +#pragma warning restore CA1812 +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/KernelFunctionMetadataExtensionsTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/KernelFunctionMetadataExtensionsTests.cs new file mode 100644 index 000000000000..ebf7b67a2f9b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/FunctionCalling/KernelFunctionMetadataExtensionsTests.cs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Linq; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +#pragma warning disable CA1812 // Uninstantiated internal types + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.FunctionCalling; + +public sealed class KernelFunctionMetadataExtensionsTests +{ + [Fact] + public void ItCanConvertToAzureOpenAIFunctionNoParameters() + { + // Arrange + var sut = new KernelFunctionMetadata("foo") + { + PluginName = "bar", + Description = "baz", + ReturnParameter = new KernelReturnParameterMetadata + { + Description = "retDesc", + Schema = KernelJsonSchema.Parse("""{"type": "object" }"""), + } + }; + + // Act + var result = sut.ToAzureOpenAIFunction(); + + // Assert + Assert.Equal(sut.Name, result.FunctionName); + Assert.Equal(sut.PluginName, result.PluginName); + Assert.Equal(sut.Description, result.Description); + Assert.Equal($"{sut.PluginName}-{sut.Name}", result.FullyQualifiedName); + + Assert.NotNull(result.ReturnParameter); + Assert.Equal("retDesc", result.ReturnParameter.Description); + Assert.Equivalent(KernelJsonSchema.Parse("""{"type": "object" }"""), result.ReturnParameter.Schema); + Assert.Null(result.ReturnParameter.ParameterType); + } + + [Fact] + public void ItCanConvertToAzureOpenAIFunctionNoPluginName() + { + // Arrange + var sut = new KernelFunctionMetadata("foo") + { + PluginName = string.Empty, + Description = "baz", + ReturnParameter = new KernelReturnParameterMetadata + { + Description = "retDesc", + Schema = KernelJsonSchema.Parse("""{"type": "object" }"""), + } + }; + + // Act + var result = sut.ToAzureOpenAIFunction(); + + // Assert + Assert.Equal(sut.Name, result.FunctionName); + Assert.Equal(sut.PluginName, result.PluginName); + Assert.Equal(sut.Description, result.Description); + Assert.Equal(sut.Name, result.FullyQualifiedName); + + Assert.NotNull(result.ReturnParameter); + Assert.Equal("retDesc", result.ReturnParameter.Description); + Assert.Equivalent(KernelJsonSchema.Parse("""{"type": "object" }"""), result.ReturnParameter.Schema); + Assert.Null(result.ReturnParameter.ParameterType); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ItCanConvertToAzureOpenAIFunctionWithParameter(bool withSchema) + { + // Arrange + var param1 = new KernelParameterMetadata("param1") + { + Description = "This is param1", + DefaultValue = "1", + ParameterType = typeof(int), + IsRequired = false, + Schema = withSchema ? KernelJsonSchema.Parse("""{"type":"integer"}""") : null, + }; + + var sut = new KernelFunctionMetadata("foo") + { + PluginName = "bar", + Description = "baz", + Parameters = [param1], + ReturnParameter = new KernelReturnParameterMetadata + { + Description = "retDesc", + Schema = KernelJsonSchema.Parse("""{"type": "object" }"""), + } + }; + + // Act + var result = sut.ToAzureOpenAIFunction(); + var outputParam = result.Parameters![0]; + + // Assert + Assert.Equal(param1.Name, outputParam.Name); + Assert.Equal("This is param1 (default value: 1)", outputParam.Description); + Assert.Equal(param1.IsRequired, outputParam.IsRequired); + Assert.NotNull(outputParam.Schema); + Assert.Equal("integer", outputParam.Schema.RootElement.GetProperty("type").GetString()); + + Assert.NotNull(result.ReturnParameter); + Assert.Equal("retDesc", result.ReturnParameter.Description); + Assert.Equivalent(KernelJsonSchema.Parse("""{"type": "object" }"""), result.ReturnParameter.Schema); + Assert.Null(result.ReturnParameter.ParameterType); + } + + [Fact] + public void ItCanConvertToAzureOpenAIFunctionWithParameterNoType() + { + // Arrange + var param1 = new KernelParameterMetadata("param1") { Description = "This is param1" }; + + var sut = new KernelFunctionMetadata("foo") + { + PluginName = "bar", + Description = "baz", + Parameters = [param1], + ReturnParameter = new KernelReturnParameterMetadata + { + Description = "retDesc", + Schema = KernelJsonSchema.Parse("""{"type": "object" }"""), + } + }; + + // Act + var result = sut.ToAzureOpenAIFunction(); + var outputParam = result.Parameters![0]; + + // Assert + Assert.Equal(param1.Name, outputParam.Name); + Assert.Equal(param1.Description, outputParam.Description); + Assert.Equal(param1.IsRequired, outputParam.IsRequired); + + Assert.NotNull(result.ReturnParameter); + Assert.Equal("retDesc", result.ReturnParameter.Description); + Assert.Equivalent(KernelJsonSchema.Parse("""{"type": "object" }"""), result.ReturnParameter.Schema); + Assert.Null(result.ReturnParameter.ParameterType); + } + + [Fact] + public void ItCanConvertToAzureOpenAIFunctionWithNoReturnParameterType() + { + // Arrange + var param1 = new KernelParameterMetadata("param1") + { + Description = "This is param1", + ParameterType = typeof(int), + }; + + var sut = new KernelFunctionMetadata("foo") + { + PluginName = "bar", + Description = "baz", + Parameters = [param1], + }; + + // Act + var result = sut.ToAzureOpenAIFunction(); + var outputParam = result.Parameters![0]; + + // Assert + Assert.Equal(param1.Name, outputParam.Name); + Assert.Equal(param1.Description, outputParam.Description); + Assert.Equal(param1.IsRequired, outputParam.IsRequired); + Assert.NotNull(outputParam.Schema); + Assert.Equal("integer", outputParam.Schema.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public void ItCanCreateValidAzureOpenAIFunctionManualForPlugin() + { + // Arrange + var kernel = new Kernel(); + kernel.Plugins.AddFromType("MyPlugin"); + + var functionMetadata = kernel.Plugins["MyPlugin"].First().Metadata; + + var sut = functionMetadata.ToAzureOpenAIFunction(); + + // Act + var result = sut.ToFunctionDefinition(); + + // Assert + Assert.NotNull(result); + Assert.Equal( + """{"type":"object","required":["parameter1","parameter2","parameter3"],"properties":{"parameter1":{"type":"string","description":"String parameter"},"parameter2":{"type":"string","enum":["Value1","Value2"],"description":"Enum parameter"},"parameter3":{"type":"string","format":"date-time","description":"DateTime parameter"}}}""", + result.Parameters.ToString() + ); + } + + [Fact] + public void ItCanCreateValidAzureOpenAIFunctionManualForPrompt() + { + // Arrange + var promptTemplateConfig = new PromptTemplateConfig("Hello AI") + { + Description = "My sample function." + }; + promptTemplateConfig.InputVariables.Add(new InputVariable + { + Name = "parameter1", + Description = "String parameter", + JsonSchema = """{"type":"string","description":"String parameter"}""" + }); + promptTemplateConfig.InputVariables.Add(new InputVariable + { + Name = "parameter2", + Description = "Enum parameter", + JsonSchema = """{"enum":["Value1","Value2"],"description":"Enum parameter"}""" + }); + var function = KernelFunctionFactory.CreateFromPrompt(promptTemplateConfig); + var functionMetadata = function.Metadata; + var sut = functionMetadata.ToAzureOpenAIFunction(); + + // Act + var result = sut.ToFunctionDefinition(); + + // Assert + Assert.NotNull(result); + Assert.Equal( + """{"type":"object","required":["parameter1","parameter2"],"properties":{"parameter1":{"type":"string","description":"String parameter"},"parameter2":{"enum":["Value1","Value2"],"description":"Enum parameter"}}}""", + result.Parameters.ToString() + ); + } + + private enum MyEnum + { + Value1, + Value2 + } + + private sealed class MyPlugin + { + [KernelFunction, Description("My sample function.")] + public string MyFunction( + [Description("String parameter")] string parameter1, + [Description("Enum parameter")] MyEnum parameter2, + [Description("DateTime parameter")] DateTime parameter3 + ) + { + return "return"; + } + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/MultipleHttpMessageHandlerStub.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/MultipleHttpMessageHandlerStub.cs new file mode 100644 index 000000000000..0af66de6a519 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/MultipleHttpMessageHandlerStub.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace SemanticKernel.Connectors.AzureOpenAI; + +internal sealed class MultipleHttpMessageHandlerStub : DelegatingHandler +{ + private int _callIteration = 0; + + public List RequestHeaders { get; private set; } + + public List ContentHeaders { get; private set; } + + public List RequestContents { get; private set; } + + public List RequestUris { get; private set; } + + public List Methods { get; private set; } + + public List ResponsesToReturn { get; set; } + + public MultipleHttpMessageHandlerStub() + { + this.RequestHeaders = []; + this.ContentHeaders = []; + this.RequestContents = []; + this.RequestUris = []; + this.Methods = []; + this.ResponsesToReturn = []; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this._callIteration++; + + this.Methods.Add(request.Method); + this.RequestUris.Add(request.RequestUri); + this.RequestHeaders.Add(request.Headers); + this.ContentHeaders.Add(request.Content?.Headers); + + var content = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken); + + this.RequestContents.Add(content); + + return await Task.FromResult(this.ResponsesToReturn[this._callIteration - 1]); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_multiple_function_calls_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_multiple_function_calls_test_response.json new file mode 100644 index 000000000000..737b972309ba --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_multiple_function_calls_test_response.json @@ -0,0 +1,64 @@ +{ + "id": "response-id", + "object": "chat.completion", + "created": 1699896916, + "model": "gpt-3.5-turbo-0613", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": { + "name": "MyPlugin-GetCurrentWeather", + "arguments": "{\n\"location\": \"Boston, MA\"\n}" + } + }, + { + "id": "2", + "type": "function", + "function": { + "name": "MyPlugin-FunctionWithException", + "arguments": "{\n\"argument\": \"value\"\n}" + } + }, + { + "id": "3", + "type": "function", + "function": { + "name": "MyPlugin-NonExistentFunction", + "arguments": "{\n\"argument\": \"value\"\n}" + } + }, + { + "id": "4", + "type": "function", + "function": { + "name": "MyPlugin-InvalidArguments", + "arguments": "invalid_arguments_format" + } + }, + { + "id": "5", + "type": "function", + "function": { + "name": "MyPlugin-IntArguments", + "arguments": "{\n\"age\": 36\n}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 82, + "completion_tokens": 17, + "total_tokens": 99 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_single_function_call_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_single_function_call_test_response.json new file mode 100644 index 000000000000..6c93e434f259 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_single_function_call_test_response.json @@ -0,0 +1,32 @@ +{ + "id": "response-id", + "object": "chat.completion", + "created": 1699896916, + "model": "gpt-3.5-turbo-0613", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": { + "name": "MyPlugin-GetCurrentWeather", + "arguments": "{\n\"location\": \"Boston, MA\"\n}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 82, + "completion_tokens": 17, + "total_tokens": 99 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_multiple_function_calls_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_multiple_function_calls_test_response.txt new file mode 100644 index 000000000000..ceb8f3e8b44b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_multiple_function_calls_test_response.txt @@ -0,0 +1,9 @@ +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":0,"id":"1","type":"function","function":{"name":"MyPlugin-GetCurrentWeather","arguments":"{\n\"location\": \"Boston, MA\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":1,"id":"2","type":"function","function":{"name":"MyPlugin-FunctionWithException","arguments":"{\n\"argument\": \"value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":2,"id":"3","type":"function","function":{"name":"MyPlugin-NonExistentFunction","arguments":"{\n\"argument\": \"value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":3,"id":"4","type":"function","function":{"name":"MyPlugin-InvalidArguments","arguments":"invalid_arguments_format"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_single_function_call_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_single_function_call_test_response.txt new file mode 100644 index 000000000000..6835039941ce --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_single_function_call_test_response.txt @@ -0,0 +1,3 @@ +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":0,"id":"1","type":"function","function":{"name":"MyPlugin-GetCurrentWeather","arguments":"{\n\"location\": \"Boston, MA\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_test_response.txt new file mode 100644 index 000000000000..e5e8d1b19afd --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_streaming_test_response.txt @@ -0,0 +1,5 @@ +data: {"id":"chatcmpl-96fqQVHGjG9Yzs4ZMB1K6nfy2oEoo","object":"chat.completion.chunk","created":1711377846,"model":"gpt-4-0125-preview","system_fingerprint":"fp_a7daf7c51e","choices":[{"index":0,"delta":{"content":"Test chat streaming response"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-96fqQVHGjG9Yzs4ZMB1K6nfy2oEoo","object":"chat.completion.chunk","created":1711377846,"model":"gpt-4-0125-preview","system_fingerprint":"fp_a7daf7c51e","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_test_response.json new file mode 100644 index 000000000000..b601bac8b55b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_test_response.json @@ -0,0 +1,22 @@ +{ + "id": "response-id", + "object": "chat.completion", + "created": 1704208954, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Test chat response" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 55, + "completion_tokens": 100, + "total_tokens": 155 + }, + "system_fingerprint": null +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_streaming_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_streaming_test_response.txt new file mode 100644 index 000000000000..5e17403da9fc --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_streaming_test_response.txt @@ -0,0 +1 @@ +data: {"id":"response-id","model":"","created":1684304924,"object":"chat.completion","choices":[{"index":0,"messages":[{"delta":{"role":"assistant","content":"Test chat with data streaming response"},"end_turn":false}]}]} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_test_response.json new file mode 100644 index 000000000000..40d769dac8a7 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/chat_completion_with_data_test_response.json @@ -0,0 +1,28 @@ +{ + "id": "response-id", + "model": "", + "created": 1684304924, + "object": "chat.completion", + "choices": [ + { + "index": 0, + "messages": [ + { + "role": "tool", + "content": "{\"citations\": [{\"content\": \"\\nAzure AI services are cloud-based artificial intelligence (AI) services...\", \"id\": null, \"title\": \"What is Azure AI services\", \"filepath\": null, \"url\": null, \"metadata\": {\"chunking\": \"original document size=250. Scores=0.4314117431640625 and 1.72564697265625.Org Highlight count=4.\"}, \"chunk_id\": \"0\"}], \"intent\": \"[\\\"Learn about Azure AI services.\\\"]\"}", + "end_turn": false + }, + { + "role": "assistant", + "content": "Test chat with data response", + "end_turn": true + } + ] + } + ], + "usage": { + "prompt_tokens": 55, + "completion_tokens": 100, + "total_tokens": 155 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_multiple_function_calls_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_multiple_function_calls_test_response.json new file mode 100644 index 000000000000..3ffa6b00cc3f --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_multiple_function_calls_test_response.json @@ -0,0 +1,40 @@ +{ + "id": "response-id", + "object": "chat.completion", + "created": 1699896916, + "model": "gpt-3.5-turbo-0613", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": { + "name": "MyPlugin-Function1", + "arguments": "{\n\"parameter\": \"function1-value\"\n}" + } + }, + { + "id": "2", + "type": "function", + "function": { + "name": "MyPlugin-Function2", + "arguments": "{\n\"parameter\": \"function2-value\"\n}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 82, + "completion_tokens": 17, + "total_tokens": 99 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_streaming_multiple_function_calls_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_streaming_multiple_function_calls_test_response.txt new file mode 100644 index 000000000000..c8aeb98e8b82 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/filters_streaming_multiple_function_calls_test_response.txt @@ -0,0 +1,5 @@ +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":0,"id":"1","type":"function","function":{"name":"MyPlugin-Function1","arguments":"{\n\"parameter\": \"function1-value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: {"id":"response-id","object":"chat.completion.chunk","created":1704212243,"model":"gpt-4","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Test chat streaming response","tool_calls":[{"index":1,"id":"2","type":"function","function":{"name":"MyPlugin-Function2","arguments":"{\n\"parameter\": \"function2-value\"\n}"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_streaming_test_response.txt b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_streaming_test_response.txt new file mode 100644 index 000000000000..a511ea446236 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_streaming_test_response.txt @@ -0,0 +1,3 @@ +data: {"id":"response-id","object":"text_completion","created":1646932609,"model":"ada","choices":[{"text":"Test chat streaming response","index":0,"logprobs":null,"finish_reason":"length"}],"usage":{"prompt_tokens":55,"completion_tokens":100,"total_tokens":155}} + +data: [DONE] diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_test_response.json b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_test_response.json new file mode 100644 index 000000000000..540229437440 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/TestData/text_completion_test_response.json @@ -0,0 +1,19 @@ +{ + "id": "response-id", + "object": "text_completion", + "created": 1646932609, + "model": "ada", + "choices": [ + { + "text": "Test chat response", + "index": 0, + "logprobs": null, + "finish_reason": "length" + } + ], + "usage": { + "prompt_tokens": 55, + "completion_tokens": 100, + "total_tokens": 155 + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/AddHeaderRequestPolicy.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/AddHeaderRequestPolicy.cs new file mode 100644 index 000000000000..8303b2ceaeaf --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/AddHeaderRequestPolicy.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Core; +using Azure.Core.Pipeline; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Helper class to inject headers into Azure SDK HTTP pipeline +/// +internal sealed class AddHeaderRequestPolicy(string headerName, string headerValue) : HttpPipelineSynchronousPolicy +{ + private readonly string _headerName = headerName; + private readonly string _headerValue = headerValue; + + public override void OnSendingRequest(HttpMessage message) + { + message.Request.Headers.Add(this._headerName, this._headerValue); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureOpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureOpenAIPromptExecutionSettings.cs new file mode 100644 index 000000000000..69c305f58f34 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureOpenAIPromptExecutionSettings.cs @@ -0,0 +1,432 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Text; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Execution settings for an AzureOpenAI completion request. +/// +[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] +public sealed class AzureOpenAIPromptExecutionSettings : PromptExecutionSettings +{ + /// + /// Temperature controls the randomness of the completion. + /// The higher the temperature, the more random the completion. + /// Default is 1.0. + /// + [JsonPropertyName("temperature")] + public double Temperature + { + get => this._temperature; + + set + { + this.ThrowIfFrozen(); + this._temperature = value; + } + } + + /// + /// TopP controls the diversity of the completion. + /// The higher the TopP, the more diverse the completion. + /// Default is 1.0. + /// + [JsonPropertyName("top_p")] + public double TopP + { + get => this._topP; + + set + { + this.ThrowIfFrozen(); + this._topP = value; + } + } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens + /// based on whether they appear in the text so far, increasing the + /// model's likelihood to talk about new topics. + /// + [JsonPropertyName("presence_penalty")] + public double PresencePenalty + { + get => this._presencePenalty; + + set + { + this.ThrowIfFrozen(); + this._presencePenalty = value; + } + } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens + /// based on their existing frequency in the text so far, decreasing + /// the model's likelihood to repeat the same line verbatim. + /// + [JsonPropertyName("frequency_penalty")] + public double FrequencyPenalty + { + get => this._frequencyPenalty; + + set + { + this.ThrowIfFrozen(); + this._frequencyPenalty = value; + } + } + + /// + /// The maximum number of tokens to generate in the completion. + /// + [JsonPropertyName("max_tokens")] + public int? MaxTokens + { + get => this._maxTokens; + + set + { + this.ThrowIfFrozen(); + this._maxTokens = value; + } + } + + /// + /// Sequences where the completion will stop generating further tokens. + /// + [JsonPropertyName("stop_sequences")] + public IList? StopSequences + { + get => this._stopSequences; + + set + { + this.ThrowIfFrozen(); + this._stopSequences = value; + } + } + + /// + /// How many completions to generate for each prompt. Default is 1. + /// Note: Because this parameter generates many completions, it can quickly consume your token quota. + /// Use carefully and ensure that you have reasonable settings for max_tokens and stop. + /// + [JsonPropertyName("results_per_prompt")] + public int ResultsPerPrompt + { + get => this._resultsPerPrompt; + + set + { + this.ThrowIfFrozen(); + this._resultsPerPrompt = value; + } + } + + /// + /// If specified, the system will make a best effort to sample deterministically such that repeated requests with the + /// same seed and parameters should return the same result. Determinism is not guaranteed. + /// + [JsonPropertyName("seed")] + public long? Seed + { + get => this._seed; + + set + { + this.ThrowIfFrozen(); + this._seed = value; + } + } + + /// + /// Gets or sets the response format to use for the completion. + /// + /// + /// Possible values are: "json_object", "text", object. + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("response_format")] + public object? ResponseFormat + { + get => this._responseFormat; + + set + { + this.ThrowIfFrozen(); + this._responseFormat = value; + } + } + + /// + /// The system prompt to use when generating text using a chat model. + /// Defaults to "Assistant is a large language model." + /// + [JsonPropertyName("chat_system_prompt")] + public string? ChatSystemPrompt + { + get => this._chatSystemPrompt; + + set + { + this.ThrowIfFrozen(); + this._chatSystemPrompt = value; + } + } + + /// + /// Modify the likelihood of specified tokens appearing in the completion. + /// + [JsonPropertyName("token_selection_biases")] + public IDictionary? TokenSelectionBiases + { + get => this._tokenSelectionBiases; + + set + { + this.ThrowIfFrozen(); + this._tokenSelectionBiases = value; + } + } + + /// + /// Gets or sets the behavior for how tool calls are handled. + /// + /// + /// + /// To disable all tool calling, set the property to null (the default). + /// + /// To request that the model use a specific function, set the property to an instance returned + /// from . + /// + /// + /// To allow the model to request one of any number of functions, set the property to an + /// instance returned from , called with + /// a list of the functions available. + /// + /// + /// To allow the model to request one of any of the functions in the supplied , + /// set the property to if the client should simply + /// send the information about the functions and not handle the response in any special manner, or + /// if the client should attempt to automatically + /// invoke the function and send the result back to the service. + /// + /// + /// For all options where an instance is provided, auto-invoke behavior may be selected. If the service + /// sends a request for a function call, if auto-invoke has been requested, the client will attempt to + /// resolve that function from the functions available in the , and if found, rather + /// than returning the response back to the caller, it will handle the request automatically, invoking + /// the function, and sending back the result. The intermediate messages will be retained in the + /// if an instance was provided. + /// + public AzureToolCallBehavior? ToolCallBehavior + { + get => this._toolCallBehavior; + + set + { + this.ThrowIfFrozen(); + this._toolCallBehavior = value; + } + } + + /// + /// A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse + /// + public string? User + { + get => this._user; + + set + { + this.ThrowIfFrozen(); + this._user = value; + } + } + + /// + /// Whether to return log probabilities of the output tokens or not. + /// If true, returns the log probabilities of each output token returned in the `content` of `message`. + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("logprobs")] + public bool? Logprobs + { + get => this._logprobs; + + set + { + this.ThrowIfFrozen(); + this._logprobs = value; + } + } + + /// + /// An integer specifying the number of most likely tokens to return at each token position, each with an associated log probability. + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("top_logprobs")] + public int? TopLogprobs + { + get => this._topLogprobs; + + set + { + this.ThrowIfFrozen(); + this._topLogprobs = value; + } + } + + /// + /// An abstraction of additional settings for chat completion, see https://learn.microsoft.com/en-us/dotnet/api/azure.ai.openai.azurechatextensionsoptions. + /// This property is compatible only with Azure OpenAI. + /// + [Experimental("SKEXP0010")] + [JsonIgnore] + public AzureChatExtensionsOptions? AzureChatExtensionsOptions + { + get => this._azureChatExtensionsOptions; + + set + { + this.ThrowIfFrozen(); + this._azureChatExtensionsOptions = value; + } + } + + /// + public override void Freeze() + { + if (this.IsFrozen) + { + return; + } + + base.Freeze(); + + if (this._stopSequences is not null) + { + this._stopSequences = new ReadOnlyCollection(this._stopSequences); + } + + if (this._tokenSelectionBiases is not null) + { + this._tokenSelectionBiases = new ReadOnlyDictionary(this._tokenSelectionBiases); + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new AzureOpenAIPromptExecutionSettings() + { + ModelId = this.ModelId, + ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, + Temperature = this.Temperature, + TopP = this.TopP, + PresencePenalty = this.PresencePenalty, + FrequencyPenalty = this.FrequencyPenalty, + MaxTokens = this.MaxTokens, + StopSequences = this.StopSequences is not null ? new List(this.StopSequences) : null, + ResultsPerPrompt = this.ResultsPerPrompt, + Seed = this.Seed, + ResponseFormat = this.ResponseFormat, + TokenSelectionBiases = this.TokenSelectionBiases is not null ? new Dictionary(this.TokenSelectionBiases) : null, + ToolCallBehavior = this.ToolCallBehavior, + User = this.User, + ChatSystemPrompt = this.ChatSystemPrompt, + Logprobs = this.Logprobs, + TopLogprobs = this.TopLogprobs, + AzureChatExtensionsOptions = this.AzureChatExtensionsOptions, + }; + } + + /// + /// Default max tokens for a text generation + /// + internal static int DefaultTextMaxTokens { get; } = 256; + + /// + /// Create a new settings object with the values from another settings object. + /// + /// Template configuration + /// Default max tokens + /// An instance of OpenAIPromptExecutionSettings + public static AzureOpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutionSettings? executionSettings, int? defaultMaxTokens = null) + { + if (executionSettings is null) + { + return new AzureOpenAIPromptExecutionSettings() + { + MaxTokens = defaultMaxTokens + }; + } + + if (executionSettings is AzureOpenAIPromptExecutionSettings settings) + { + return settings; + } + + var json = JsonSerializer.Serialize(executionSettings); + + var openAIExecutionSettings = JsonSerializer.Deserialize(json, JsonOptionsCache.ReadPermissive); + if (openAIExecutionSettings is not null) + { + return openAIExecutionSettings; + } + + throw new ArgumentException($"Invalid execution settings, cannot convert to {nameof(AzureOpenAIPromptExecutionSettings)}", nameof(executionSettings)); + } + + /// + /// Create a new settings object with the values from another settings object. + /// + /// Template configuration + /// Default max tokens + /// An instance of OpenAIPromptExecutionSettings + [Obsolete("This method is deprecated in favor of OpenAIPromptExecutionSettings.AzureChatExtensionsOptions")] + public static AzureOpenAIPromptExecutionSettings FromExecutionSettingsWithData(PromptExecutionSettings? executionSettings, int? defaultMaxTokens = null) + { + var settings = FromExecutionSettings(executionSettings, defaultMaxTokens); + + if (settings.StopSequences?.Count == 0) + { + // Azure OpenAI WithData API does not allow to send empty array of stop sequences + // Gives back "Validation error at #/stop/str: Input should be a valid string\nValidation error at #/stop/list[str]: List should have at least 1 item after validation, not 0" + settings.StopSequences = null; + } + + return settings; + } + + #region private ================================================================================ + + private double _temperature = 1; + private double _topP = 1; + private double _presencePenalty; + private double _frequencyPenalty; + private int? _maxTokens; + private IList? _stopSequences; + private int _resultsPerPrompt = 1; + private long? _seed; + private object? _responseFormat; + private IDictionary? _tokenSelectionBiases; + private AzureToolCallBehavior? _toolCallBehavior; + private string? _user; + private string? _chatSystemPrompt; + private bool? _logprobs; + private int? _topLogprobs; + private AzureChatExtensionsOptions? _azureChatExtensionsOptions; + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureToolCallBehavior.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureToolCallBehavior.cs new file mode 100644 index 000000000000..4c3baef49268 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/AzureToolCallBehavior.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Azure.AI.OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// Represents a behavior for Azure OpenAI tool calls. +public abstract class AzureToolCallBehavior +{ + // NOTE: Right now, the only tools that are available are for function calling. In the future, + // this class can be extended to support additional kinds of tools, including composite ones: + // the OpenAIPromptExecutionSettings has a single ToolCallBehavior property, but we could + // expose a `public static ToolCallBehavior Composite(params ToolCallBehavior[] behaviors)` + // or the like to allow multiple distinct tools to be provided, should that be appropriate. + // We can also consider additional forms of tools, such as ones that dynamically examine + // the Kernel, KernelArguments, etc., and dynamically contribute tools to the ChatCompletionsOptions. + + /// + /// The default maximum number of tool-call auto-invokes that can be made in a single request. + /// + /// + /// After this number of iterations as part of a single user request is reached, auto-invocation + /// will be disabled (e.g. will behave like )). + /// This is a safeguard against possible runaway execution if the model routinely re-requests + /// the same function over and over. It is currently hardcoded, but in the future it could + /// be made configurable by the developer. Other configuration is also possible in the future, + /// such as a delegate on the instance that can be invoked upon function call failure (e.g. failure + /// to find the requested function, failure to invoke the function, etc.), with behaviors for + /// what to do in such a case, e.g. respond to the model telling it to try again. With parallel tool call + /// support, where the model can request multiple tools in a single response, it is significantly + /// less likely that this limit is reached, as most of the time only a single request is needed. + /// + private const int DefaultMaximumAutoInvokeAttempts = 128; + + /// + /// Gets an instance that will provide all of the 's plugins' function information. + /// Function call requests from the model will be propagated back to the caller. + /// + /// + /// If no is available, no function information will be provided to the model. + /// + public static AzureToolCallBehavior EnableKernelFunctions { get; } = new KernelFunctions(autoInvoke: false); + + /// + /// Gets an instance that will both provide all of the 's plugins' function information + /// to the model and attempt to automatically handle any function call requests. + /// + /// + /// When successful, tool call requests from the model become an implementation detail, with the service + /// handling invoking any requested functions and supplying the results back to the model. + /// If no is available, no function information will be provided to the model. + /// + public static AzureToolCallBehavior AutoInvokeKernelFunctions { get; } = new KernelFunctions(autoInvoke: true); + + /// Gets an instance that will provide the specified list of functions to the model. + /// The functions that should be made available to the model. + /// true to attempt to automatically handle function call requests; otherwise, false. + /// + /// The that may be set into + /// to indicate that the specified functions should be made available to the model. + /// + public static AzureToolCallBehavior EnableFunctions(IEnumerable functions, bool autoInvoke = false) + { + Verify.NotNull(functions); + return new EnabledFunctions(functions, autoInvoke); + } + + /// Gets an instance that will request the model to use the specified function. + /// The function the model should request to use. + /// true to attempt to automatically handle function call requests; otherwise, false. + /// + /// The that may be set into + /// to indicate that the specified function should be requested by the model. + /// + public static AzureToolCallBehavior RequireFunction(AzureOpenAIFunction function, bool autoInvoke = false) + { + Verify.NotNull(function); + return new RequiredFunction(function, autoInvoke); + } + + /// Initializes the instance; prevents external instantiation. + private AzureToolCallBehavior(bool autoInvoke) + { + this.MaximumAutoInvokeAttempts = autoInvoke ? DefaultMaximumAutoInvokeAttempts : 0; + } + + /// + /// Options to control tool call result serialization behavior. + /// + [Obsolete("This property is deprecated in favor of Kernel.SerializerOptions that will be introduced in one of the following releases.")] + [EditorBrowsable(EditorBrowsableState.Never)] + public virtual JsonSerializerOptions? ToolCallResultSerializerOptions { get; set; } + + /// Gets how many requests are part of a single interaction should include this tool in the request. + /// + /// This should be greater than or equal to . It defaults to . + /// Once this limit is reached, the tools will no longer be included in subsequent retries as part of the operation, e.g. + /// if this is 1, the first request will include the tools, but the subsequent response sending back the tool's result + /// will not include the tools for further use. + /// + internal virtual int MaximumUseAttempts => int.MaxValue; + + /// Gets how many tool call request/response roundtrips are supported with auto-invocation. + /// + /// To disable auto invocation, this can be set to 0. + /// + internal int MaximumAutoInvokeAttempts { get; } + + /// + /// Gets whether validation against a specified list is required before allowing the model to request a function from the kernel. + /// + /// true if it's ok to invoke any kernel function requested by the model if it's found; false if a request needs to be validated against an allow list. + internal virtual bool AllowAnyRequestedKernelFunction => false; + + /// Configures the with any tools this provides. + /// The used for the operation. This can be queried to determine what tools to provide into the . + /// The destination to configure. + internal abstract void ConfigureOptions(Kernel? kernel, ChatCompletionsOptions options); + + /// + /// Represents a that will provide to the model all available functions from a + /// provided by the client. Setting this will have no effect if no is provided. + /// + internal sealed class KernelFunctions : AzureToolCallBehavior + { + internal KernelFunctions(bool autoInvoke) : base(autoInvoke) { } + + public override string ToString() => $"{nameof(KernelFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0})"; + + internal override void ConfigureOptions(Kernel? kernel, ChatCompletionsOptions options) + { + // If no kernel is provided, we don't have any tools to provide. + if (kernel is not null) + { + // Provide all functions from the kernel. + IList functions = kernel.Plugins.GetFunctionsMetadata(); + if (functions.Count > 0) + { + options.ToolChoice = ChatCompletionsToolChoice.Auto; + for (int i = 0; i < functions.Count; i++) + { + options.Tools.Add(new ChatCompletionsFunctionToolDefinition(functions[i].ToAzureOpenAIFunction().ToFunctionDefinition())); + } + } + } + } + + internal override bool AllowAnyRequestedKernelFunction => true; + } + + /// + /// Represents a that provides a specified list of functions to the model. + /// + internal sealed class EnabledFunctions : AzureToolCallBehavior + { + private readonly AzureOpenAIFunction[] _openAIFunctions; + private readonly ChatCompletionsFunctionToolDefinition[] _functions; + + public EnabledFunctions(IEnumerable functions, bool autoInvoke) : base(autoInvoke) + { + this._openAIFunctions = functions.ToArray(); + + var defs = new ChatCompletionsFunctionToolDefinition[this._openAIFunctions.Length]; + for (int i = 0; i < defs.Length; i++) + { + defs[i] = new ChatCompletionsFunctionToolDefinition(this._openAIFunctions[i].ToFunctionDefinition()); + } + this._functions = defs; + } + + public override string ToString() => $"{nameof(EnabledFunctions)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0}): {string.Join(", ", this._functions.Select(f => f.Name))}"; + + internal override void ConfigureOptions(Kernel? kernel, ChatCompletionsOptions options) + { + AzureOpenAIFunction[] openAIFunctions = this._openAIFunctions; + ChatCompletionsFunctionToolDefinition[] functions = this._functions; + Debug.Assert(openAIFunctions.Length == functions.Length); + + if (openAIFunctions.Length > 0) + { + bool autoInvoke = base.MaximumAutoInvokeAttempts > 0; + + // If auto-invocation is specified, we need a kernel to be able to invoke the functions. + // Lack of a kernel is fatal: we don't want to tell the model we can handle the functions + // and then fail to do so, so we fail before we get to that point. This is an error + // on the consumers behalf: if they specify auto-invocation with any functions, they must + // specify the kernel and the kernel must contain those functions. + if (autoInvoke && kernel is null) + { + throw new KernelException($"Auto-invocation with {nameof(EnabledFunctions)} is not supported when no kernel is provided."); + } + + options.ToolChoice = ChatCompletionsToolChoice.Auto; + for (int i = 0; i < openAIFunctions.Length; i++) + { + // Make sure that if auto-invocation is specified, every enabled function can be found in the kernel. + if (autoInvoke) + { + Debug.Assert(kernel is not null); + AzureOpenAIFunction f = openAIFunctions[i]; + if (!kernel!.Plugins.TryGetFunction(f.PluginName, f.FunctionName, out _)) + { + throw new KernelException($"The specified {nameof(EnabledFunctions)} function {f.FullyQualifiedName} is not available in the kernel."); + } + } + + // Add the function. + options.Tools.Add(functions[i]); + } + } + } + } + + /// Represents a that requests the model use a specific function. + internal sealed class RequiredFunction : AzureToolCallBehavior + { + private readonly AzureOpenAIFunction _function; + private readonly ChatCompletionsFunctionToolDefinition _tool; + private readonly ChatCompletionsToolChoice _choice; + + public RequiredFunction(AzureOpenAIFunction function, bool autoInvoke) : base(autoInvoke) + { + this._function = function; + this._tool = new ChatCompletionsFunctionToolDefinition(function.ToFunctionDefinition()); + this._choice = new ChatCompletionsToolChoice(this._tool); + } + + public override string ToString() => $"{nameof(RequiredFunction)}(autoInvoke:{this.MaximumAutoInvokeAttempts != 0}): {this._tool.Name}"; + + internal override void ConfigureOptions(Kernel? kernel, ChatCompletionsOptions options) + { + bool autoInvoke = base.MaximumAutoInvokeAttempts > 0; + + // If auto-invocation is specified, we need a kernel to be able to invoke the functions. + // Lack of a kernel is fatal: we don't want to tell the model we can handle the functions + // and then fail to do so, so we fail before we get to that point. This is an error + // on the consumers behalf: if they specify auto-invocation with any functions, they must + // specify the kernel and the kernel must contain those functions. + if (autoInvoke && kernel is null) + { + throw new KernelException($"Auto-invocation with {nameof(RequiredFunction)} is not supported when no kernel is provided."); + } + + // Make sure that if auto-invocation is specified, the required function can be found in the kernel. + if (autoInvoke && !kernel!.Plugins.TryGetFunction(this._function.PluginName, this._function.FunctionName, out _)) + { + throw new KernelException($"The specified {nameof(RequiredFunction)} function {this._function.FullyQualifiedName} is not available in the kernel."); + } + + options.ToolChoice = this._choice; + options.Tools.Add(this._tool); + } + + /// Gets how many requests are part of a single interaction should include this tool in the request. + /// + /// Unlike and , this must use 1 as the maximum + /// use attempts. Otherwise, every call back to the model _requires_ it to invoke the function (as opposed + /// to allows it), which means we end up doing the same work over and over and over until the maximum is reached. + /// Thus for "requires", we must send the tool information only once. + /// + internal override int MaximumUseAttempts => 1; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatCompletion/AzureOpenAIChatCompletionService.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatCompletion/AzureOpenAIChatCompletionService.cs new file mode 100644 index 000000000000..e478a301d947 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatCompletion/AzureOpenAIChatCompletionService.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Services; +using Microsoft.SemanticKernel.TextGeneration; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Azure OpenAI chat completion service. +/// +public sealed class AzureOpenAIChatCompletionService : IChatCompletionService, ITextGenerationService +{ + /// Core implementation shared by Azure OpenAI clients. + private readonly AzureOpenAIClientCore _core; + + /// + /// Create an instance of the connector with API key auth. + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Azure OpenAI API key, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Azure OpenAI model id, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public AzureOpenAIChatCompletionService( + string deploymentName, + string endpoint, + string apiKey, + string? modelId = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + this._core = new(deploymentName, endpoint, apiKey, httpClient, loggerFactory?.CreateLogger(typeof(AzureOpenAIChatCompletionService))); + + this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + /// Create an instance of the connector with AAD auth. + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Token credentials, e.g. DefaultAzureCredential, ManagedIdentityCredential, EnvironmentCredential, etc. + /// Azure OpenAI model id, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + public AzureOpenAIChatCompletionService( + string deploymentName, + string endpoint, + TokenCredential credentials, + string? modelId = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + this._core = new(deploymentName, endpoint, credentials, httpClient, loggerFactory?.CreateLogger(typeof(AzureOpenAIChatCompletionService))); + this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + /// Creates a new client instance using the specified . + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Custom . + /// Azure OpenAI model id, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// The to use for logging. If null, no logging will be performed. + public AzureOpenAIChatCompletionService( + string deploymentName, + OpenAIClient openAIClient, + string? modelId = null, + ILoggerFactory? loggerFactory = null) + { + this._core = new(deploymentName, openAIClient, loggerFactory?.CreateLogger(typeof(AzureOpenAIChatCompletionService))); + this._core.AddAttribute(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._core.Attributes; + + /// + public Task> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + => this._core.GetChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken); + + /// + public IAsyncEnumerable GetStreamingChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + => this._core.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken); + + /// + public Task> GetTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + => this._core.GetChatAsTextContentsAsync(prompt, executionSettings, kernel, cancellationToken); + + /// + public IAsyncEnumerable GetStreamingTextContentsAsync(string prompt, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + => this._core.GetChatAsTextStreamingContentsAsync(prompt, executionSettings, kernel, cancellationToken); +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatHistoryExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatHistoryExtensions.cs new file mode 100644 index 000000000000..23412f666e23 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/ChatHistoryExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +namespace Microsoft.SemanticKernel; + +/// +/// Chat history extensions. +/// +public static class ChatHistoryExtensions +{ + /// + /// Add a message to the chat history at the end of the streamed message + /// + /// Target chat history + /// list of streaming message contents + /// Returns the original streaming results with some message processing + [Experimental("SKEXP0010")] + public static async IAsyncEnumerable AddStreamingMessageAsync(this ChatHistory chatHistory, IAsyncEnumerable streamingMessageContents) + { + List messageContents = []; + + // Stream the response. + StringBuilder? contentBuilder = null; + Dictionary? toolCallIdsByIndex = null; + Dictionary? functionNamesByIndex = null; + Dictionary? functionArgumentBuildersByIndex = null; + Dictionary? metadata = null; + AuthorRole? streamedRole = null; + string? streamedName = null; + + await foreach (var chatMessage in streamingMessageContents.ConfigureAwait(false)) + { + metadata ??= (Dictionary?)chatMessage.Metadata; + + if (chatMessage.Content is { Length: > 0 } contentUpdate) + { + (contentBuilder ??= new()).Append(contentUpdate); + } + + AzureOpenAIFunctionToolCall.TrackStreamingToolingUpdate(chatMessage.ToolCallUpdate, ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex); + + // Is always expected to have at least one chunk with the role provided from a streaming message + streamedRole ??= chatMessage.Role; + streamedName ??= chatMessage.AuthorName; + + messageContents.Add(chatMessage); + yield return chatMessage; + } + + if (messageContents.Count != 0) + { + var role = streamedRole ?? AuthorRole.Assistant; + + chatHistory.Add( + new AzureOpenAIChatMessageContent( + role, + contentBuilder?.ToString() ?? string.Empty, + messageContents[0].ModelId!, + AzureOpenAIFunctionToolCall.ConvertToolCallUpdatesToChatCompletionsFunctionToolCalls(ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex), + metadata) + { AuthorName = streamedName }); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj b/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj index 837dd5b3c1db..8e8f53594708 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Connectors.AzureOpenAI.csproj @@ -21,7 +21,7 @@ - + diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIChatMessageContent.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIChatMessageContent.cs new file mode 100644 index 000000000000..8cbecc909951 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIChatMessageContent.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// AzureOpenAI specialized chat message content +/// +public sealed class AzureOpenAIChatMessageContent : ChatMessageContent +{ + /// + /// Gets the metadata key for the name property. + /// + public static string ToolIdProperty => $"{nameof(ChatCompletionsToolCall)}.{nameof(ChatCompletionsToolCall.Id)}"; + + /// + /// Gets the metadata key for the list of . + /// + internal static string FunctionToolCallsProperty => $"{nameof(ChatResponseMessage)}.FunctionToolCalls"; + + /// + /// Initializes a new instance of the class. + /// + internal AzureOpenAIChatMessageContent(ChatResponseMessage chatMessage, string modelId, IReadOnlyDictionary? metadata = null) + : base(new AuthorRole(chatMessage.Role.ToString()), chatMessage.Content, modelId, chatMessage, System.Text.Encoding.UTF8, CreateMetadataDictionary(chatMessage.ToolCalls, metadata)) + { + this.ToolCalls = chatMessage.ToolCalls; + } + + /// + /// Initializes a new instance of the class. + /// + internal AzureOpenAIChatMessageContent(ChatRole role, string? content, string modelId, IReadOnlyList toolCalls, IReadOnlyDictionary? metadata = null) + : base(new AuthorRole(role.ToString()), content, modelId, content, System.Text.Encoding.UTF8, CreateMetadataDictionary(toolCalls, metadata)) + { + this.ToolCalls = toolCalls; + } + + /// + /// Initializes a new instance of the class. + /// + internal AzureOpenAIChatMessageContent(AuthorRole role, string? content, string modelId, IReadOnlyList toolCalls, IReadOnlyDictionary? metadata = null) + : base(role, content, modelId, content, System.Text.Encoding.UTF8, CreateMetadataDictionary(toolCalls, metadata)) + { + this.ToolCalls = toolCalls; + } + + /// + /// A list of the tools called by the model. + /// + public IReadOnlyList ToolCalls { get; } + + /// + /// Retrieve the resulting function from the chat result. + /// + /// The , or null if no function was returned by the model. + public IReadOnlyList GetOpenAIFunctionToolCalls() + { + List? functionToolCallList = null; + + foreach (var toolCall in this.ToolCalls) + { + if (toolCall is ChatCompletionsFunctionToolCall functionToolCall) + { + (functionToolCallList ??= []).Add(new AzureOpenAIFunctionToolCall(functionToolCall)); + } + } + + if (functionToolCallList is not null) + { + return functionToolCallList; + } + + return []; + } + + private static IReadOnlyDictionary? CreateMetadataDictionary( + IReadOnlyList toolCalls, + IReadOnlyDictionary? original) + { + // We only need to augment the metadata if there are any tool calls. + if (toolCalls.Count > 0) + { + Dictionary newDictionary; + if (original is null) + { + // There's no existing metadata to clone; just allocate a new dictionary. + newDictionary = new Dictionary(1); + } + else if (original is IDictionary origIDictionary) + { + // Efficiently clone the old dictionary to a new one. + newDictionary = new Dictionary(origIDictionary); + } + else + { + // There's metadata to clone but we have to do so one item at a time. + newDictionary = new Dictionary(original.Count + 1); + foreach (var kvp in original) + { + newDictionary[kvp.Key] = kvp.Value; + } + } + + // Add the additional entry. + newDictionary.Add(FunctionToolCallsProperty, toolCalls.OfType().ToList()); + + return newDictionary; + } + + return original; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIClientCore.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIClientCore.cs new file mode 100644 index 000000000000..e34b191a83b8 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIClientCore.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Azure; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Core implementation for Azure OpenAI clients, providing common functionality and properties. +/// +internal sealed class AzureOpenAIClientCore : ClientCore +{ + /// + /// Gets the key used to store the deployment name in the dictionary. + /// + public static string DeploymentNameKey => "DeploymentName"; + + /// + /// OpenAI / Azure OpenAI Client + /// + internal override OpenAIClient Client { get; } + + /// + /// Initializes a new instance of the class using API Key authentication. + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Azure OpenAI API key, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + internal AzureOpenAIClientCore( + string deploymentName, + string endpoint, + string apiKey, + HttpClient? httpClient = null, + ILogger? logger = null) : base(logger) + { + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.StartsWith(endpoint, "https://", "The Azure OpenAI endpoint must start with 'https://'"); + Verify.NotNullOrWhiteSpace(apiKey); + + var options = GetOpenAIClientOptions(httpClient); + + this.DeploymentOrModelName = deploymentName; + this.Endpoint = new Uri(endpoint); + this.Client = new OpenAIClient(this.Endpoint, new AzureKeyCredential(apiKey), options); + } + + /// + /// Initializes a new instance of the class supporting AAD authentication. + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Azure OpenAI deployment URL, see https://learn.microsoft.com/azure/cognitive-services/openai/quickstart + /// Token credential, e.g. DefaultAzureCredential, ManagedIdentityCredential, EnvironmentCredential, etc. + /// Custom for HTTP requests. + /// The to use for logging. If null, no logging will be performed. + internal AzureOpenAIClientCore( + string deploymentName, + string endpoint, + TokenCredential credential, + HttpClient? httpClient = null, + ILogger? logger = null) : base(logger) + { + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNullOrWhiteSpace(endpoint); + Verify.StartsWith(endpoint, "https://", "The Azure OpenAI endpoint must start with 'https://'"); + + var options = GetOpenAIClientOptions(httpClient); + + this.DeploymentOrModelName = deploymentName; + this.Endpoint = new Uri(endpoint); + this.Client = new OpenAIClient(this.Endpoint, credential, options); + } + + /// + /// Initializes a new instance of the class using the specified OpenAIClient. + /// Note: instances created this way might not have the default diagnostics settings, + /// it's up to the caller to configure the client. + /// + /// Azure OpenAI deployment name, see https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource + /// Custom . + /// The to use for logging. If null, no logging will be performed. + internal AzureOpenAIClientCore( + string deploymentName, + OpenAIClient openAIClient, + ILogger? logger = null) : base(logger) + { + Verify.NotNullOrWhiteSpace(deploymentName); + Verify.NotNull(openAIClient); + + this.DeploymentOrModelName = deploymentName; + this.Client = openAIClient; + + this.AddAttribute(DeploymentNameKey, deploymentName); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunction.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunction.cs new file mode 100644 index 000000000000..4a3cff49103d --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunction.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Azure.AI.OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Represents a function parameter that can be passed to an AzureOpenAI function tool call. +/// +public sealed class AzureOpenAIFunctionParameter +{ + internal AzureOpenAIFunctionParameter(string? name, string? description, bool isRequired, Type? parameterType, KernelJsonSchema? schema) + { + this.Name = name ?? string.Empty; + this.Description = description ?? string.Empty; + this.IsRequired = isRequired; + this.ParameterType = parameterType; + this.Schema = schema; + } + + /// Gets the name of the parameter. + public string Name { get; } + + /// Gets a description of the parameter. + public string Description { get; } + + /// Gets whether the parameter is required vs optional. + public bool IsRequired { get; } + + /// Gets the of the parameter, if known. + public Type? ParameterType { get; } + + /// Gets a JSON schema for the parameter, if known. + public KernelJsonSchema? Schema { get; } +} + +/// +/// Represents a function return parameter that can be returned by a tool call to AzureOpenAI. +/// +public sealed class AzureOpenAIFunctionReturnParameter +{ + internal AzureOpenAIFunctionReturnParameter(string? description, Type? parameterType, KernelJsonSchema? schema) + { + this.Description = description ?? string.Empty; + this.Schema = schema; + this.ParameterType = parameterType; + } + + /// Gets a description of the return parameter. + public string Description { get; } + + /// Gets the of the return parameter, if known. + public Type? ParameterType { get; } + + /// Gets a JSON schema for the return parameter, if known. + public KernelJsonSchema? Schema { get; } +} + +/// +/// Represents a function that can be passed to the AzureOpenAI API +/// +public sealed class AzureOpenAIFunction +{ + /// + /// Cached storing the JSON for a function with no parameters. + /// + /// + /// This is an optimization to avoid serializing the same JSON Schema over and over again + /// for this relatively common case. + /// + private static readonly BinaryData s_zeroFunctionParametersSchema = new("""{"type":"object","required":[],"properties":{}}"""); + /// + /// Cached schema for a descriptionless string. + /// + private static readonly KernelJsonSchema s_stringNoDescriptionSchema = KernelJsonSchema.Parse("""{"type":"string"}"""); + + /// Initializes the OpenAIFunction. + internal AzureOpenAIFunction( + string? pluginName, + string functionName, + string? description, + IReadOnlyList? parameters, + AzureOpenAIFunctionReturnParameter? returnParameter) + { + Verify.NotNullOrWhiteSpace(functionName); + + this.PluginName = pluginName; + this.FunctionName = functionName; + this.Description = description; + this.Parameters = parameters; + this.ReturnParameter = returnParameter; + } + + /// Gets the separator used between the plugin name and the function name, if a plugin name is present. + /// This separator was previously _, but has been changed to - to better align to the behavior elsewhere in SK and in response + /// to developers who want to use underscores in their function or plugin names. We plan to make this setting configurable in the future. + public static string NameSeparator { get; set; } = "-"; + + /// Gets the name of the plugin with which the function is associated, if any. + public string? PluginName { get; } + + /// Gets the name of the function. + public string FunctionName { get; } + + /// Gets the fully-qualified name of the function. + /// + /// This is the concatenation of the and the , + /// separated by . If there is no , this is + /// the same as . + /// + public string FullyQualifiedName => + string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{NameSeparator}{this.FunctionName}"; + + /// Gets a description of the function. + public string? Description { get; } + + /// Gets a list of parameters to the function, if any. + public IReadOnlyList? Parameters { get; } + + /// Gets the return parameter of the function, if any. + public AzureOpenAIFunctionReturnParameter? ReturnParameter { get; } + + /// + /// Converts the representation to the Azure SDK's + /// representation. + /// + /// A containing all the function information. + public FunctionDefinition ToFunctionDefinition() + { + BinaryData resultParameters = s_zeroFunctionParametersSchema; + + IReadOnlyList? parameters = this.Parameters; + if (parameters is { Count: > 0 }) + { + var properties = new Dictionary(); + var required = new List(); + + for (int i = 0; i < parameters.Count; i++) + { + var parameter = parameters[i]; + properties.Add(parameter.Name, parameter.Schema ?? GetDefaultSchemaForTypelessParameter(parameter.Description)); + if (parameter.IsRequired) + { + required.Add(parameter.Name); + } + } + + resultParameters = BinaryData.FromObjectAsJson(new + { + type = "object", + required, + properties, + }); + } + + return new FunctionDefinition + { + Name = this.FullyQualifiedName, + Description = this.Description, + Parameters = resultParameters, + }; + } + + /// Gets a for a typeless parameter with the specified description, defaulting to typeof(string) + private static KernelJsonSchema GetDefaultSchemaForTypelessParameter(string? description) + { + // If there's a description, incorporate it. + if (!string.IsNullOrWhiteSpace(description)) + { + return KernelJsonSchemaBuilder.Build(null, typeof(string), description); + } + + // Otherwise, we can use a cached schema for a string with no description. + return s_stringNoDescriptionSchema; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunctionToolCall.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunctionToolCall.cs new file mode 100644 index 000000000000..bea73a474d37 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIFunctionToolCall.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Azure.AI.OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Represents an AzureOpenAI function tool call with deserialized function name and arguments. +/// +public sealed class AzureOpenAIFunctionToolCall +{ + private string? _fullyQualifiedFunctionName; + + /// Initialize the from a . + internal AzureOpenAIFunctionToolCall(ChatCompletionsFunctionToolCall functionToolCall) + { + Verify.NotNull(functionToolCall); + Verify.NotNull(functionToolCall.Name); + + string fullyQualifiedFunctionName = functionToolCall.Name; + string functionName = fullyQualifiedFunctionName; + string? arguments = functionToolCall.Arguments; + string? pluginName = null; + + int separatorPos = fullyQualifiedFunctionName.IndexOf(AzureOpenAIFunction.NameSeparator, StringComparison.Ordinal); + if (separatorPos >= 0) + { + pluginName = fullyQualifiedFunctionName.AsSpan(0, separatorPos).Trim().ToString(); + functionName = fullyQualifiedFunctionName.AsSpan(separatorPos + AzureOpenAIFunction.NameSeparator.Length).Trim().ToString(); + } + + this.Id = functionToolCall.Id; + this._fullyQualifiedFunctionName = fullyQualifiedFunctionName; + this.PluginName = pluginName; + this.FunctionName = functionName; + if (!string.IsNullOrWhiteSpace(arguments)) + { + this.Arguments = JsonSerializer.Deserialize>(arguments!); + } + } + + /// Gets the ID of the tool call. + public string? Id { get; } + + /// Gets the name of the plugin with which this function is associated, if any. + public string? PluginName { get; } + + /// Gets the name of the function. + public string FunctionName { get; } + + /// Gets a name/value collection of the arguments to the function, if any. + public Dictionary? Arguments { get; } + + /// Gets the fully-qualified name of the function. + /// + /// This is the concatenation of the and the , + /// separated by . If there is no , + /// this is the same as . + /// + public string FullyQualifiedName => + this._fullyQualifiedFunctionName ??= + string.IsNullOrEmpty(this.PluginName) ? this.FunctionName : $"{this.PluginName}{AzureOpenAIFunction.NameSeparator}{this.FunctionName}"; + + /// + public override string ToString() + { + var sb = new StringBuilder(this.FullyQualifiedName); + + sb.Append('('); + if (this.Arguments is not null) + { + string separator = ""; + foreach (var arg in this.Arguments) + { + sb.Append(separator).Append(arg.Key).Append(':').Append(arg.Value); + separator = ", "; + } + } + sb.Append(')'); + + return sb.ToString(); + } + + /// + /// Tracks tooling updates from streaming responses. + /// + /// The tool call update to incorporate. + /// Lazily-initialized dictionary mapping indices to IDs. + /// Lazily-initialized dictionary mapping indices to names. + /// Lazily-initialized dictionary mapping indices to arguments. + internal static void TrackStreamingToolingUpdate( + StreamingToolCallUpdate? update, + ref Dictionary? toolCallIdsByIndex, + ref Dictionary? functionNamesByIndex, + ref Dictionary? functionArgumentBuildersByIndex) + { + if (update is null) + { + // Nothing to track. + return; + } + + // If we have an ID, ensure the index is being tracked. Even if it's not a function update, + // we want to keep track of it so we can send back an error. + if (update.Id is string id) + { + (toolCallIdsByIndex ??= [])[update.ToolCallIndex] = id; + } + + if (update is StreamingFunctionToolCallUpdate ftc) + { + // Ensure we're tracking the function's name. + if (ftc.Name is string name) + { + (functionNamesByIndex ??= [])[ftc.ToolCallIndex] = name; + } + + // Ensure we're tracking the function's arguments. + if (ftc.ArgumentsUpdate is string argumentsUpdate) + { + if (!(functionArgumentBuildersByIndex ??= []).TryGetValue(ftc.ToolCallIndex, out StringBuilder? arguments)) + { + functionArgumentBuildersByIndex[ftc.ToolCallIndex] = arguments = new(); + } + + arguments.Append(argumentsUpdate); + } + } + } + + /// + /// Converts the data built up by into an array of s. + /// + /// Dictionary mapping indices to IDs. + /// Dictionary mapping indices to names. + /// Dictionary mapping indices to arguments. + internal static ChatCompletionsFunctionToolCall[] ConvertToolCallUpdatesToChatCompletionsFunctionToolCalls( + ref Dictionary? toolCallIdsByIndex, + ref Dictionary? functionNamesByIndex, + ref Dictionary? functionArgumentBuildersByIndex) + { + ChatCompletionsFunctionToolCall[] toolCalls = []; + if (toolCallIdsByIndex is { Count: > 0 }) + { + toolCalls = new ChatCompletionsFunctionToolCall[toolCallIdsByIndex.Count]; + + int i = 0; + foreach (KeyValuePair toolCallIndexAndId in toolCallIdsByIndex) + { + string? functionName = null; + StringBuilder? functionArguments = null; + + functionNamesByIndex?.TryGetValue(toolCallIndexAndId.Key, out functionName); + functionArgumentBuildersByIndex?.TryGetValue(toolCallIndexAndId.Key, out functionArguments); + + toolCalls[i] = new ChatCompletionsFunctionToolCall(toolCallIndexAndId.Value, functionName ?? string.Empty, functionArguments?.ToString() ?? string.Empty); + i++; + } + + Debug.Assert(i == toolCalls.Length); + } + + return toolCalls; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIKernelFunctionMetadataExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIKernelFunctionMetadataExtensions.cs new file mode 100644 index 000000000000..30f796f82ae0 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIKernelFunctionMetadataExtensions.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Extensions for specific to the AzureOpenAI connector. +/// +public static class AzureOpenAIKernelFunctionMetadataExtensions +{ + /// + /// Convert a to an . + /// + /// The object to convert. + /// An object. + public static AzureOpenAIFunction ToAzureOpenAIFunction(this KernelFunctionMetadata metadata) + { + IReadOnlyList metadataParams = metadata.Parameters; + + var openAIParams = new AzureOpenAIFunctionParameter[metadataParams.Count]; + for (int i = 0; i < openAIParams.Length; i++) + { + var param = metadataParams[i]; + + openAIParams[i] = new AzureOpenAIFunctionParameter( + param.Name, + GetDescription(param), + param.IsRequired, + param.ParameterType, + param.Schema); + } + + return new AzureOpenAIFunction( + metadata.PluginName, + metadata.Name, + metadata.Description, + openAIParams, + new AzureOpenAIFunctionReturnParameter( + metadata.ReturnParameter.Description, + metadata.ReturnParameter.ParameterType, + metadata.ReturnParameter.Schema)); + + static string GetDescription(KernelParameterMetadata param) + { + if (InternalTypeConverter.ConvertToString(param.DefaultValue) is string stringValue && !string.IsNullOrEmpty(stringValue)) + { + return $"{param.Description} (default value: {stringValue})"; + } + + return param.Description; + } + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIPluginCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIPluginCollectionExtensions.cs new file mode 100644 index 000000000000..c667183f773c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIPluginCollectionExtensions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Azure.AI.OpenAI; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Extension methods for . +/// +public static class AzureOpenAIPluginCollectionExtensions +{ + /// + /// Given an object, tries to retrieve the corresponding and populate with its parameters. + /// + /// The plugins. + /// The object. + /// When this method returns, the function that was retrieved if one with the specified name was found; otherwise, + /// When this method returns, the arguments for the function; otherwise, + /// if the function was found; otherwise, . + public static bool TryGetFunctionAndArguments( + this IReadOnlyKernelPluginCollection plugins, + ChatCompletionsFunctionToolCall functionToolCall, + [NotNullWhen(true)] out KernelFunction? function, + out KernelArguments? arguments) => + plugins.TryGetFunctionAndArguments(new AzureOpenAIFunctionToolCall(functionToolCall), out function, out arguments); + + /// + /// Given an object, tries to retrieve the corresponding and populate with its parameters. + /// + /// The plugins. + /// The object. + /// When this method returns, the function that was retrieved if one with the specified name was found; otherwise, + /// When this method returns, the arguments for the function; otherwise, + /// if the function was found; otherwise, . + public static bool TryGetFunctionAndArguments( + this IReadOnlyKernelPluginCollection plugins, + AzureOpenAIFunctionToolCall functionToolCall, + [NotNullWhen(true)] out KernelFunction? function, + out KernelArguments? arguments) + { + if (plugins.TryGetFunction(functionToolCall.PluginName, functionToolCall.FunctionName, out function)) + { + // Add parameters to arguments + arguments = null; + if (functionToolCall.Arguments is not null) + { + arguments = []; + foreach (var parameter in functionToolCall.Arguments) + { + arguments[parameter.Key] = parameter.Value?.ToString(); + } + } + + return true; + } + + // Function not found in collection + arguments = null; + return false; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingChatMessageContent.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingChatMessageContent.cs new file mode 100644 index 000000000000..c1843b185f89 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingChatMessageContent.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; +using Azure.AI.OpenAI; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Azure OpenAI specialized streaming chat message content. +/// +/// +/// Represents a chat message content chunk that was streamed from the remote model. +/// +public sealed class AzureOpenAIStreamingChatMessageContent : StreamingChatMessageContent +{ + /// + /// The reason why the completion finished. + /// + public CompletionsFinishReason? FinishReason { get; set; } + + /// + /// Create a new instance of the class. + /// + /// Internal Azure SDK Message update representation + /// Index of the choice + /// The model ID used to generate the content + /// Additional metadata + internal AzureOpenAIStreamingChatMessageContent( + StreamingChatCompletionsUpdate chatUpdate, + int choiceIndex, + string modelId, + IReadOnlyDictionary? metadata = null) + : base( + chatUpdate.Role.HasValue ? new AuthorRole(chatUpdate.Role.Value.ToString()) : null, + chatUpdate.ContentUpdate, + chatUpdate, + choiceIndex, + modelId, + Encoding.UTF8, + metadata) + { + this.ToolCallUpdate = chatUpdate.ToolCallUpdate; + this.FinishReason = chatUpdate?.FinishReason; + } + + /// + /// Create a new instance of the class. + /// + /// Author role of the message + /// Content of the message + /// Tool call update + /// Completion finish reason + /// Index of the choice + /// The model ID used to generate the content + /// Additional metadata + internal AzureOpenAIStreamingChatMessageContent( + AuthorRole? authorRole, + string? content, + StreamingToolCallUpdate? tootToolCallUpdate = null, + CompletionsFinishReason? completionsFinishReason = null, + int choiceIndex = 0, + string? modelId = null, + IReadOnlyDictionary? metadata = null) + : base( + authorRole, + content, + null, + choiceIndex, + modelId, + Encoding.UTF8, + metadata) + { + this.ToolCallUpdate = tootToolCallUpdate; + this.FinishReason = completionsFinishReason; + } + + /// Gets any update information in the message about a tool call. + public StreamingToolCallUpdate? ToolCallUpdate { get; } + + /// + public override byte[] ToByteArray() => this.Encoding.GetBytes(this.ToString()); + + /// + public override string ToString() => this.Content ?? string.Empty; +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingTextContent.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingTextContent.cs new file mode 100644 index 000000000000..9d9497fd68d5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureOpenAIStreamingTextContent.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Azure OpenAI specialized streaming text content. +/// +/// +/// Represents a text content chunk that was streamed from the remote model. +/// +public sealed class AzureOpenAIStreamingTextContent : StreamingTextContent +{ + /// + /// Create a new instance of the class. + /// + /// Text update + /// Index of the choice + /// The model ID used to generate the content + /// Inner chunk object + /// Metadata information + internal AzureOpenAIStreamingTextContent( + string text, + int choiceIndex, + string modelId, + object? innerContentObject = null, + IReadOnlyDictionary? metadata = null) + : base( + text, + choiceIndex, + modelId, + innerContentObject, + Encoding.UTF8, + metadata) + { + } + + /// + public override byte[] ToByteArray() + { + return this.Encoding.GetBytes(this.ToString()); + } + + /// + public override string ToString() + { + return this.Text ?? string.Empty; + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/ClientCore.cs new file mode 100644 index 000000000000..dda7578da8ea --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/ClientCore.cs @@ -0,0 +1,1574 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Core.Pipeline; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Diagnostics; +using Microsoft.SemanticKernel.Http; + +#pragma warning disable CA2208 // Instantiate argument exceptions correctly + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Base class for AI clients that provides common functionality for interacting with OpenAI services. +/// +internal abstract class ClientCore +{ + private const string ModelProvider = "openai"; + private const int MaxResultsPerPrompt = 128; + + /// + /// The maximum number of auto-invokes that can be in-flight at any given time as part of the current + /// asynchronous chain of execution. + /// + /// + /// This is a fail-safe mechanism. If someone accidentally manages to set up execution settings in such a way that + /// auto-invocation is invoked recursively, and in particular where a prompt function is able to auto-invoke itself, + /// we could end up in an infinite loop. This const is a backstop against that happening. We should never come close + /// to this limit, but if we do, auto-invoke will be disabled for the current flow in order to prevent runaway execution. + /// With the current setup, the way this could possibly happen is if a prompt function is configured with built-in + /// execution settings that opt-in to auto-invocation of everything in the kernel, in which case the invocation of that + /// prompt function could advertize itself as a candidate for auto-invocation. We don't want to outright block that, + /// if that's something a developer has asked to do (e.g. it might be invoked with different arguments than its parent + /// was invoked with), but we do want to limit it. This limit is arbitrary and can be tweaked in the future and/or made + /// configurable should need arise. + /// + private const int MaxInflightAutoInvokes = 128; + + /// Singleton tool used when tool call count drops to 0 but we need to supply tools to keep the service happy. + private static readonly ChatCompletionsFunctionToolDefinition s_nonInvocableFunctionTool = new() { Name = "NonInvocableTool" }; + + /// Tracking for . + private static readonly AsyncLocal s_inflightAutoInvokes = new(); + + internal ClientCore(ILogger? logger = null) + { + this.Logger = logger ?? NullLogger.Instance; + } + + /// + /// Model Id or Deployment Name + /// + internal string DeploymentOrModelName { get; set; } = string.Empty; + + /// + /// OpenAI / Azure OpenAI Client + /// + internal abstract OpenAIClient Client { get; } + + internal Uri? Endpoint { get; set; } = null; + + /// + /// Logger instance + /// + internal ILogger Logger { get; set; } + + /// + /// Storage for AI service attributes. + /// + internal Dictionary Attributes { get; } = []; + + /// + /// Instance of for metrics. + /// + private static readonly Meter s_meter = new("Microsoft.SemanticKernel.Connectors.OpenAI"); + + /// + /// Instance of to keep track of the number of prompt tokens used. + /// + private static readonly Counter s_promptTokensCounter = + s_meter.CreateCounter( + name: "semantic_kernel.connectors.openai.tokens.prompt", + unit: "{token}", + description: "Number of prompt tokens used"); + + /// + /// Instance of to keep track of the number of completion tokens used. + /// + private static readonly Counter s_completionTokensCounter = + s_meter.CreateCounter( + name: "semantic_kernel.connectors.openai.tokens.completion", + unit: "{token}", + description: "Number of completion tokens used"); + + /// + /// Instance of to keep track of the total number of tokens used. + /// + private static readonly Counter s_totalTokensCounter = + s_meter.CreateCounter( + name: "semantic_kernel.connectors.openai.tokens.total", + unit: "{token}", + description: "Number of tokens used"); + + /// + /// Creates completions for the prompt and settings. + /// + /// The prompt to complete. + /// Execution settings for the completion API. + /// The containing services, plugins, and other state for use throughout the operation. + /// The to monitor for cancellation requests. The default is . + /// Completions generated by the remote model + internal async Task> GetTextResultsAsync( + string prompt, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + CancellationToken cancellationToken = default) + { + AzureOpenAIPromptExecutionSettings textExecutionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings, AzureOpenAIPromptExecutionSettings.DefaultTextMaxTokens); + + ValidateMaxTokens(textExecutionSettings.MaxTokens); + + var options = CreateCompletionsOptions(prompt, textExecutionSettings, this.DeploymentOrModelName); + + Completions? responseData = null; + List responseContent; + using (var activity = ModelDiagnostics.StartCompletionActivity(this.Endpoint, this.DeploymentOrModelName, ModelProvider, prompt, textExecutionSettings)) + { + try + { + responseData = (await RunRequestAsync(() => this.Client.GetCompletionsAsync(options, cancellationToken)).ConfigureAwait(false)).Value; + if (responseData.Choices.Count == 0) + { + throw new KernelException("Text completions not found"); + } + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + if (responseData != null) + { + // Capture available metadata even if the operation failed. + activity + .SetResponseId(responseData.Id) + .SetPromptTokenUsage(responseData.Usage.PromptTokens) + .SetCompletionTokenUsage(responseData.Usage.CompletionTokens); + } + throw; + } + + responseContent = responseData.Choices.Select(choice => new TextContent(choice.Text, this.DeploymentOrModelName, choice, Encoding.UTF8, GetTextChoiceMetadata(responseData, choice))).ToList(); + activity?.SetCompletionResponse(responseContent, responseData.Usage.PromptTokens, responseData.Usage.CompletionTokens); + } + + this.LogUsage(responseData.Usage); + + return responseContent; + } + + internal async IAsyncEnumerable GetStreamingTextContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AzureOpenAIPromptExecutionSettings textExecutionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings, AzureOpenAIPromptExecutionSettings.DefaultTextMaxTokens); + + ValidateMaxTokens(textExecutionSettings.MaxTokens); + + var options = CreateCompletionsOptions(prompt, textExecutionSettings, this.DeploymentOrModelName); + + using var activity = ModelDiagnostics.StartCompletionActivity(this.Endpoint, this.DeploymentOrModelName, ModelProvider, prompt, textExecutionSettings); + + StreamingResponse response; + try + { + response = await RunRequestAsync(() => this.Client.GetCompletionsStreamingAsync(options, cancellationToken)).ConfigureAwait(false); + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + var responseEnumerator = response.ConfigureAwait(false).GetAsyncEnumerator(); + List? streamedContents = activity is not null ? [] : null; + try + { + while (true) + { + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + Completions completions = responseEnumerator.Current; + foreach (Choice choice in completions.Choices) + { + var openAIStreamingTextContent = new AzureOpenAIStreamingTextContent( + choice.Text, choice.Index, this.DeploymentOrModelName, choice, GetTextChoiceMetadata(completions, choice)); + streamedContents?.Add(openAIStreamingTextContent); + yield return openAIStreamingTextContent; + } + } + } + finally + { + activity?.EndStreaming(streamedContents); + await responseEnumerator.DisposeAsync(); + } + } + + private static Dictionary GetTextChoiceMetadata(Completions completions, Choice choice) + { + return new Dictionary(8) + { + { nameof(completions.Id), completions.Id }, + { nameof(completions.Created), completions.Created }, + { nameof(completions.PromptFilterResults), completions.PromptFilterResults }, + { nameof(completions.Usage), completions.Usage }, + { nameof(choice.ContentFilterResults), choice.ContentFilterResults }, + + // Serialization of this struct behaves as an empty object {}, need to cast to string to avoid it. + { nameof(choice.FinishReason), choice.FinishReason?.ToString() }, + + { nameof(choice.LogProbabilityModel), choice.LogProbabilityModel }, + { nameof(choice.Index), choice.Index }, + }; + } + + private static Dictionary GetChatChoiceMetadata(ChatCompletions completions, ChatChoice chatChoice) + { + return new Dictionary(12) + { + { nameof(completions.Id), completions.Id }, + { nameof(completions.Created), completions.Created }, + { nameof(completions.PromptFilterResults), completions.PromptFilterResults }, + { nameof(completions.SystemFingerprint), completions.SystemFingerprint }, + { nameof(completions.Usage), completions.Usage }, + { nameof(chatChoice.ContentFilterResults), chatChoice.ContentFilterResults }, + + // Serialization of this struct behaves as an empty object {}, need to cast to string to avoid it. + { nameof(chatChoice.FinishReason), chatChoice.FinishReason?.ToString() }, + + { nameof(chatChoice.FinishDetails), chatChoice.FinishDetails }, + { nameof(chatChoice.LogProbabilityInfo), chatChoice.LogProbabilityInfo }, + { nameof(chatChoice.Index), chatChoice.Index }, + { nameof(chatChoice.Enhancements), chatChoice.Enhancements }, + }; + } + + private static Dictionary GetResponseMetadata(StreamingChatCompletionsUpdate completions) + { + return new Dictionary(4) + { + { nameof(completions.Id), completions.Id }, + { nameof(completions.Created), completions.Created }, + { nameof(completions.SystemFingerprint), completions.SystemFingerprint }, + + // Serialization of this struct behaves as an empty object {}, need to cast to string to avoid it. + { nameof(completions.FinishReason), completions.FinishReason?.ToString() }, + }; + } + + private static Dictionary GetResponseMetadata(AudioTranscription audioTranscription) + { + return new Dictionary(3) + { + { nameof(audioTranscription.Language), audioTranscription.Language }, + { nameof(audioTranscription.Duration), audioTranscription.Duration }, + { nameof(audioTranscription.Segments), audioTranscription.Segments } + }; + } + + /// + /// Generates an embedding from the given . + /// + /// List of strings to generate embeddings for + /// The containing services, plugins, and other state for use throughout the operation. + /// The number of dimensions the resulting output embeddings should have. Only supported in "text-embedding-3" and later models. + /// The to monitor for cancellation requests. The default is . + /// List of embeddings + internal async Task>> GetEmbeddingsAsync( + IList data, + Kernel? kernel, + int? dimensions, + CancellationToken cancellationToken) + { + var result = new List>(data.Count); + + if (data.Count > 0) + { + var embeddingsOptions = new EmbeddingsOptions(this.DeploymentOrModelName, data) + { + Dimensions = dimensions + }; + + var response = await RunRequestAsync(() => this.Client.GetEmbeddingsAsync(embeddingsOptions, cancellationToken)).ConfigureAwait(false); + var embeddings = response.Value.Data; + + if (embeddings.Count != data.Count) + { + throw new KernelException($"Expected {data.Count} text embedding(s), but received {embeddings.Count}"); + } + + for (var i = 0; i < embeddings.Count; i++) + { + result.Add(embeddings[i].Embedding); + } + } + + return result; + } + + //internal async Task> GetTextContentFromAudioAsync( + // AudioContent content, + // PromptExecutionSettings? executionSettings, + // CancellationToken cancellationToken) + //{ + // Verify.NotNull(content.Data); + // var audioData = content.Data.Value; + // if (audioData.IsEmpty) + // { + // throw new ArgumentException("Audio data cannot be empty", nameof(content)); + // } + + // OpenAIAudioToTextExecutionSettings? audioExecutionSettings = OpenAIAudioToTextExecutionSettings.FromExecutionSettings(executionSettings); + + // Verify.ValidFilename(audioExecutionSettings?.Filename); + + // var audioOptions = new AudioTranscriptionOptions + // { + // AudioData = BinaryData.FromBytes(audioData), + // DeploymentName = this.DeploymentOrModelName, + // Filename = audioExecutionSettings.Filename, + // Language = audioExecutionSettings.Language, + // Prompt = audioExecutionSettings.Prompt, + // ResponseFormat = audioExecutionSettings.ResponseFormat, + // Temperature = audioExecutionSettings.Temperature + // }; + + // AudioTranscription responseData = (await RunRequestAsync(() => this.Client.GetAudioTranscriptionAsync(audioOptions, cancellationToken)).ConfigureAwait(false)).Value; + + // return [new(responseData.Text, this.DeploymentOrModelName, metadata: GetResponseMetadata(responseData))]; + //} + + /// + /// Generate a new chat message + /// + /// Chat history + /// Execution settings for the completion API. + /// The containing services, plugins, and other state for use throughout the operation. + /// Async cancellation token + /// Generated chat message in string format + internal async Task> GetChatMessageContentsAsync( + ChatHistory chat, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + CancellationToken cancellationToken = default) + { + Verify.NotNull(chat); + + // Convert the incoming execution settings to OpenAI settings. + AzureOpenAIPromptExecutionSettings chatExecutionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); + bool autoInvoke = kernel is not null && chatExecutionSettings.ToolCallBehavior?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; + ValidateMaxTokens(chatExecutionSettings.MaxTokens); + ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); + + // Create the Azure SDK ChatCompletionOptions instance from all available information. + var chatOptions = this.CreateChatCompletionsOptions(chatExecutionSettings, chat, kernel, this.DeploymentOrModelName); + + for (int requestIndex = 1; ; requestIndex++) + { + // Make the request. + ChatCompletions? responseData = null; + List responseContent; + using (var activity = ModelDiagnostics.StartCompletionActivity(this.Endpoint, this.DeploymentOrModelName, ModelProvider, chat, chatExecutionSettings)) + { + try + { + responseData = (await RunRequestAsync(() => this.Client.GetChatCompletionsAsync(chatOptions, cancellationToken)).ConfigureAwait(false)).Value; + this.LogUsage(responseData.Usage); + if (responseData.Choices.Count == 0) + { + throw new KernelException("Chat completions not found"); + } + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + if (responseData != null) + { + // Capture available metadata even if the operation failed. + activity + .SetResponseId(responseData.Id) + .SetPromptTokenUsage(responseData.Usage.PromptTokens) + .SetCompletionTokenUsage(responseData.Usage.CompletionTokens); + } + throw; + } + + responseContent = responseData.Choices.Select(chatChoice => this.GetChatMessage(chatChoice, responseData)).ToList(); + activity?.SetCompletionResponse(responseContent, responseData.Usage.PromptTokens, responseData.Usage.CompletionTokens); + } + + // If we don't want to attempt to invoke any functions, just return the result. + // Or if we are auto-invoking but we somehow end up with other than 1 choice even though only 1 was requested, similarly bail. + if (!autoInvoke || responseData.Choices.Count != 1) + { + return responseContent; + } + + Debug.Assert(kernel is not null); + + // Get our single result and extract the function call information. If this isn't a function call, or if it is + // but we're unable to find the function or extract the relevant information, just return the single result. + // Note that we don't check the FinishReason and instead check whether there are any tool calls, as the service + // may return a FinishReason of "stop" even if there are tool calls to be made, in particular if a required tool + // is specified. + ChatChoice resultChoice = responseData.Choices[0]; + AzureOpenAIChatMessageContent result = this.GetChatMessage(resultChoice, responseData); + if (result.ToolCalls.Count == 0) + { + return [result]; + } + + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Tool requests: {Requests}", result.ToolCalls.Count); + } + if (this.Logger.IsEnabled(LogLevel.Trace)) + { + this.Logger.LogTrace("Function call requests: {Requests}", string.Join(", ", result.ToolCalls.OfType().Select(ftc => $"{ftc.Name}({ftc.Arguments})"))); + } + + // Add the original assistant message to the chatOptions; this is required for the service + // to understand the tool call responses. Also add the result message to the caller's chat + // history: if they don't want it, they can remove it, but this makes the data available, + // including metadata like usage. + chatOptions.Messages.Add(GetRequestMessage(resultChoice.Message)); + chat.Add(result); + + // We must send back a response for every tool call, regardless of whether we successfully executed it or not. + // If we successfully execute it, we'll add the result. If we don't, we'll add an error. + for (int toolCallIndex = 0; toolCallIndex < result.ToolCalls.Count; toolCallIndex++) + { + ChatCompletionsToolCall toolCall = result.ToolCalls[toolCallIndex]; + + // We currently only know about function tool calls. If it's anything else, we'll respond with an error. + if (toolCall is not ChatCompletionsFunctionToolCall functionToolCall) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Tool call was not a function call.", toolCall, this.Logger); + continue; + } + + // Parse the function call arguments. + AzureOpenAIFunctionToolCall? openAIFunctionToolCall; + try + { + openAIFunctionToolCall = new(functionToolCall); + } + catch (JsonException) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Function call arguments were invalid JSON.", toolCall, this.Logger); + continue; + } + + // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, + // then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able + // to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check. + if (chatExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && + !IsRequestableTool(chatOptions, openAIFunctionToolCall)) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Function call request for a function that wasn't defined.", toolCall, this.Logger); + continue; + } + + // Find the function in the kernel and populate the arguments. + if (!kernel!.Plugins.TryGetFunctionAndArguments(openAIFunctionToolCall, out KernelFunction? function, out KernelArguments? functionArgs)) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Requested function could not be found.", toolCall, this.Logger); + continue; + } + + // Now, invoke the function, and add the resulting tool call message to the chat options. + FunctionResult functionResult = new(function) { Culture = kernel.Culture }; + AutoFunctionInvocationContext invocationContext = new(kernel, function, functionResult, chat) + { + Arguments = functionArgs, + RequestSequenceIndex = requestIndex - 1, + FunctionSequenceIndex = toolCallIndex, + FunctionCount = result.ToolCalls.Count + }; + + s_inflightAutoInvokes.Value++; + try + { + invocationContext = await OnAutoFunctionInvocationAsync(kernel, invocationContext, async (context) => + { + // Check if filter requested termination. + if (context.Terminate) + { + return; + } + + // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any + // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, + // as the called function could in turn telling the model about itself as a possible candidate for invocation. + context.Result = await function.InvokeAsync(kernel, invocationContext.Arguments, cancellationToken: cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception e) +#pragma warning restore CA1031 // Do not catch general exception types + { + AddResponseMessage(chatOptions, chat, null, $"Error: Exception while invoking function. {e.Message}", toolCall, this.Logger); + continue; + } + finally + { + s_inflightAutoInvokes.Value--; + } + + // Apply any changes from the auto function invocation filters context to final result. + functionResult = invocationContext.Result; + + object functionResultValue = functionResult.GetValue() ?? string.Empty; + var stringResult = ProcessFunctionResult(functionResultValue, chatExecutionSettings.ToolCallBehavior); + + AddResponseMessage(chatOptions, chat, stringResult, errorMessage: null, functionToolCall, this.Logger); + + // If filter requested termination, returning latest function result. + if (invocationContext.Terminate) + { + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Filter requested termination of automatic function invocation."); + } + + return [chat.Last()]; + } + } + + // Update tool use information for the next go-around based on having completed another iteration. + Debug.Assert(chatExecutionSettings.ToolCallBehavior is not null); + + // Set the tool choice to none. If we end up wanting to use tools, we'll reset it to the desired value. + chatOptions.ToolChoice = ChatCompletionsToolChoice.None; + chatOptions.Tools.Clear(); + + if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts) + { + // Don't add any tools as we've reached the maximum attempts limit. + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the tool.", chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts); + } + } + else + { + // Regenerate the tool list as necessary. The invocation of the function(s) could have augmented + // what functions are available in the kernel. + chatExecutionSettings.ToolCallBehavior.ConfigureOptions(kernel, chatOptions); + } + + // Having already sent tools and with tool call information in history, the service can become unhappy ("[] is too short - 'tools'") + // if we don't send any tools in subsequent requests, even if we say not to use any. + if (chatOptions.ToolChoice == ChatCompletionsToolChoice.None) + { + Debug.Assert(chatOptions.Tools.Count == 0); + chatOptions.Tools.Add(s_nonInvocableFunctionTool); + } + + // Disable auto invocation if we've exceeded the allowed limit. + if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts) + { + autoInvoke = false; + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts); + } + } + } + } + + internal async IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chat, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(chat); + + AzureOpenAIPromptExecutionSettings chatExecutionSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); + + ValidateMaxTokens(chatExecutionSettings.MaxTokens); + + bool autoInvoke = kernel is not null && chatExecutionSettings.ToolCallBehavior?.MaximumAutoInvokeAttempts > 0 && s_inflightAutoInvokes.Value < MaxInflightAutoInvokes; + ValidateAutoInvoke(autoInvoke, chatExecutionSettings.ResultsPerPrompt); + + var chatOptions = this.CreateChatCompletionsOptions(chatExecutionSettings, chat, kernel, this.DeploymentOrModelName); + + StringBuilder? contentBuilder = null; + Dictionary? toolCallIdsByIndex = null; + Dictionary? functionNamesByIndex = null; + Dictionary? functionArgumentBuildersByIndex = null; + + for (int requestIndex = 1; ; requestIndex++) + { + // Reset state + contentBuilder?.Clear(); + toolCallIdsByIndex?.Clear(); + functionNamesByIndex?.Clear(); + functionArgumentBuildersByIndex?.Clear(); + + // Stream the response. + IReadOnlyDictionary? metadata = null; + string? streamedName = null; + ChatRole? streamedRole = default; + CompletionsFinishReason finishReason = default; + ChatCompletionsFunctionToolCall[]? toolCalls = null; + FunctionCallContent[]? functionCallContents = null; + + using (var activity = ModelDiagnostics.StartCompletionActivity(this.Endpoint, this.DeploymentOrModelName, ModelProvider, chat, chatExecutionSettings)) + { + // Make the request. + StreamingResponse response; + try + { + response = await RunRequestAsync(() => this.Client.GetChatCompletionsStreamingAsync(chatOptions, cancellationToken)).ConfigureAwait(false); + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + var responseEnumerator = response.ConfigureAwait(false).GetAsyncEnumerator(); + List? streamedContents = activity is not null ? [] : null; + try + { + while (true) + { + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + } + catch (Exception ex) when (activity is not null) + { + activity.SetError(ex); + throw; + } + + StreamingChatCompletionsUpdate update = responseEnumerator.Current; + metadata = GetResponseMetadata(update); + streamedRole ??= update.Role; + streamedName ??= update.AuthorName; + finishReason = update.FinishReason ?? default; + + // If we're intending to invoke function calls, we need to consume that function call information. + if (autoInvoke) + { + if (update.ContentUpdate is { Length: > 0 } contentUpdate) + { + (contentBuilder ??= new()).Append(contentUpdate); + } + + AzureOpenAIFunctionToolCall.TrackStreamingToolingUpdate(update.ToolCallUpdate, ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex); + } + + var openAIStreamingChatMessageContent = new AzureOpenAIStreamingChatMessageContent(update, update.ChoiceIndex ?? 0, this.DeploymentOrModelName, metadata) { AuthorName = streamedName }; + + if (update.ToolCallUpdate is StreamingFunctionToolCallUpdate functionCallUpdate) + { + openAIStreamingChatMessageContent.Items.Add(new StreamingFunctionCallUpdateContent( + callId: functionCallUpdate.Id, + name: functionCallUpdate.Name, + arguments: functionCallUpdate.ArgumentsUpdate, + functionCallIndex: functionCallUpdate.ToolCallIndex)); + } + + streamedContents?.Add(openAIStreamingChatMessageContent); + yield return openAIStreamingChatMessageContent; + } + + // Translate all entries into ChatCompletionsFunctionToolCall instances. + toolCalls = AzureOpenAIFunctionToolCall.ConvertToolCallUpdatesToChatCompletionsFunctionToolCalls( + ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex); + + // Translate all entries into FunctionCallContent instances for diagnostics purposes. + functionCallContents = this.GetFunctionCallContents(toolCalls).ToArray(); + } + finally + { + activity?.EndStreaming(streamedContents, ModelDiagnostics.IsSensitiveEventsEnabled() ? functionCallContents : null); + await responseEnumerator.DisposeAsync(); + } + } + + // If we don't have a function to invoke, we're done. + // Note that we don't check the FinishReason and instead check whether there are any tool calls, as the service + // may return a FinishReason of "stop" even if there are tool calls to be made, in particular if a required tool + // is specified. + if (!autoInvoke || + toolCallIdsByIndex is not { Count: > 0 }) + { + yield break; + } + + // Get any response content that was streamed. + string content = contentBuilder?.ToString() ?? string.Empty; + + // Log the requests + if (this.Logger.IsEnabled(LogLevel.Trace)) + { + this.Logger.LogTrace("Function call requests: {Requests}", string.Join(", ", toolCalls.Select(fcr => $"{fcr.Name}({fcr.Arguments})"))); + } + else if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Function call requests: {Requests}", toolCalls.Length); + } + + // Add the original assistant message to the chatOptions; this is required for the service + // to understand the tool call responses. + chatOptions.Messages.Add(GetRequestMessage(streamedRole ?? default, content, streamedName, toolCalls)); + chat.Add(this.GetChatMessage(streamedRole ?? default, content, toolCalls, functionCallContents, metadata, streamedName)); + + // Respond to each tooling request. + for (int toolCallIndex = 0; toolCallIndex < toolCalls.Length; toolCallIndex++) + { + ChatCompletionsFunctionToolCall toolCall = toolCalls[toolCallIndex]; + + // We currently only know about function tool calls. If it's anything else, we'll respond with an error. + if (string.IsNullOrEmpty(toolCall.Name)) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Tool call was not a function call.", toolCall, this.Logger); + continue; + } + + // Parse the function call arguments. + AzureOpenAIFunctionToolCall? openAIFunctionToolCall; + try + { + openAIFunctionToolCall = new(toolCall); + } + catch (JsonException) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Function call arguments were invalid JSON.", toolCall, this.Logger); + continue; + } + + // Make sure the requested function is one we requested. If we're permitting any kernel function to be invoked, + // then we don't need to check this, as it'll be handled when we look up the function in the kernel to be able + // to invoke it. If we're permitting only a specific list of functions, though, then we need to explicitly check. + if (chatExecutionSettings.ToolCallBehavior?.AllowAnyRequestedKernelFunction is not true && + !IsRequestableTool(chatOptions, openAIFunctionToolCall)) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Function call request for a function that wasn't defined.", toolCall, this.Logger); + continue; + } + + // Find the function in the kernel and populate the arguments. + if (!kernel!.Plugins.TryGetFunctionAndArguments(openAIFunctionToolCall, out KernelFunction? function, out KernelArguments? functionArgs)) + { + AddResponseMessage(chatOptions, chat, result: null, "Error: Requested function could not be found.", toolCall, this.Logger); + continue; + } + + // Now, invoke the function, and add the resulting tool call message to the chat options. + FunctionResult functionResult = new(function) { Culture = kernel.Culture }; + AutoFunctionInvocationContext invocationContext = new(kernel, function, functionResult, chat) + { + Arguments = functionArgs, + RequestSequenceIndex = requestIndex - 1, + FunctionSequenceIndex = toolCallIndex, + FunctionCount = toolCalls.Length + }; + + s_inflightAutoInvokes.Value++; + try + { + invocationContext = await OnAutoFunctionInvocationAsync(kernel, invocationContext, async (context) => + { + // Check if filter requested termination. + if (context.Terminate) + { + return; + } + + // Note that we explicitly do not use executionSettings here; those pertain to the all-up operation and not necessarily to any + // further calls made as part of this function invocation. In particular, we must not use function calling settings naively here, + // as the called function could in turn telling the model about itself as a possible candidate for invocation. + context.Result = await function.InvokeAsync(kernel, invocationContext.Arguments, cancellationToken: cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception e) +#pragma warning restore CA1031 // Do not catch general exception types + { + AddResponseMessage(chatOptions, chat, result: null, $"Error: Exception while invoking function. {e.Message}", toolCall, this.Logger); + continue; + } + finally + { + s_inflightAutoInvokes.Value--; + } + + // Apply any changes from the auto function invocation filters context to final result. + functionResult = invocationContext.Result; + + object functionResultValue = functionResult.GetValue() ?? string.Empty; + var stringResult = ProcessFunctionResult(functionResultValue, chatExecutionSettings.ToolCallBehavior); + + AddResponseMessage(chatOptions, chat, stringResult, errorMessage: null, toolCall, this.Logger); + + // If filter requested termination, returning latest function result and breaking request iteration loop. + if (invocationContext.Terminate) + { + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Filter requested termination of automatic function invocation."); + } + + var lastChatMessage = chat.Last(); + + yield return new AzureOpenAIStreamingChatMessageContent(lastChatMessage.Role, lastChatMessage.Content); + yield break; + } + } + + // Update tool use information for the next go-around based on having completed another iteration. + Debug.Assert(chatExecutionSettings.ToolCallBehavior is not null); + + // Set the tool choice to none. If we end up wanting to use tools, we'll reset it to the desired value. + chatOptions.ToolChoice = ChatCompletionsToolChoice.None; + chatOptions.Tools.Clear(); + + if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts) + { + // Don't add any tools as we've reached the maximum attempts limit. + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum use ({MaximumUse}) reached; removing the tool.", chatExecutionSettings.ToolCallBehavior!.MaximumUseAttempts); + } + } + else + { + // Regenerate the tool list as necessary. The invocation of the function(s) could have augmented + // what functions are available in the kernel. + chatExecutionSettings.ToolCallBehavior.ConfigureOptions(kernel, chatOptions); + } + + // Having already sent tools and with tool call information in history, the service can become unhappy ("[] is too short - 'tools'") + // if we don't send any tools in subsequent requests, even if we say not to use any. + if (chatOptions.ToolChoice == ChatCompletionsToolChoice.None) + { + Debug.Assert(chatOptions.Tools.Count == 0); + chatOptions.Tools.Add(s_nonInvocableFunctionTool); + } + + // Disable auto invocation if we've exceeded the allowed limit. + if (requestIndex >= chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts) + { + autoInvoke = false; + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug("Maximum auto-invoke ({MaximumAutoInvoke}) reached.", chatExecutionSettings.ToolCallBehavior!.MaximumAutoInvokeAttempts); + } + } + } + } + + /// Checks if a tool call is for a function that was defined. + private static bool IsRequestableTool(ChatCompletionsOptions options, AzureOpenAIFunctionToolCall ftc) + { + IList tools = options.Tools; + for (int i = 0; i < tools.Count; i++) + { + if (tools[i] is ChatCompletionsFunctionToolDefinition def && + string.Equals(def.Name, ftc.FullyQualifiedName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + internal async IAsyncEnumerable GetChatAsTextStreamingContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AzureOpenAIPromptExecutionSettings chatSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); + ChatHistory chat = CreateNewChat(prompt, chatSettings); + + await foreach (var chatUpdate in this.GetStreamingChatMessageContentsAsync(chat, executionSettings, kernel, cancellationToken).ConfigureAwait(false)) + { + yield return new StreamingTextContent(chatUpdate.Content, chatUpdate.ChoiceIndex, chatUpdate.ModelId, chatUpdate, Encoding.UTF8, chatUpdate.Metadata); + } + } + + internal async Task> GetChatAsTextContentsAsync( + string text, + PromptExecutionSettings? executionSettings, + Kernel? kernel, + CancellationToken cancellationToken = default) + { + AzureOpenAIPromptExecutionSettings chatSettings = AzureOpenAIPromptExecutionSettings.FromExecutionSettings(executionSettings); + + ChatHistory chat = CreateNewChat(text, chatSettings); + return (await this.GetChatMessageContentsAsync(chat, chatSettings, kernel, cancellationToken).ConfigureAwait(false)) + .Select(chat => new TextContent(chat.Content, chat.ModelId, chat.Content, Encoding.UTF8, chat.Metadata)) + .ToList(); + } + + internal void AddAttribute(string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + this.Attributes.Add(key, value); + } + } + + /// Gets options to use for an OpenAIClient + /// Custom for HTTP requests. + /// Optional API version. + /// An instance of . + internal static OpenAIClientOptions GetOpenAIClientOptions(HttpClient? httpClient, OpenAIClientOptions.ServiceVersion? serviceVersion = null) + { + OpenAIClientOptions options = serviceVersion is not null ? + new(serviceVersion.Value) : + new(); + + options.Diagnostics.ApplicationId = HttpHeaderConstant.Values.UserAgent; + options.AddPolicy(new AddHeaderRequestPolicy(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientCore))), HttpPipelinePosition.PerCall); + + if (httpClient is not null) + { + options.Transport = new HttpClientTransport(httpClient); + options.RetryPolicy = new RetryPolicy(maxRetries: 0); // Disable Azure SDK retry policy if and only if a custom HttpClient is provided. + options.Retry.NetworkTimeout = Timeout.InfiniteTimeSpan; // Disable Azure SDK default timeout + } + + return options; + } + + /// + /// Create a new empty chat instance + /// + /// Optional chat instructions for the AI service + /// Execution settings + /// Chat object + private static ChatHistory CreateNewChat(string? text = null, AzureOpenAIPromptExecutionSettings? executionSettings = null) + { + var chat = new ChatHistory(); + + // If settings is not provided, create a new chat with the text as the system prompt + AuthorRole textRole = AuthorRole.System; + + if (!string.IsNullOrWhiteSpace(executionSettings?.ChatSystemPrompt)) + { + chat.AddSystemMessage(executionSettings!.ChatSystemPrompt!); + textRole = AuthorRole.User; + } + + if (!string.IsNullOrWhiteSpace(text)) + { + chat.AddMessage(textRole, text!); + } + + return chat; + } + + private static CompletionsOptions CreateCompletionsOptions(string text, AzureOpenAIPromptExecutionSettings executionSettings, string deploymentOrModelName) + { + if (executionSettings.ResultsPerPrompt is < 1 or > MaxResultsPerPrompt) + { + throw new ArgumentOutOfRangeException($"{nameof(executionSettings)}.{nameof(executionSettings.ResultsPerPrompt)}", executionSettings.ResultsPerPrompt, $"The value must be in range between 1 and {MaxResultsPerPrompt}, inclusive."); + } + + var options = new CompletionsOptions + { + Prompts = { text.Replace("\r\n", "\n") }, // normalize line endings + MaxTokens = executionSettings.MaxTokens, + Temperature = (float?)executionSettings.Temperature, + NucleusSamplingFactor = (float?)executionSettings.TopP, + FrequencyPenalty = (float?)executionSettings.FrequencyPenalty, + PresencePenalty = (float?)executionSettings.PresencePenalty, + Echo = false, + ChoicesPerPrompt = executionSettings.ResultsPerPrompt, + GenerationSampleCount = executionSettings.ResultsPerPrompt, + LogProbabilityCount = executionSettings.TopLogprobs, + User = executionSettings.User, + DeploymentName = deploymentOrModelName + }; + + if (executionSettings.TokenSelectionBiases is not null) + { + foreach (var keyValue in executionSettings.TokenSelectionBiases) + { + options.TokenSelectionBiases.Add(keyValue.Key, keyValue.Value); + } + } + + if (executionSettings.StopSequences is { Count: > 0 }) + { + foreach (var s in executionSettings.StopSequences) + { + options.StopSequences.Add(s); + } + } + + return options; + } + + private ChatCompletionsOptions CreateChatCompletionsOptions( + AzureOpenAIPromptExecutionSettings executionSettings, + ChatHistory chatHistory, + Kernel? kernel, + string deploymentOrModelName) + { + if (executionSettings.ResultsPerPrompt is < 1 or > MaxResultsPerPrompt) + { + throw new ArgumentOutOfRangeException($"{nameof(executionSettings)}.{nameof(executionSettings.ResultsPerPrompt)}", executionSettings.ResultsPerPrompt, $"The value must be in range between 1 and {MaxResultsPerPrompt}, inclusive."); + } + + if (this.Logger.IsEnabled(LogLevel.Trace)) + { + this.Logger.LogTrace("ChatHistory: {ChatHistory}, Settings: {Settings}", + JsonSerializer.Serialize(chatHistory), + JsonSerializer.Serialize(executionSettings)); + } + + var options = new ChatCompletionsOptions + { + MaxTokens = executionSettings.MaxTokens, + Temperature = (float?)executionSettings.Temperature, + NucleusSamplingFactor = (float?)executionSettings.TopP, + FrequencyPenalty = (float?)executionSettings.FrequencyPenalty, + PresencePenalty = (float?)executionSettings.PresencePenalty, + ChoiceCount = executionSettings.ResultsPerPrompt, + DeploymentName = deploymentOrModelName, + Seed = executionSettings.Seed, + User = executionSettings.User, + LogProbabilitiesPerToken = executionSettings.TopLogprobs, + EnableLogProbabilities = executionSettings.Logprobs, + AzureExtensionsOptions = executionSettings.AzureChatExtensionsOptions + }; + + switch (executionSettings.ResponseFormat) + { + case ChatCompletionsResponseFormat formatObject: + // If the response format is an Azure SDK ChatCompletionsResponseFormat, just pass it along. + options.ResponseFormat = formatObject; + break; + + case string formatString: + // If the response format is a string, map the ones we know about, and ignore the rest. + switch (formatString) + { + case "json_object": + options.ResponseFormat = ChatCompletionsResponseFormat.JsonObject; + break; + + case "text": + options.ResponseFormat = ChatCompletionsResponseFormat.Text; + break; + } + break; + + case JsonElement formatElement: + // This is a workaround for a type mismatch when deserializing a JSON into an object? type property. + // Handling only string formatElement. + if (formatElement.ValueKind == JsonValueKind.String) + { + string formatString = formatElement.GetString() ?? ""; + switch (formatString) + { + case "json_object": + options.ResponseFormat = ChatCompletionsResponseFormat.JsonObject; + break; + + case "text": + options.ResponseFormat = ChatCompletionsResponseFormat.Text; + break; + } + } + break; + } + + executionSettings.ToolCallBehavior?.ConfigureOptions(kernel, options); + if (executionSettings.TokenSelectionBiases is not null) + { + foreach (var keyValue in executionSettings.TokenSelectionBiases) + { + options.TokenSelectionBiases.Add(keyValue.Key, keyValue.Value); + } + } + + if (executionSettings.StopSequences is { Count: > 0 }) + { + foreach (var s in executionSettings.StopSequences) + { + options.StopSequences.Add(s); + } + } + + if (!string.IsNullOrWhiteSpace(executionSettings.ChatSystemPrompt) && !chatHistory.Any(m => m.Role == AuthorRole.System)) + { + options.Messages.AddRange(GetRequestMessages(new ChatMessageContent(AuthorRole.System, executionSettings!.ChatSystemPrompt), executionSettings.ToolCallBehavior)); + } + + foreach (var message in chatHistory) + { + options.Messages.AddRange(GetRequestMessages(message, executionSettings.ToolCallBehavior)); + } + + return options; + } + + private static ChatRequestMessage GetRequestMessage(ChatRole chatRole, string contents, string? name, ChatCompletionsFunctionToolCall[]? tools) + { + if (chatRole == ChatRole.User) + { + return new ChatRequestUserMessage(contents) { Name = name }; + } + + if (chatRole == ChatRole.System) + { + return new ChatRequestSystemMessage(contents) { Name = name }; + } + + if (chatRole == ChatRole.Assistant) + { + var msg = new ChatRequestAssistantMessage(contents) { Name = name }; + if (tools is not null) + { + foreach (ChatCompletionsFunctionToolCall tool in tools) + { + msg.ToolCalls.Add(tool); + } + } + return msg; + } + + throw new NotImplementedException($"Role {chatRole} is not implemented"); + } + + private static List GetRequestMessages(ChatMessageContent message, AzureToolCallBehavior? toolCallBehavior) + { + if (message.Role == AuthorRole.System) + { + return [new ChatRequestSystemMessage(message.Content) { Name = message.AuthorName }]; + } + + if (message.Role == AuthorRole.Tool) + { + // Handling function results represented by the TextContent type. + // Example: new ChatMessageContent(AuthorRole.Tool, content, metadata: new Dictionary(1) { { OpenAIChatMessageContent.ToolIdProperty, toolCall.Id } }) + if (message.Metadata?.TryGetValue(AzureOpenAIChatMessageContent.ToolIdProperty, out object? toolId) is true && + toolId?.ToString() is string toolIdString) + { + return [new ChatRequestToolMessage(message.Content, toolIdString)]; + } + + // Handling function results represented by the FunctionResultContent type. + // Example: new ChatMessageContent(AuthorRole.Tool, items: new ChatMessageContentItemCollection { new FunctionResultContent(functionCall, result) }) + List? toolMessages = null; + foreach (var item in message.Items) + { + if (item is not FunctionResultContent resultContent) + { + continue; + } + + toolMessages ??= []; + + if (resultContent.Result is Exception ex) + { + toolMessages.Add(new ChatRequestToolMessage($"Error: Exception while invoking function. {ex.Message}", resultContent.CallId)); + continue; + } + + var stringResult = ProcessFunctionResult(resultContent.Result ?? string.Empty, toolCallBehavior); + + toolMessages.Add(new ChatRequestToolMessage(stringResult ?? string.Empty, resultContent.CallId)); + } + + if (toolMessages is not null) + { + return toolMessages; + } + + throw new NotSupportedException("No function result provided in the tool message."); + } + + if (message.Role == AuthorRole.User) + { + if (message.Items is { Count: 1 } && message.Items.FirstOrDefault() is TextContent textContent) + { + return [new ChatRequestUserMessage(textContent.Text) { Name = message.AuthorName }]; + } + + return [new ChatRequestUserMessage(message.Items.Select(static (KernelContent item) => (ChatMessageContentItem)(item switch + { + TextContent textContent => new ChatMessageTextContentItem(textContent.Text), + ImageContent imageContent => GetImageContentItem(imageContent), + _ => throw new NotSupportedException($"Unsupported chat message content type '{item.GetType()}'.") + }))) + { Name = message.AuthorName }]; + } + + if (message.Role == AuthorRole.Assistant) + { + var asstMessage = new ChatRequestAssistantMessage(message.Content) { Name = message.AuthorName }; + + // Handling function calls supplied via either: + // ChatCompletionsToolCall.ToolCalls collection items or + // ChatMessageContent.Metadata collection item with 'ChatResponseMessage.FunctionToolCalls' key. + IEnumerable? tools = (message as AzureOpenAIChatMessageContent)?.ToolCalls; + if (tools is null && message.Metadata?.TryGetValue(AzureOpenAIChatMessageContent.FunctionToolCallsProperty, out object? toolCallsObject) is true) + { + tools = toolCallsObject as IEnumerable; + if (tools is null && toolCallsObject is JsonElement { ValueKind: JsonValueKind.Array } array) + { + int length = array.GetArrayLength(); + var ftcs = new List(length); + for (int i = 0; i < length; i++) + { + JsonElement e = array[i]; + if (e.TryGetProperty("Id", out JsonElement id) && + e.TryGetProperty("Name", out JsonElement name) && + e.TryGetProperty("Arguments", out JsonElement arguments) && + id.ValueKind == JsonValueKind.String && + name.ValueKind == JsonValueKind.String && + arguments.ValueKind == JsonValueKind.String) + { + ftcs.Add(new ChatCompletionsFunctionToolCall(id.GetString()!, name.GetString()!, arguments.GetString()!)); + } + } + tools = ftcs; + } + } + + if (tools is not null) + { + asstMessage.ToolCalls.AddRange(tools); + } + + // Handling function calls supplied via ChatMessageContent.Items collection elements of the FunctionCallContent type. + HashSet? functionCallIds = null; + foreach (var item in message.Items) + { + if (item is not FunctionCallContent callRequest) + { + continue; + } + + functionCallIds ??= new HashSet(asstMessage.ToolCalls.Select(t => t.Id)); + + if (callRequest.Id is null || functionCallIds.Contains(callRequest.Id)) + { + continue; + } + + var argument = JsonSerializer.Serialize(callRequest.Arguments); + + asstMessage.ToolCalls.Add(new ChatCompletionsFunctionToolCall(callRequest.Id, FunctionName.ToFullyQualifiedName(callRequest.FunctionName, callRequest.PluginName, AzureOpenAIFunction.NameSeparator), argument ?? string.Empty)); + } + + return [asstMessage]; + } + + throw new NotSupportedException($"Role {message.Role} is not supported."); + } + + private static ChatMessageImageContentItem GetImageContentItem(ImageContent imageContent) + { + if (imageContent.Data is { IsEmpty: false } data) + { + return new ChatMessageImageContentItem(BinaryData.FromBytes(data), imageContent.MimeType); + } + + if (imageContent.Uri is not null) + { + return new ChatMessageImageContentItem(imageContent.Uri); + } + + throw new ArgumentException($"{nameof(ImageContent)} must have either Data or a Uri."); + } + + private static ChatRequestMessage GetRequestMessage(ChatResponseMessage message) + { + if (message.Role == ChatRole.System) + { + return new ChatRequestSystemMessage(message.Content); + } + + if (message.Role == ChatRole.Assistant) + { + var msg = new ChatRequestAssistantMessage(message.Content); + if (message.ToolCalls is { Count: > 0 } tools) + { + foreach (ChatCompletionsToolCall tool in tools) + { + msg.ToolCalls.Add(tool); + } + } + + return msg; + } + + if (message.Role == ChatRole.User) + { + return new ChatRequestUserMessage(message.Content); + } + + throw new NotSupportedException($"Role {message.Role} is not supported."); + } + + private AzureOpenAIChatMessageContent GetChatMessage(ChatChoice chatChoice, ChatCompletions responseData) + { + var message = new AzureOpenAIChatMessageContent(chatChoice.Message, this.DeploymentOrModelName, GetChatChoiceMetadata(responseData, chatChoice)); + + message.Items.AddRange(this.GetFunctionCallContents(chatChoice.Message.ToolCalls)); + + return message; + } + + private AzureOpenAIChatMessageContent GetChatMessage(ChatRole chatRole, string content, ChatCompletionsFunctionToolCall[] toolCalls, FunctionCallContent[]? functionCalls, IReadOnlyDictionary? metadata, string? authorName) + { + var message = new AzureOpenAIChatMessageContent(chatRole, content, this.DeploymentOrModelName, toolCalls, metadata) + { + AuthorName = authorName, + }; + + if (functionCalls is not null) + { + message.Items.AddRange(functionCalls); + } + + return message; + } + + private IEnumerable GetFunctionCallContents(IEnumerable toolCalls) + { + List? result = null; + + foreach (var toolCall in toolCalls) + { + // Adding items of 'FunctionCallContent' type to the 'Items' collection even though the function calls are available via the 'ToolCalls' property. + // This allows consumers to work with functions in an LLM-agnostic way. + if (toolCall is ChatCompletionsFunctionToolCall functionToolCall) + { + Exception? exception = null; + KernelArguments? arguments = null; + try + { + arguments = JsonSerializer.Deserialize(functionToolCall.Arguments); + if (arguments is not null) + { + // Iterate over copy of the names to avoid mutating the dictionary while enumerating it + var names = arguments.Names.ToArray(); + foreach (var name in names) + { + arguments[name] = arguments[name]?.ToString(); + } + } + } + catch (JsonException ex) + { + exception = new KernelException("Error: Function call arguments were invalid JSON.", ex); + + if (this.Logger.IsEnabled(LogLevel.Debug)) + { + this.Logger.LogDebug(ex, "Failed to deserialize function arguments ({FunctionName}/{FunctionId}).", functionToolCall.Name, functionToolCall.Id); + } + } + + var functionName = FunctionName.Parse(functionToolCall.Name, AzureOpenAIFunction.NameSeparator); + + var functionCallContent = new FunctionCallContent( + functionName: functionName.Name, + pluginName: functionName.PluginName, + id: functionToolCall.Id, + arguments: arguments) + { + InnerContent = functionToolCall, + Exception = exception + }; + + result ??= []; + result.Add(functionCallContent); + } + } + + return result ?? Enumerable.Empty(); + } + + private static void AddResponseMessage(ChatCompletionsOptions chatOptions, ChatHistory chat, string? result, string? errorMessage, ChatCompletionsToolCall toolCall, ILogger logger) + { + // Log any error + if (errorMessage is not null && logger.IsEnabled(LogLevel.Debug)) + { + Debug.Assert(result is null); + logger.LogDebug("Failed to handle tool request ({ToolId}). {Error}", toolCall.Id, errorMessage); + } + + // Add the tool response message to the chat options + result ??= errorMessage ?? string.Empty; + chatOptions.Messages.Add(new ChatRequestToolMessage(result, toolCall.Id)); + + // Add the tool response message to the chat history. + var message = new ChatMessageContent(role: AuthorRole.Tool, content: result, metadata: new Dictionary { { AzureOpenAIChatMessageContent.ToolIdProperty, toolCall.Id } }); + + if (toolCall is ChatCompletionsFunctionToolCall functionCall) + { + // Add an item of type FunctionResultContent to the ChatMessageContent.Items collection in addition to the function result stored as a string in the ChatMessageContent.Content property. + // This will enable migration to the new function calling model and facilitate the deprecation of the current one in the future. + var functionName = FunctionName.Parse(functionCall.Name, AzureOpenAIFunction.NameSeparator); + message.Items.Add(new FunctionResultContent(functionName.Name, functionName.PluginName, functionCall.Id, result)); + } + + chat.Add(message); + } + + private static void ValidateMaxTokens(int? maxTokens) + { + if (maxTokens.HasValue && maxTokens < 1) + { + throw new ArgumentException($"MaxTokens {maxTokens} is not valid, the value must be greater than zero"); + } + } + + private static void ValidateAutoInvoke(bool autoInvoke, int resultsPerPrompt) + { + if (autoInvoke && resultsPerPrompt != 1) + { + // We can remove this restriction in the future if valuable. However, multiple results per prompt is rare, + // and limiting this significantly curtails the complexity of the implementation. + throw new ArgumentException($"Auto-invocation of tool calls may only be used with a {nameof(AzureOpenAIPromptExecutionSettings.ResultsPerPrompt)} of 1."); + } + } + + private static async Task RunRequestAsync(Func> request) + { + try + { + return await request.Invoke().ConfigureAwait(false); + } + catch (RequestFailedException e) + { + throw e.ToHttpOperationException(); + } + } + + /// + /// Captures usage details, including token information. + /// + /// Instance of with usage details. + private void LogUsage(CompletionsUsage usage) + { + if (usage is null) + { + this.Logger.LogDebug("Token usage information unavailable."); + return; + } + + if (this.Logger.IsEnabled(LogLevel.Information)) + { + this.Logger.LogInformation( + "Prompt tokens: {PromptTokens}. Completion tokens: {CompletionTokens}. Total tokens: {TotalTokens}.", + usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens); + } + + s_promptTokensCounter.Add(usage.PromptTokens); + s_completionTokensCounter.Add(usage.CompletionTokens); + s_totalTokensCounter.Add(usage.TotalTokens); + } + + /// + /// Processes the function result. + /// + /// The result of the function call. + /// The ToolCallBehavior object containing optional settings like JsonSerializerOptions.TypeInfoResolver. + /// A string representation of the function result. + private static string? ProcessFunctionResult(object functionResult, AzureToolCallBehavior? toolCallBehavior) + { + if (functionResult is string stringResult) + { + return stringResult; + } + + // This is an optimization to use ChatMessageContent content directly + // without unnecessary serialization of the whole message content class. + if (functionResult is ChatMessageContent chatMessageContent) + { + return chatMessageContent.ToString(); + } + + // For polymorphic serialization of unknown in advance child classes of the KernelContent class, + // a corresponding JsonTypeInfoResolver should be provided via the JsonSerializerOptions.TypeInfoResolver property. + // For more details about the polymorphic serialization, see the article at: + // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-8-0 +#pragma warning disable CS0618 // Type or member is obsolete + return JsonSerializer.Serialize(functionResult, toolCallBehavior?.ToolCallResultSerializerOptions); +#pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Executes auto function invocation filters and/or function itself. + /// This method can be moved to when auto function invocation logic will be extracted to common place. + /// + private static async Task OnAutoFunctionInvocationAsync( + Kernel kernel, + AutoFunctionInvocationContext context, + Func functionCallCallback) + { + await InvokeFilterOrFunctionAsync(kernel.AutoFunctionInvocationFilters, functionCallCallback, context).ConfigureAwait(false); + + return context; + } + + /// + /// This method will execute auto function invocation filters and function recursively. + /// If there are no registered filters, just function will be executed. + /// If there are registered filters, filter on position will be executed. + /// Second parameter of filter is callback. It can be either filter on + 1 position or function if there are no remaining filters to execute. + /// Function will be always executed as last step after all filters. + /// + private static async Task InvokeFilterOrFunctionAsync( + IList? autoFunctionInvocationFilters, + Func functionCallCallback, + AutoFunctionInvocationContext context, + int index = 0) + { + if (autoFunctionInvocationFilters is { Count: > 0 } && index < autoFunctionInvocationFilters.Count) + { + await autoFunctionInvocationFilters[index].OnAutoFunctionInvocationAsync(context, + (context) => InvokeFilterOrFunctionAsync(autoFunctionInvocationFilters, functionCallCallback, context, index + 1)).ConfigureAwait(false); + } + else + { + await functionCallCallback(context).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/RequestFailedExceptionExtensions.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/RequestFailedExceptionExtensions.cs new file mode 100644 index 000000000000..3857d0191fbe --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/RequestFailedExceptionExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using Azure; + +namespace Microsoft.SemanticKernel.Connectors.AzureOpenAI; + +/// +/// Provides extension methods for the class. +/// +internal static class RequestFailedExceptionExtensions +{ + /// + /// Converts a to an . + /// + /// The original . + /// An instance. + public static HttpOperationException ToHttpOperationException(this RequestFailedException exception) + { + const int NoResponseReceived = 0; + + string? responseContent = null; + + try + { + responseContent = exception.GetRawResponse()?.Content?.ToString(); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch { } // We want to suppress any exceptions that occur while reading the content, ensuring that an HttpOperationException is thrown instead. +#pragma warning restore CA1031 + + return new HttpOperationException( + exception.Status == NoResponseReceived ? null : (HttpStatusCode?)exception.Status, + responseContent, + exception.Message, + exception); + } +}