diff --git a/dotnet/src/Agents/OpenAI/Extensions/OpenAIResponseExtensions.cs b/dotnet/src/Agents/OpenAI/Extensions/OpenAIResponseExtensions.cs index 20a5f97bbcec..a2803cd19a62 100644 --- a/dotnet/src/Agents/OpenAI/Extensions/OpenAIResponseExtensions.cs +++ b/dotnet/src/Agents/OpenAI/Extensions/OpenAIResponseExtensions.cs @@ -84,7 +84,10 @@ public static ChatMessageContentItemCollection ToChatMessageContentItemCollectio KernelArguments? arguments = null; try { - arguments = JsonSerializer.Deserialize(functionCallResponseItem.FunctionArguments); + if (!string.IsNullOrWhiteSpace(functionCallResponseItem.FunctionArguments?.ToString())) + { + arguments = JsonSerializer.Deserialize(functionCallResponseItem.FunctionArguments); + } } catch (JsonException ex) { @@ -116,7 +119,10 @@ public static FunctionCallContent ToFunctionCallContent(this FunctionCallRespons KernelArguments? arguments = null; try { - arguments = JsonSerializer.Deserialize(functionCallResponseItem.FunctionArguments); + if (!string.IsNullOrWhiteSpace(functionCallResponseItem.FunctionArguments?.ToString())) + { + arguments = JsonSerializer.Deserialize(functionCallResponseItem.FunctionArguments); + } } catch (JsonException ex) { diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Extensions/OpenAIResponseExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Extensions/OpenAIResponseExtensionsTests.cs index 69ec60ab6995..1a95394e80d7 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/Extensions/OpenAIResponseExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/Extensions/OpenAIResponseExtensionsTests.cs @@ -153,6 +153,41 @@ public void VerifyToFunctionCallContent() Assert.NotNull(content.Arguments); } + /// + /// Verify that a function call without arguments, as some OpenAI compatible servers return for a function without parameters, is not reported as invalid JSON. + /// + [Fact] + public void VerifyToFunctionCallContentWithEmptyArguments() + { + // Arrange + FunctionCallResponseItem responseItem = FunctionCallResponseItem.CreateFunctionCallItem("callId", "functionName", new BinaryData(string.Empty)); + + // Act + FunctionCallContent content = responseItem.ToFunctionCallContent(); + + // Assert + Assert.Null(content.Exception); + Assert.Null(content.Arguments); + } + + /// + /// Verify that a function call without arguments is not reported as invalid JSON when converted to a . + /// + [Fact] + public void VerifyToChatMessageContentItemCollectionWithEmptyFunctionCallArguments() + { + // Arrange + FunctionCallResponseItem responseItem = FunctionCallResponseItem.CreateFunctionCallItem("callId", "functionName", new BinaryData(string.Empty)); + + // Act + ChatMessageContentItemCollection collection = responseItem.ToChatMessageContentItemCollection(); + + // Assert + FunctionCallContent content = Assert.IsType(Assert.Single(collection)); + Assert.Null(content.Exception); + Assert.Null(content.Arguments); + } + /// /// Verify that ReasoningResponseItem with SummaryParts generates ReasoningContent correctly. /// diff --git a/dotnet/src/Connectors/Connectors.MistralAI.UnitTests/MistralAIPluginCollectionExtensionsTests.cs b/dotnet/src/Connectors/Connectors.MistralAI.UnitTests/MistralAIPluginCollectionExtensionsTests.cs new file mode 100644 index 000000000000..ddc79e6ad7f0 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.MistralAI.UnitTests/MistralAIPluginCollectionExtensionsTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.MistralAI.Client; +using Xunit; + +namespace SemanticKernel.Connectors.MistralAI.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class MistralAIPluginCollectionExtensionsTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ItTreatsEmptyFunctionArgumentsAsNoArguments(string arguments) + { + // Arrange + var kernel = new Kernel(); + kernel.ImportPluginFromFunctions("MyPlugin", [KernelFunctionFactory.CreateFromMethod(() => "noon", "GetTime")]); + var toolCallFunction = new MistralFunction("MyPlugin-GetTime", string.Empty, null) { Arguments = arguments }; + + // Act + bool found = kernel.Plugins.TryGetFunctionAndArguments(toolCallFunction, out KernelFunction? function, out KernelArguments? kernelArguments); + + // Assert + Assert.True(found); + Assert.NotNull(function); + Assert.Null(kernelArguments); + } +} diff --git a/dotnet/src/Connectors/Connectors.MistralAI/Client/MistralClient.cs b/dotnet/src/Connectors/Connectors.MistralAI/Client/MistralClient.cs index cd3192c65b8e..0f77ec0c2113 100644 --- a/dotnet/src/Connectors/Connectors.MistralAI/Client/MistralClient.cs +++ b/dotnet/src/Connectors/Connectors.MistralAI/Client/MistralClient.cs @@ -929,7 +929,7 @@ private void AddFunctionCallContent(ChatMessageContent message, MistralToolCall // This allows consumers to work with functions in an LLM-agnostic way. Exception? exception = null; KernelArguments? arguments = null; - if (toolCall.Function.Arguments is not null) + if (!string.IsNullOrWhiteSpace(toolCall.Function.Arguments)) { try { diff --git a/dotnet/src/Connectors/Connectors.MistralAI/Extensions/MistralAIPluginCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.MistralAI/Extensions/MistralAIPluginCollectionExtensions.cs index eba2ed366d38..2787b7c6b71c 100644 --- a/dotnet/src/Connectors/Connectors.MistralAI/Extensions/MistralAIPluginCollectionExtensions.cs +++ b/dotnet/src/Connectors/Connectors.MistralAI/Extensions/MistralAIPluginCollectionExtensions.cs @@ -30,7 +30,7 @@ internal static bool TryGetFunctionAndArguments( { // Add parameters to arguments arguments = null; - if (functionToolCall.Arguments is not null) + if (!string.IsNullOrWhiteSpace(functionToolCall.Arguments)) { // TODO user serializer options from the Kernel var functionArguments = JsonSerializer.Deserialize>(functionToolCall.Arguments); diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionEmptyToolArgumentsTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionEmptyToolArgumentsTests.cs new file mode 100644 index 000000000000..18dec907e082 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionEmptyToolArgumentsTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using Xunit; + +namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; + +/// +/// Tests for tool calls that come back without arguments from OpenAI compatible servers. +/// +public sealed class OpenAIChatCompletionEmptyToolArgumentsTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public OpenAIChatCompletionEmptyToolArgumentsTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task ItDoesNotReportAToolCallWithoutArgumentsAsInvalidJsonAsync(string arguments) + { + // Arrange + string response = $$""" + { + "id": "response-id", + "object": "chat.completion", + "created": 1699896916, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { "id": "1", "type": "function", "function": { "name": "MyPlugin-GetTime", "arguments": "{{arguments}}" } } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + } + """; + using var httpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(response, Encoding.UTF8, "application/json") }; + this._messageHandlerStub.ResponseToReturn = httpResponse; + + var kernel = new Kernel(); + kernel.ImportPluginFromFunctions("MyPlugin", [KernelFunctionFactory.CreateFromMethod(() => "noon", "GetTime")]); + var sut = new OpenAIChatCompletionService(modelId: "gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + + // Act + var result = await sut.GetChatMessageContentAsync( + new ChatHistory("What time is it?"), + new OpenAIPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(autoInvoke: false) }, + kernel); + + // Assert + var functionCall = Assert.Single(result.Items.OfType()); + Assert.Null(functionCall.Exception); + Assert.Null(functionCall.Arguments); + Assert.Equal("GetTime", functionCall.FunctionName); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } +} diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs index 2e9e77cf04eb..9c03b4f7cc7a 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs @@ -1053,7 +1053,11 @@ private List GetFunctionCallContents(IEnumerable(toolCall.FunctionArguments); + if (!string.IsNullOrWhiteSpace(toolCall.FunctionArguments?.ToString())) + { + arguments = JsonSerializer.Deserialize(toolCall.FunctionArguments!); + } + if (arguments is { Count: > 0 } && !retainArgumentTypes) { // Iterate over copy of the names to avoid mutating the dictionary while enumerating it