From 393ea225408d08c0b4e510a49551977819cc4825 Mon Sep 17 00:00:00 2001 From: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:31:51 +0200 Subject: [PATCH 1/2] Fix duplicate tools from OpenAI extra body --- .../OpenAIChatCompletionExtraBodyTests.cs | 30 ++++++++++ .../Settings/OpenAIPromptExecutionSettings.cs | 56 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 95f0b409ed0b..07bf0060a69e 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -117,6 +117,36 @@ public async Task ExtraBodyOverridesFirstClassPropertyAsync() Assert.Equal(0.0, body.GetProperty("temperature").GetDouble()); } + [Theory] + [InlineData("tools")] + [InlineData("$.tools")] + public async Task ExtraBodyToolsDoesNotDuplicatePropertyAsync(string key) + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + [key] = new object[] + { + new { type = "web_search" }, + new { type = "function", function = new { name = "lookup" } }, + }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools")); + Assert.Equal("web_search", tools.Value[0].GetProperty("type").GetString()); + Assert.Equal("function", tools.Value[1].GetProperty("type").GetString()); + Assert.Equal("lookup", tools.Value[1].GetProperty("function").GetProperty("name").GetString()); + } + [Fact] public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync() { diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index 402e33f95175..095d01d5a96f 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -650,6 +650,11 @@ protected override void PrepareChatOptionsForRequest(Microsoft.Extensions.AI.Cha /// internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string key, object? value) { + if (TryApplyExtraBodyTools(options, key, value)) + { + return; + } + string path = key.StartsWith("$.", StringComparison.Ordinal) ? key : $"$[{JsonSerializer.Serialize(key)}]"; @@ -669,6 +674,57 @@ internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string k #pragma warning restore SCME0001 } + private static bool TryApplyExtraBodyTools(ChatCompletionOptions options, string key, object? value) + { + if (value is null || (key != "tools" && key != "$.tools")) + { + return false; + } + + JsonElement tools = JsonSerializer.SerializeToElement(value, value.GetType()); + if (tools.ValueKind != JsonValueKind.Array || tools.EnumerateArray().Any(tool => tool.ValueKind != JsonValueKind.Object)) + { + return false; + } + + var parsedTools = new List(); + foreach (JsonElement toolElement in tools.EnumerateArray()) + { + // ChatTool currently models function tools only. Start with one so the SDK owns serialization of the + // surrounding tools array, then replace its fields through JsonPatch to support newer tool types. + ChatTool tool = ChatTool.CreateFunctionTool("placeholder"); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.function")); + foreach (JsonProperty property in toolElement.EnumerateObject()) + { + string path = property.Name is "type" or "function" + ? $"$.{property.Name}" + : $"$[{JsonSerializer.Serialize(property.Name)}]"; + byte[] propertyPath = System.Text.Encoding.UTF8.GetBytes(path); + if (property.Value.ValueKind == JsonValueKind.Null) + { + tool.Patch.SetNull(propertyPath); + } + else + { + tool.Patch.Set(propertyPath, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(property.Value))); + } + } +#pragma warning restore SCME0001 + + parsedTools.Add(tool); + } + + options.Tools.Clear(); + foreach (ChatTool tool in parsedTools) + { + options.Tools.Add(tool); + } + + return true; + } + /// /// Applies all entries from . to . /// From 47f0756ee43a6809060be965a47faa0d49dc0c08 Mon Sep 17 00:00:00 2001 From: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:58:01 +0200 Subject: [PATCH 2/2] Address tools JSONPath review feedback --- .../OpenAIChatCompletionExtraBodyTests.cs | 24 ++++ .../Settings/OpenAIPromptExecutionSettings.cs | 127 ++++++++++++++---- 2 files changed, 123 insertions(+), 28 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 07bf0060a69e..e2538f782339 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -147,6 +147,30 @@ public async Task ExtraBodyToolsDoesNotDuplicatePropertyAsync(string key) Assert.Equal("lookup", tools.Value[1].GetProperty("function").GetProperty("name").GetString()); } + [Fact] + public async Task ExtraBodyNestedToolsPathDoesNotDuplicatePropertyAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.tools[0].type"] = "web_search", + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var body = ParseRequestBody(this._messageHandlerStub.RequestContent!); + var tools = Assert.Single(body.EnumerateObject(), property => property.NameEquals("tools")); + var tool = Assert.Single(tools.Value.EnumerateArray()); + Assert.Equal("web_search", tool.GetProperty("type").GetString()); + Assert.False(tool.TryGetProperty("function", out _)); + } + [Fact] public async Task ExtraBodyNestedDictionaryEmitsNestedJsonObjectAsync() { diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs index 095d01d5a96f..278b0c1f2872 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Settings/OpenAIPromptExecutionSettings.cs @@ -676,55 +676,126 @@ internal static void ApplyExtraBodyEntry(ChatCompletionOptions options, string k private static bool TryApplyExtraBodyTools(ChatCompletionOptions options, string key, object? value) { - if (value is null || (key != "tools" && key != "$.tools")) + if (key is "tools" or "$.tools") + { + if (value is null) + { + return false; + } + + JsonElement tools = JsonSerializer.SerializeToElement(value, value.GetType()); + if (tools.ValueKind != JsonValueKind.Array || tools.EnumerateArray().Any(tool => tool.ValueKind != JsonValueKind.Object)) + { + return false; + } + + options.Tools.Clear(); + foreach (JsonElement toolElement in tools.EnumerateArray()) + { + options.Tools.Add(CreatePatchedTool(toolElement)); + } + + return true; + } + + const string ToolsIndexPrefix = "$.tools["; + if (!key.StartsWith(ToolsIndexPrefix, StringComparison.Ordinal)) { return false; } - JsonElement tools = JsonSerializer.SerializeToElement(value, value.GetType()); - if (tools.ValueKind != JsonValueKind.Array || tools.EnumerateArray().Any(tool => tool.ValueKind != JsonValueKind.Object)) + int closingBracket = key.IndexOf(']', ToolsIndexPrefix.Length); + if (closingBracket < 0 || + !int.TryParse(key[ToolsIndexPrefix.Length..closingBracket], out int toolIndex) || + toolIndex < 0) { return false; } - var parsedTools = new List(); - foreach (JsonElement toolElement in tools.EnumerateArray()) + string toolPath = key[(closingBracket + 1)..]; + if (toolPath.Length > 0 && toolPath[0] is not ('.' or '[')) { - // ChatTool currently models function tools only. Start with one so the SDK owns serialization of the - // surrounding tools array, then replace its fields through JsonPatch to support newer tool types. - ChatTool tool = ChatTool.CreateFunctionTool("placeholder"); + return false; + } -#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. - tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.function")); - foreach (JsonProperty property in toolElement.EnumerateObject()) + while (options.Tools.Count <= toolIndex) + { + options.Tools.Add(CreateEmptyPatchedTool()); + } + + if (toolPath.Length == 0) + { + if (value is null) { - string path = property.Name is "type" or "function" - ? $"$.{property.Name}" - : $"$[{JsonSerializer.Serialize(property.Name)}]"; - byte[] propertyPath = System.Text.Encoding.UTF8.GetBytes(path); - if (property.Value.ValueKind == JsonValueKind.Null) - { - tool.Patch.SetNull(propertyPath); - } - else - { - tool.Patch.Set(propertyPath, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(property.Value))); - } + return false; + } + + JsonElement tool = JsonSerializer.SerializeToElement(value, value.GetType()); + if (tool.ValueKind != JsonValueKind.Object) + { + return false; } -#pragma warning restore SCME0001 - parsedTools.Add(tool); + options.Tools[toolIndex] = CreatePatchedTool(tool); + return true; } - options.Tools.Clear(); - foreach (ChatTool tool in parsedTools) +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + byte[] nestedPath = System.Text.Encoding.UTF8.GetBytes($"${toolPath}"); + if (value is null) + { + options.Tools[toolIndex].Patch.SetNull(nestedPath); + } + else { - options.Tools.Add(tool); + options.Tools[toolIndex].Patch.Set( + nestedPath, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()))); } +#pragma warning restore SCME0001 return true; } + private static ChatTool CreatePatchedTool(JsonElement toolElement) + { + ChatTool tool = CreateEmptyPatchedTool(); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + foreach (JsonProperty property in toolElement.EnumerateObject()) + { + string path = property.Name is "type" or "function" + ? $"$.{property.Name}" + : $"$[{JsonSerializer.Serialize(property.Name)}]"; + byte[] propertyPath = System.Text.Encoding.UTF8.GetBytes(path); + if (property.Value.ValueKind == JsonValueKind.Null) + { + tool.Patch.SetNull(propertyPath); + } + else + { + tool.Patch.Set(propertyPath, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(property.Value))); + } + } +#pragma warning restore SCME0001 + + return tool; + } + + private static ChatTool CreateEmptyPatchedTool() + { + // ChatTool currently models function tools only. Use one as the SDK-owned carrier for the tools array, + // then remove its modeled defaults so ExtraBody remains the sole source of tool fields. + ChatTool tool = ChatTool.CreateFunctionTool("placeholder"); + +#pragma warning disable SCME0001 // System.ClientModel JsonPatch is for evaluation purposes only. + tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.type")); + tool.Patch.Remove(System.Text.Encoding.UTF8.GetBytes("$.function")); +#pragma warning restore SCME0001 + + return tool; + } + /// /// Applies all entries from . to . ///