diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 7ff9332fbec0..b58af11e54b2 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -10,7 +10,7 @@ - + @@ -49,7 +49,7 @@ - + diff --git a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_Function.cs b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_Function.cs index 017c2d3c9877..cda940eb82d6 100644 --- a/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_Function.cs +++ b/dotnet/samples/GettingStartedWithAgents/OpenAIAssistant/Step05_AssistantTool_Function.cs @@ -29,9 +29,13 @@ public async Task UseSingleAssistantWithFunctionToolsAsync() { Name = HostName, Instructions = HostInstructions, - Metadata = AssistantSampleMetadata, }; + foreach (var kv in AssistantSampleMetadata) + { + creationOptions.Metadata.Add(kv.Key, kv.Value); + } + // In this sample the function tools are added to the assistant this is // important if you want to retrieve the assistant later and then dynamically check // what function tools it requires. diff --git a/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs b/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs index 9d2b01cda30f..b8a0a3778745 100644 --- a/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs +++ b/dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs @@ -256,7 +256,7 @@ await functionProcessor.InvokeFunctionCallsAsync( int messageCount = 0; foreach (RunStep completedStep in completedStepsToProcess) { - if (completedStep.Type == RunStepType.ToolCalls) + if (completedStep.Kind == RunStepKind.ToolCall) { foreach (RunStepToolCall toolCall in completedStep.Details.ToolCalls) { @@ -264,15 +264,15 @@ await functionProcessor.InvokeFunctionCallsAsync( ChatMessageContent? content = null; // Process code-interpreter content - if (toolCall.ToolKind == RunStepToolCallKind.CodeInterpreter) + if (toolCall.Kind == RunStepToolCallKind.CodeInterpreter) { content = GenerateCodeInterpreterContent(agent.GetName(), toolCall.CodeInterpreterInput, completedStep); isVisible = true; } // Process function result content - else if (toolCall.ToolKind == RunStepToolCallKind.Function) + else if (toolCall.Kind == RunStepToolCallKind.Function) { - FunctionResultContent functionStep = functionSteps[toolCall.ToolCallId]; // Function step always captured on invocation + FunctionResultContent functionStep = functionSteps[toolCall.Id]; // Function step always captured on invocation content = GenerateFunctionResultContent(agent.GetName(), [functionStep], completedStep); } @@ -284,7 +284,7 @@ await functionProcessor.InvokeFunctionCallsAsync( } } } - else if (completedStep.Type == RunStepType.MessageCreation) + else if (completedStep.Kind == RunStepKind.CreatedMessage) { // Retrieve the message ThreadMessage? message = await RetrieveMessageAsync(client, threadId, completedStep.Details.CreatedMessageId, agent.PollingOptions.MessageSynchronizationDelay, cancellationToken).ConfigureAwait(false); @@ -506,7 +506,7 @@ await client.GetRunStepsAsync(run.ThreadId, run.Id, cancellationToken: cancellat { foreach (RunStepToolCall stepDetails in step.Details.ToolCalls) { - toolMap[stepDetails.ToolCallId] = step.Id; + toolMap[stepDetails.Id] = step.Id; } } @@ -564,14 +564,14 @@ await RetrieveMessageAsync( { foreach (RunStepToolCall toolCall in step.Details.ToolCalls) { - if (toolCall.ToolKind == RunStepToolCallKind.Function) + if (toolCall.Kind == RunStepToolCallKind.Function) { messages?.Add(GenerateFunctionResultContent(agent.GetName(), stepFunctionResults[step.Id], step)); stepFunctionResults.Remove(step.Id); break; } - if (toolCall.ToolKind == RunStepToolCallKind.CodeInterpreter) + if (toolCall.Kind == RunStepToolCallKind.CodeInterpreter) { messages?.Add(GenerateCodeInterpreterContent(agent.GetName(), toolCall.CodeInterpreterInput, step)); } @@ -761,13 +761,13 @@ private static ChatMessageContent GenerateCodeInterpreterContent(string agentNam private static IEnumerable ParseFunctionStep(OpenAIAssistantAgent agent, RunStep step) { - if (step.Status == RunStepStatus.InProgress && step.Type == RunStepType.ToolCalls) + if (step.Status == RunStepStatus.InProgress && step.Kind == RunStepKind.ToolCall) { foreach (RunStepToolCall toolCall in step.Details.ToolCalls) { (FunctionName nameParts, KernelArguments functionArguments) = ParseFunctionCall(toolCall.FunctionName, toolCall.FunctionArguments); - FunctionCallContent content = new(nameParts.Name, nameParts.PluginName, toolCall.ToolCallId, functionArguments); + FunctionCallContent content = new(nameParts.Name, nameParts.PluginName, toolCall.Id, functionArguments); yield return content; } diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureClientCoreTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureClientCoreTests.cs new file mode 100644 index 000000000000..c9c47f07ee86 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Core/AzureClientCoreTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using Azure.AI.OpenAI; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Moq; + +namespace SemanticKernel.Connectors.AzureOpenAI.UnitTests.Core; + +public sealed class AzureClientCoreTests : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly Mock _mockLogger; + + public AzureClientCoreTests() + { + this._httpClient = new HttpClient(); + this._mockLogger = new Mock(); + } + + public void Dispose() + { + this._httpClient.Dispose(); + } + + [Fact] + public void ConstructorWithValidParametersShouldInitializeCorrectly() + { + // Arrange + var deploymentName = "test-deployment"; + var endpoint = "https://test-endpoint.openai.azure.com/"; + var apiKey = "test-api-key"; + + // Act + var azureClientCore = new AzureClientCore(deploymentName, endpoint, apiKey, this._httpClient, this._mockLogger.Object); + + // Assert + Assert.NotNull(azureClientCore.Client); + Assert.Equal(deploymentName, azureClientCore.DeploymentName); + Assert.Equal(new Uri(endpoint), azureClientCore.Endpoint); + } + + [Fact] + public void ConstructorWithInvalidEndpointShouldThrowArgumentException() + { + // Arrange + var deploymentName = "test-deployment"; + var invalidEndpoint = "http://invalid-endpoint"; + var apiKey = "test-api-key"; + + // Act & Assert + Assert.Throws(() => + new AzureClientCore(deploymentName, invalidEndpoint, apiKey, this._httpClient, this._mockLogger.Object)); + } + + [Fact] + public void ConstructorWithTokenCredentialShouldInitializeCorrectly() + { + // Arrange + var deploymentName = "test-deployment"; + var endpoint = "https://test-endpoint.openai.azure.com/"; + var tokenCredential = new Mock().Object; + + // Act + var azureClientCore = new AzureClientCore(deploymentName, endpoint, tokenCredential, this._httpClient, this._mockLogger.Object); + + // Assert + Assert.NotNull(azureClientCore.Client); + Assert.Equal(deploymentName, azureClientCore.DeploymentName); + Assert.Equal(new Uri(endpoint), azureClientCore.Endpoint); + } + + [Fact] + public void ConstructorWithOpenAIClientShouldInitializeCorrectly() + { + // Arrange + var deploymentName = "test-deployment"; + var openAIClient = new Mock(MockBehavior.Strict, new Uri("https://test-endpoint.openai.azure.com/"), new Mock().Object).Object; + + // Act + var azureClientCore = new AzureClientCore(deploymentName, openAIClient, this._mockLogger.Object); + + // Assert + Assert.NotNull(azureClientCore.Client); + Assert.Equal(deploymentName, azureClientCore.DeploymentName); + } +} diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionServiceTests.cs index 336d12036db9..844501f729fa 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAIChatCompletionServiceTests.cs @@ -79,6 +79,17 @@ public void ConstructorWithTokenCredentialWorksCorrectly(bool includeLoggerFacto Assert.Equal("model-id", service.Attributes["ModelId"]); } + [Theory] + [InlineData("invalid")] + public void ConstructorThrowsOnInvalidApiVersion(string? apiVersion) + { + // Act & Assert + Assert.Throws(() => + { + _ = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", httpClient: this._httpClient, apiVersion: apiVersion); + }); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -122,8 +133,10 @@ public async Task GetTextContentsWorksCorrectlyAsync() Assert.Equal(155, usage.TotalTokenCount); } - [Fact] - public async Task GetChatMessageContentsHandlesSettingsCorrectlyAsync() + [Theory] + [InlineData("system")] + [InlineData("developer")] + public async Task GetChatMessageContentsHandlesSettingsCorrectlyAsync(string historyRole) { // Arrange var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); @@ -152,7 +165,14 @@ public async Task GetChatMessageContentsHandlesSettingsCorrectlyAsync() var chatHistory = new ChatHistory(); chatHistory.AddUserMessage("User Message"); chatHistory.AddUserMessage([new ImageContent(new Uri("https://image")), new TextContent("User Message")]); - chatHistory.AddSystemMessage("System Message"); + if (historyRole == "system") + { + chatHistory.AddSystemMessage("System Message"); + } + else + { + chatHistory.AddDeveloperMessage("Developer Message"); + } chatHistory.AddAssistantMessage("Assistant Message"); using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) @@ -189,8 +209,16 @@ public async Task GetChatMessageContentsHandlesSettingsCorrectlyAsync() 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()); + if (historyRole == "system") + { + Assert.Equal("system", systemMessage.GetProperty("role").GetString()); + Assert.Equal("System Message", systemMessage.GetProperty("content").GetString()); + } + else + { + Assert.Equal("developer", systemMessage.GetProperty("role").GetString()); + Assert.Equal("Developer Message", systemMessage.GetProperty("content").GetString()); + } Assert.Equal("assistant", assistantMessage.GetProperty("role").GetString()); Assert.Equal("Assistant Message", assistantMessage.GetProperty("content").GetString()); @@ -245,6 +273,66 @@ public async Task GetChatMessageContentsHandlesResponseFormatCorrectlyAsync(obje Assert.Equal(expectedResponseType, content.GetProperty("response_format").GetProperty("type").GetString()); } + [Theory] + [InlineData(null, null)] + [InlineData("string", "low")] + [InlineData("string", "medium")] + [InlineData("string", "high")] + [InlineData("ChatReasonEffortLevel.Low", "low")] + [InlineData("ChatReasonEffortLevel.Medium", "medium")] + [InlineData("ChatReasonEffortLevel.High", "high")] + public async Task GetChatMessageInReasoningEffortAsync(string? effortType, string? expectedEffortLevel) + { + // Assert + object? reasoningEffortObject = null; + switch (effortType) + { + case "string": + reasoningEffortObject = expectedEffortLevel; + break; + case "ChatReasonEffortLevel.Low": + reasoningEffortObject = ChatReasoningEffortLevel.Low; + break; + case "ChatReasonEffortLevel.Medium": + reasoningEffortObject = ChatReasoningEffortLevel.Medium; + break; + case "ChatReasonEffortLevel.High": + reasoningEffortObject = ChatReasoningEffortLevel.High; + break; + } + + var modelId = "o1"; + var sut = new OpenAIChatCompletionService(modelId, "apiKey", httpClient: this._httpClient); + OpenAIPromptExecutionSettings executionSettings = new() { ReasoningEffort = reasoningEffortObject }; + using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("TestData/chat_completion_test_response.json")) + }; + + this._messageHandlerStub.ResponsesToReturn.Add(responseMessage); + + // Act + var result = await sut.GetChatMessageContentAsync(new ChatHistory("System message"), executionSettings); + + // Assert + Assert.NotNull(result); + + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + + if (expectedEffortLevel is null) + { + Assert.False(optionsJson.TryGetProperty("reasoning_effort", out _)); + return; + } + + var requestedReasoningEffort = optionsJson.GetProperty("reasoning_effort").GetString(); + + Assert.Equal(expectedEffortLevel, requestedReasoningEffort); + } + [Theory] [MemberData(nameof(ToolCallBehaviors))] public async Task GetChatMessageContentsWorksCorrectlyAsync(ToolCallBehavior behavior) @@ -806,6 +894,49 @@ public async Task GetChatMessageContentsUsesPromptAndSettingsCorrectlyAsync() Assert.Equal("user", messages[1].GetProperty("role").GetString()); } + [Fact] + public async Task GetChatMessageContentsUsesDeveloperPromptAndSettingsCorrectlyAsync() + { + // Arrange + const string Prompt = "This is test prompt"; + const string DeveloperMessage = "This is test system message"; + + var service = new AzureOpenAIChatCompletionService("deployment", "https://endpoint", "api-key", "model-id", this._httpClient); + var settings = new AzureOpenAIPromptExecutionSettings() { ChatDeveloperPrompt = DeveloperMessage }; + + using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(AzureOpenAITestHelper.GetTestResponse("chat_completion_test_response.json")) + }; + this._messageHandlerStub.ResponsesToReturn.Add(responseMessage); + + 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(DeveloperMessage, messages[0].GetProperty("content").GetString()); + Assert.Equal("developer", 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() { @@ -1537,6 +1668,14 @@ public async Task GetStreamingChatMessageContentsWithFunctionCallAndEmptyArgumen public static TheoryData Versions => new() { + { "V2025_01_01_preview", "2025-01-01-preview" }, + { "V2025_01_01_PREVIEW", "2025-01-01-preview" }, + { "2025_01_01_Preview", "2025-01-01-preview" }, + { "2025-01-01-preview", "2025-01-01-preview" }, + { "V2024_12_01_preview", "2024-12-01-preview" }, + { "V2024_12_01_PREVIEW", "2024-12-01-preview" }, + { "2024_12_01_Preview", "2024-12-01-preview" }, + { "2024-12-01-preview", "2024-12-01-preview" }, { "V2024_10_01_preview", "2024-10-01-preview" }, { "V2024_10_01_PREVIEW", "2024-10-01-preview" }, { "2024_10_01_Preview", "2024-10-01-preview" }, @@ -1552,10 +1691,16 @@ public async Task GetStreamingChatMessageContentsWithFunctionCallAndEmptyArgumen { "V2024_06_01", "2024-06-01" }, { "2024_06_01", "2024-06-01" }, { "2024-06-01", "2024-06-01" }, + { "V2024_10_21", "2024-10-21" }, + { "2024_10_21", "2024-10-21" }, + { "2024-10-21", "2024-10-21" }, + { AzureOpenAIClientOptions.ServiceVersion.V2025_01_01_Preview.ToString(), null }, + { AzureOpenAIClientOptions.ServiceVersion.V2024_12_01_Preview.ToString(), null }, { AzureOpenAIClientOptions.ServiceVersion.V2024_10_01_Preview.ToString(), null }, { AzureOpenAIClientOptions.ServiceVersion.V2024_09_01_Preview.ToString(), null }, { AzureOpenAIClientOptions.ServiceVersion.V2024_08_01_Preview.ToString(), null }, - { AzureOpenAIClientOptions.ServiceVersion.V2024_06_01.ToString(), null } + { AzureOpenAIClientOptions.ServiceVersion.V2024_06_01.ToString(), null }, + { AzureOpenAIClientOptions.ServiceVersion.V2024_10_21.ToString(), null } }; public void Dispose() diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextToAudioServiceTests.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextToAudioServiceTests.cs index 3ad42a32eac6..27e2b3ebc14d 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextToAudioServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI.UnitTests/Services/AzureOpenAITextToAudioServiceTests.cs @@ -47,6 +47,23 @@ public void ConstructorsAddRequiredMetadata(bool includeLoggerFactory) Assert.Equal("deployment-name", service.Attributes["DeploymentName"]); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConstructorTokenCredentialAddRequiredMetadata(bool includeLoggerFactory) + { + // Arrange & Act + var service = includeLoggerFactory ? + new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", Azure.Core.DelegatedTokenCredential.Create((context, ct) + => new Azure.Core.AccessToken("abc", DateTimeOffset.Now.AddMinutes(30))), "model-id", loggerFactory: this._mockLoggerFactory.Object) : + new AzureOpenAITextToAudioService("deployment-name", "https://endpoint", Azure.Core.DelegatedTokenCredential.Create((context, ct) + => new Azure.Core.AccessToken("abc", DateTimeOffset.Now.AddMinutes(30))), "model-id"); + + // Assert + Assert.Equal("model-id", service.Attributes["ModelId"]); + Assert.Equal("deployment-name", service.Attributes["DeploymentName"]); + } + [Fact] public void ItThrowsIfModelIdIsNotProvided() { diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs index bf7859815f1d..687addf37087 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.ChatCompletion.cs @@ -50,6 +50,7 @@ protected override ChatCompletionOptions CreateChatCompletionOptions( TopLogProbabilityCount = executionSettings.TopLogprobs, IncludeLogProbabilities = executionSettings.Logprobs, StoredOutputEnabled = executionSettings.Store, + ReasoningEffortLevel = GetEffortLevel(executionSettings), }; var responseFormat = GetResponseFormat(executionSettings); diff --git a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs index 5ad45701a921..a3dbbe730057 100644 --- a/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs +++ b/dotnet/src/Connectors/Connectors.AzureOpenAI/Core/AzureClientCore.cs @@ -135,9 +135,12 @@ internal static AzureOpenAIClientOptions GetAzureOpenAIClientOptions(HttpClient? sdkVersion = serviceVersion.ToUpperInvariant() switch // Azure SDK versioning { "2024-06-01" or "V2024_06_01" or "2024_06_01" => AzureOpenAIClientOptions.ServiceVersion.V2024_06_01, + "2024-10-21" or "V2024_10_21" or "2024_10_21" => AzureOpenAIClientOptions.ServiceVersion.V2024_10_21, "2024-08-01-PREVIEW" or "V2024_08_01_PREVIEW" or "2024_08_01_PREVIEW" => AzureOpenAIClientOptions.ServiceVersion.V2024_08_01_Preview, "2024-09-01-PREVIEW" or "V2024_09_01_PREVIEW" or "2024_09_01_PREVIEW" => AzureOpenAIClientOptions.ServiceVersion.V2024_09_01_Preview, "2024-10-01-PREVIEW" or "V2024_10_01_PREVIEW" or "2024_10_01_PREVIEW" => AzureOpenAIClientOptions.ServiceVersion.V2024_10_01_Preview, + "2024-12-01-PREVIEW" or "V2024_12_01_PREVIEW" or "2024_12_01_PREVIEW" => AzureOpenAIClientOptions.ServiceVersion.V2024_12_01_Preview, + "2025-01-01-PREVIEW" or "V2025_01_01_PREVIEW" or "2025_01_01_PREVIEW" => AzureOpenAIClientOptions.ServiceVersion.V2025_01_01_Preview, _ => throw new NotSupportedException($"The service version '{serviceVersion}' is not supported.") }; diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs index 74360e542358..d6b83f21a391 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionServiceTests.cs @@ -589,6 +589,48 @@ public async Task GetChatMessageContentsUsesPromptAndSettingsCorrectlyAsync() Assert.Equal("user", messages[1].GetProperty("role").GetString()); } + [Fact] + public async Task GetChatMessageContentsUsesDeveloperPromptAndSettingsCorrectlyAsync() + { + // Arrange + const string Prompt = "This is test prompt"; + const string DeveloperMessage = "This is test system message"; + + var service = new OpenAIChatCompletionService("model-id", "api-key", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings() { ChatDeveloperPrompt = DeveloperMessage }; + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("TestData/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.RequestContent; + + Assert.NotNull(requestContentByteArray); + + var requestContent = JsonSerializer.Deserialize(Encoding.UTF8.GetString(requestContentByteArray)); + + var messages = requestContent.GetProperty("messages"); + + Assert.Equal(2, messages.GetArrayLength()); + + Assert.Equal(DeveloperMessage, messages[0].GetProperty("content").GetString()); + Assert.Equal("developer", 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() { @@ -962,6 +1004,65 @@ public async Task GetChatMessageInResponseFormatsAsync(string formatType, string Assert.NotNull(result); } + [Theory] + [InlineData(null, null)] + [InlineData("string", "low")] + [InlineData("string", "medium")] + [InlineData("string", "high")] + [InlineData("ChatReasonEffortLevel.Low", "low")] + [InlineData("ChatReasonEffortLevel.Medium", "medium")] + [InlineData("ChatReasonEffortLevel.High", "high")] + public async Task GetChatMessageInReasoningEffortAsync(string? effortType, string? expectedEffortLevel) + { + // Assert + object? reasoningEffortObject = null; + switch (effortType) + { + case "string": + reasoningEffortObject = expectedEffortLevel; + break; + case "ChatReasonEffortLevel.Low": + reasoningEffortObject = ChatReasoningEffortLevel.Low; + break; + case "ChatReasonEffortLevel.Medium": + reasoningEffortObject = ChatReasoningEffortLevel.Medium; + break; + case "ChatReasonEffortLevel.High": + reasoningEffortObject = ChatReasoningEffortLevel.High; + break; + } + + var modelId = "o1"; + var sut = new OpenAIChatCompletionService(modelId, "apiKey", httpClient: this._httpClient); + OpenAIPromptExecutionSettings executionSettings = new() { ReasoningEffort = reasoningEffortObject }; + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText("TestData/chat_completion_test_response.json")) + }; + + // Act + var result = await sut.GetChatMessageContentAsync(this._chatHistoryForTest, executionSettings); + + // Assert + Assert.NotNull(result); + + var actualRequestContent = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + Assert.NotNull(actualRequestContent); + + var optionsJson = JsonSerializer.Deserialize(actualRequestContent); + + if (expectedEffortLevel is null) + { + Assert.False(optionsJson.TryGetProperty("reasoning_effort", out _)); + return; + } + + var requestedReasoningEffort = optionsJson.GetProperty("reasoning_effort").GetString(); + + Assert.Equal(expectedEffortLevel, requestedReasoningEffort); + } + [Fact(Skip = "Not working running in the console")] public async Task GetInvalidResponseThrowsExceptionAndIsCapturedByDiagnosticsAsync() { diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Settings/OpenAIPromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Settings/OpenAIPromptExecutionSettingsTests.cs index 90272b94717c..dda1af38a596 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Settings/OpenAIPromptExecutionSettingsTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Settings/OpenAIPromptExecutionSettingsTests.cs @@ -34,6 +34,10 @@ public void ItCreatesOpenAIExecutionSettingsWithCorrectDefaults() Assert.Equal(128, executionSettings.MaxTokens); Assert.Null(executionSettings.Store); Assert.Null(executionSettings.Metadata); + Assert.Null(executionSettings.Seed); + Assert.Null(executionSettings.ReasoningEffort); + Assert.Null(executionSettings.ChatSystemPrompt); + Assert.Null(executionSettings.ChatDeveloperPrompt); } [Fact] @@ -48,13 +52,15 @@ public void ItUsesExistingOpenAIExecutionSettings() PresencePenalty = 0.7, StopSequences = ["foo", "bar"], ChatSystemPrompt = "chat system prompt", + ChatDeveloperPrompt = "chat developer prompt", MaxTokens = 128, Logprobs = true, TopLogprobs = 5, TokenSelectionBiases = new Dictionary() { { 1, 2 }, { 3, 4 } }, Seed = 123456, Store = true, - Metadata = new Dictionary() { { "foo", "bar" } } + Metadata = new Dictionary() { { "foo", "bar" } }, + ReasoningEffort = "high" }; // Act @@ -70,6 +76,9 @@ public void ItUsesExistingOpenAIExecutionSettings() Assert.Equal(actualSettings.Seed, executionSettings.Seed); Assert.Equal(actualSettings.Store, executionSettings.Store); Assert.Equal(actualSettings.Metadata, executionSettings.Metadata); + Assert.Equal(actualSettings.ReasoningEffort, executionSettings.ReasoningEffort); + Assert.Equal(actualSettings.ChatSystemPrompt, executionSettings.ChatSystemPrompt); + Assert.Equal(actualSettings.ChatDeveloperPrompt, executionSettings.ChatDeveloperPrompt); } [Fact] @@ -112,6 +121,8 @@ public void ItCreatesOpenAIExecutionSettingsFromExtraPropertiesSnakeCase() { "results_per_prompt", 2 }, { "stop_sequences", new [] { "foo", "bar" } }, { "chat_system_prompt", "chat system prompt" }, + { "chat_developer_prompt", "chat developer prompt" }, + { "reasoning_effort", "high" }, { "max_tokens", 128 }, { "token_selection_biases", new Dictionary() { { 1, 2 }, { 3, 4 } } }, { "seed", 123456 }, @@ -144,6 +155,8 @@ public void ItCreatesOpenAIExecutionSettingsFromExtraPropertiesAsStrings() { "results_per_prompt", "2" }, { "stop_sequences", new [] { "foo", "bar" } }, { "chat_system_prompt", "chat system prompt" }, + { "chat_developer_prompt", "chat developer prompt" }, + { "reasoning_effort", "high" }, { "max_tokens", "128" }, { "token_selection_biases", new Dictionary() { { "1", "2" }, { "3", "4" } } }, { "seed", 123456 }, @@ -174,6 +187,8 @@ public void ItCreatesOpenAIExecutionSettingsFromJsonSnakeCase() "results_per_prompt": 2, "stop_sequences": [ "foo", "bar" ], "chat_system_prompt": "chat system prompt", + "chat_developer_prompt": "chat developer prompt", + "reasoning_effort": "high", "token_selection_biases": { "1": 2, "3": 4 }, "max_tokens": 128, "seed": 123456, @@ -311,6 +326,8 @@ private static void AssertExecutionSettings(OpenAIPromptExecutionSettings execut Assert.Equal(0.7, executionSettings.PresencePenalty); Assert.Equal(new string[] { "foo", "bar" }, executionSettings.StopSequences); Assert.Equal("chat system prompt", executionSettings.ChatSystemPrompt); + Assert.Equal("chat developer prompt", executionSettings.ChatDeveloperPrompt); + Assert.Equal("high", executionSettings.ReasoningEffort!.ToString()); Assert.Equal(new Dictionary() { { 1, 2 }, { 3, 4 } }, executionSettings.TokenSelectionBiases); Assert.Equal(128, executionSettings.MaxTokens); Assert.Equal(123456, executionSettings.Seed); diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 129e7913b788..683e44bfe32b 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -469,6 +469,7 @@ protected virtual ChatCompletionOptions CreateChatCompletionOptions( TopLogProbabilityCount = executionSettings.TopLogprobs, IncludeLogProbabilities = executionSettings.Logprobs, StoredOutputEnabled = executionSettings.Store, + ReasoningEffortLevel = GetEffortLevel(executionSettings), }; var responseFormat = GetResponseFormat(executionSettings); @@ -519,6 +520,33 @@ protected virtual ChatCompletionOptions CreateChatCompletionOptions( return options; } + protected static ChatReasoningEffortLevel? GetEffortLevel(OpenAIPromptExecutionSettings executionSettings) + { + var effortLevelObject = executionSettings.ReasoningEffort; + if (effortLevelObject is null) + { + return null; + } + + if (effortLevelObject is ChatReasoningEffortLevel effort) + { + return effort; + } + + if (effortLevelObject is string textEffortLevel) + { + return textEffortLevel.ToUpperInvariant() switch + { + "LOW" => ChatReasoningEffortLevel.Low, + "MEDIUM" => ChatReasoningEffortLevel.Medium, + "HIGH" => ChatReasoningEffortLevel.High, + _ => throw new NotSupportedException($"The provided reasoning effort '{textEffortLevel}' is not supported.") + }; + } + + throw new NotSupportedException($"The provided reasoning effort '{effortLevelObject.GetType()}' is not supported."); + } + /// /// Retrieves the response format based on the provided settings. /// @@ -589,13 +617,14 @@ private static bool IsRequestableTool(IList tools, FunctionCallContent /// /// Optional chat instructions for the AI service /// Execution settings + /// Indicates what will be the role of the text. Defaults to system role prompt /// Chat object - private static ChatHistory CreateNewChat(string? text = null, OpenAIPromptExecutionSettings? executionSettings = null) + private static ChatHistory CreateNewChat(string? text = null, OpenAIPromptExecutionSettings? executionSettings = null, AuthorRole? textRole = 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; + textRole ??= AuthorRole.System; if (!string.IsNullOrWhiteSpace(executionSettings?.ChatSystemPrompt)) { @@ -603,9 +632,15 @@ private static ChatHistory CreateNewChat(string? text = null, OpenAIPromptExecut textRole = AuthorRole.User; } + if (!string.IsNullOrWhiteSpace(executionSettings?.ChatDeveloperPrompt)) + { + chat.AddDeveloperMessage(executionSettings!.ChatDeveloperPrompt!); + textRole = AuthorRole.User; + } + if (!string.IsNullOrWhiteSpace(text)) { - chat.AddMessage(textRole, text!); + chat.AddMessage(textRole.Value, text!); } return chat; @@ -615,6 +650,11 @@ private static List CreateChatCompletionMessages(OpenAIPromptExecut { List messages = []; + if (!string.IsNullOrWhiteSpace(executionSettings.ChatDeveloperPrompt) && !chatHistory.Any(m => m.Role == AuthorRole.Developer)) + { + messages.Add(new DeveloperChatMessage(executionSettings.ChatDeveloperPrompt)); + } + if (!string.IsNullOrWhiteSpace(executionSettings.ChatSystemPrompt) && !chatHistory.Any(m => m.Role == AuthorRole.System)) { messages.Add(new SystemChatMessage(executionSettings.ChatSystemPrompt)); @@ -630,6 +670,11 @@ private static List CreateChatCompletionMessages(OpenAIPromptExecut private static List CreateRequestMessages(ChatMessageContent message) { + if (message.Role == AuthorRole.Developer) + { + return [new DeveloperChatMessage(message.Content) { ParticipantName = message.AuthorName }]; + } + if (message.Role == AuthorRole.System) { return [new SystemChatMessage(message.Content) { ParticipantName = message.AuthorName }]; diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index add62d564046..bd3187b936d6 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -18,6 +18,29 @@ namespace Microsoft.SemanticKernel.Connectors.OpenAI; [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public class OpenAIPromptExecutionSettings : PromptExecutionSettings { + /// + /// Gets or sets an object specifying the effort level for the model to use when generating the completion. + /// + /// + /// Constrains effort on reasoning for reasoning models. + /// Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response. + /// Possible values are: + /// - values: "low", "medium", "high"; + /// - object; + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("reasoning_effort")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? ReasoningEffort + { + get => this._reasoningEffort; + set + { + this.ThrowIfFrozen(); + this._reasoningEffort = value; + } + } + /// /// Temperature controls the randomness of the completion. /// The higher the temperature, the more random the completion. @@ -183,6 +206,24 @@ public string? ChatSystemPrompt } } + /// + /// The system prompt to use when generating text using a chat model. + /// Defaults to "Assistant is a large language model." + /// + [Experimental("SKEXP0010")] + [JsonPropertyName("chat_developer_prompt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ChatDeveloperPrompt + { + get => this._chatDeveloperPrompt; + + set + { + this.ThrowIfFrozen(); + this._chatDeveloperPrompt = value; + } + } + /// /// Modify the likelihood of specified tokens appearing in the completion. /// @@ -410,15 +451,18 @@ public static OpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutio FunctionChoiceBehavior = this.FunctionChoiceBehavior, User = this.User, ChatSystemPrompt = this.ChatSystemPrompt, + ChatDeveloperPrompt = this.ChatDeveloperPrompt, Logprobs = this.Logprobs, TopLogprobs = this.TopLogprobs, Store = this.Store, Metadata = this.Metadata is not null ? new Dictionary(this.Metadata) : null, + ReasoningEffort = this.ReasoningEffort }; } #region private ================================================================================ + private object? _reasoningEffort; private double? _temperature; private double? _topP; private double? _presencePenalty; @@ -431,6 +475,7 @@ public static OpenAIPromptExecutionSettings FromExecutionSettings(PromptExecutio private ToolCallBehavior? _toolCallBehavior; private string? _user; private string? _chatSystemPrompt; + private string? _chatDeveloperPrompt; private bool? _logprobs; private int? _topLogprobs; private bool? _store; diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/AuthorRole.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/AuthorRole.cs index 05f473b1b792..d4dd082dd98b 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/AuthorRole.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/AuthorRole.cs @@ -11,6 +11,11 @@ namespace Microsoft.SemanticKernel.ChatCompletion; /// public readonly struct AuthorRole : IEquatable { + /// + /// The role that instructs or sets the behavior of the assistant. + /// + public static AuthorRole Developer { get; } = new("developer"); + /// /// The role that instructs or sets the behavior of the assistant. /// diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs index fda7be0d0c8c..22968c47ea38 100644 --- a/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs +++ b/dotnet/src/SemanticKernel.Abstractions/AI/ChatCompletion/ChatHistory.cs @@ -28,15 +28,26 @@ public ChatHistory() } /// - /// Creates a new instance of the class with a system message + /// Creates a new instance of the with a first message in the provided . + /// If not role is provided then the first message will default to role. /// - /// The system message to add to the history. - public ChatHistory(string systemMessage) + /// The text message to add to the first message in chat history. + /// The role to add as the first message. + public ChatHistory(string message, AuthorRole role) { - Verify.NotNullOrWhiteSpace(systemMessage); + Verify.NotNullOrWhiteSpace(message); this._messages = []; - this.AddSystemMessage(systemMessage); + this.Add(new ChatMessageContent(role, message)); + } + + /// + /// Creates a new instance of the class with a system message. + /// + /// The system message to add to the history. + public ChatHistory(string systemMessage) + : this(systemMessage, AuthorRole.System) + { } /// Initializes the history will all of the specified messages. @@ -97,6 +108,13 @@ public void AddAssistantMessage(string content) => public void AddSystemMessage(string content) => this.AddMessage(AuthorRole.System, content); + /// + /// Add a developer message to the chat history + /// + /// Message content + public void AddDeveloperMessage(string content) => + this.AddMessage(AuthorRole.Developer, content); + /// Adds a message to the history. /// The message to add. /// is null. diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/ChatHistoryTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/ChatHistoryTests.cs index 723349450e99..20cc5b1269fd 100644 --- a/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/ChatHistoryTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ChatCompletion/ChatHistoryTests.cs @@ -43,4 +43,35 @@ public void ItCanBeSerializedAndDeserialized() chatHistoryDeserialized[i].Items.OfType().Single().Text); } } + + [Theory] + [InlineData("system")] + [InlineData("developer")] + public void CtorWorksForSystemAndDeveloper(string providedRole) + { + // Arrange + var targetRole = providedRole == "system" ? AuthorRole.System : AuthorRole.Developer; + var options = new JsonSerializerOptions(); + var chatHistory = new ChatHistory("First message", targetRole); + + var chatHistoryJson = JsonSerializer.Serialize(chatHistory, options); + + // Act + var chatHistoryDeserialized = JsonSerializer.Deserialize(chatHistoryJson, options); + + // Assert + Assert.NotNull(chatHistoryDeserialized); + Assert.Equal(chatHistory.Count, chatHistoryDeserialized.Count); + Assert.Equal(providedRole, chatHistoryDeserialized[0].Role.Label); + for (var i = 0; i < chatHistory.Count; i++) + { + Assert.Equal(chatHistory[i].Role.Label, chatHistoryDeserialized[i].Role.Label); + Assert.Equal(chatHistory[i].Content, chatHistoryDeserialized[i].Content); + Assert.Equal(chatHistory[i].AuthorName, chatHistoryDeserialized[i].AuthorName); + Assert.Equal(chatHistory[i].Items.Count, chatHistoryDeserialized[i].Items.Count); + Assert.Equal( + chatHistory[i].Items.OfType().Single().Text, + chatHistoryDeserialized[i].Items.OfType().Single().Text); + } + } }