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