From 8b64c7433b361fa4342e4421b1ae2411fd5273a0 Mon Sep 17 00:00:00 2001 From: Nithin Date: Mon, 3 Aug 2026 23:51:38 -0400 Subject: [PATCH 1/6] fix(dotnet/connectors/openai): deduplicate top-level JSON keys when using ExtraBody (#14156) --- .../OpenAIChatCompletionExtraBodyTests.cs | 54 +++++++++++- .../Connectors.OpenAI/Core/ClientCore.cs | 87 ++++++++++++++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 95f0b409ed0b..eb175181a4e3 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -1,7 +1,8 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Http; using System.Text; @@ -12,6 +13,7 @@ using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.OpenAI; using Xunit; +using Xunit.Abstractions; using ChatMessageContent = Microsoft.SemanticKernel.ChatMessageContent; namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; @@ -25,9 +27,11 @@ public sealed class OpenAIChatCompletionExtraBodyTests : IDisposable private readonly HttpMessageHandlerStub _messageHandlerStub; private readonly HttpClient _httpClient; private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; + private readonly ITestOutputHelper _output; - public OpenAIChatCompletionExtraBodyTests() + public OpenAIChatCompletionExtraBodyTests(ITestOutputHelper output) { + this._output = output; this._messageHandlerStub = new HttpMessageHandlerStub { ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) @@ -206,6 +210,52 @@ public async Task ExtraBodyNullValueEmitsJsonNullAsync() } [Fact] + public async Task ExtraBodyToolsDoesNotEmitDuplicateToolsKeyInRequestBodyAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["tools"] = new[] { new { type = "web_search" } }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var jsonString = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + int toolsKeyCount = System.Text.RegularExpressions.Regex.Matches(jsonString, "\"tools\"\\s*:").Count; + Assert.Equal(1, toolsKeyCount); + } + + [Fact] + public async Task ExtraBodyDoesNotEmitDuplicateTopLevelKeysInRequestBodyAsync() + { + // Arrange + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + Temperature = 0.7, + ExtraBody = new Dictionary + { + ["temperature"] = 0.5, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + var jsonString = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); + int tempKeyCount = System.Text.RegularExpressions.Regex.Matches(jsonString, "\"temperature\"\\s*:").Count; + Assert.Equal(1, tempKeyCount); + } + + [Fact] + public void FromExecutionSettingsRoundTripPreservesExtraBody() { // Arrange - deserializing through the base type (e.g. via PromptTemplateConfig) should preserve extra_body. diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs index 4f7beb0e0e23..8fbbae0e0d1e 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.ClientModel; @@ -202,6 +202,7 @@ internal static OpenAIClientOptions GetOpenAIClientOptions(HttpClient? httpClien options.Endpoint ??= endpoint ?? httpClient?.BaseAddress; options.AddPolicy(CreateRequestHeaderPolicy(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(ClientCore))), PipelinePosition.PerCall); + options.AddPolicy(DeduplicateJsonKeysPipelinePolicy.Instance, PipelinePosition.PerCall); if (orgId is not null) { @@ -270,4 +271,88 @@ protected static GenericActionPipelinePolicy CreateRequestHeaderPolicy(string he } }); } + + private sealed class DeduplicateJsonKeysPipelinePolicy : PipelinePolicy + { + public static DeduplicateJsonKeysPipelinePolicy Instance { get; } = new(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + SanitizeMessageContent(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + SanitizeMessageContent(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void SanitizeMessageContent(PipelineMessage message) + { + if (message.Request.Content is null) + { + return; + } + + using var memoryStream = new System.IO.MemoryStream(); + message.Request.Content.WriteTo(memoryStream, default); + byte[] bytes = memoryStream.ToArray(); + if (bytes.Length == 0) + { + return; + } + + string rawJson = System.Text.Encoding.UTF8.GetString(bytes); + if (!rawJson.StartsWith('{')) + { + return; + } + + string cleanJson = DeduplicateTopLevelJsonKeys(rawJson); + if (!string.Equals(rawJson, cleanJson, StringComparison.Ordinal)) + { + message.Request.Content = System.ClientModel.BinaryContent.Create(BinaryData.FromString(cleanJson)); + } + } + + private static string DeduplicateTopLevelJsonKeys(string rawJson) + { +#pragma warning disable CA1031 // Catch all exceptions to prevent request pipeline failure + try + { + using var doc = System.Text.Json.JsonDocument.Parse(rawJson); + var root = doc.RootElement; + if (root.ValueKind != System.Text.Json.JsonValueKind.Object) + { + return rawJson; + } + + var dictionary = new Dictionary(StringComparer.Ordinal); + foreach (var prop in root.EnumerateObject()) + { + dictionary[prop.Name] = prop.Value.Clone(); + } + + using var stream = new System.IO.MemoryStream(); + using (var writer = new System.Text.Json.Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var kvp in dictionary) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + catch + { + return rawJson; + } +#pragma warning restore CA1031 + } + } } From f52b28921a11ac1aaa5fd0b592aae94227959292 Mon Sep 17 00:00:00 2001 From: Nithin Date: Tue, 4 Aug 2026 00:24:52 -0400 Subject: [PATCH 2/6] style(dotnet/connectors/openai): clean up unused test output helper --- .../Services/OpenAIChatCompletionExtraBodyTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index eb175181a4e3..778619500932 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -13,7 +13,6 @@ using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.OpenAI; using Xunit; -using Xunit.Abstractions; using ChatMessageContent = Microsoft.SemanticKernel.ChatMessageContent; namespace SemanticKernel.Connectors.OpenAI.UnitTests.Services; @@ -27,11 +26,9 @@ public sealed class OpenAIChatCompletionExtraBodyTests : IDisposable private readonly HttpMessageHandlerStub _messageHandlerStub; private readonly HttpClient _httpClient; private readonly ChatHistory _chatHistory = [new ChatMessageContent(AuthorRole.User, "test")]; - private readonly ITestOutputHelper _output; - public OpenAIChatCompletionExtraBodyTests(ITestOutputHelper output) + public OpenAIChatCompletionExtraBodyTests() { - this._output = output; this._messageHandlerStub = new HttpMessageHandlerStub { ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) From 6df3a390be5b24416e4f903835e4ac60877fcd11 Mon Sep 17 00:00:00 2001 From: Nithin Date: Tue, 4 Aug 2026 00:43:11 -0400 Subject: [PATCH 3/6] perf(dotnet/connectors/openai): optimize deduplication pipeline policy and add exception safety --- .../Connectors.OpenAI/Core/ClientCore.cs | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs index 8fbbae0e0d1e..5365de4e7286 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs @@ -290,30 +290,39 @@ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyL private static void SanitizeMessageContent(PipelineMessage message) { - if (message.Request.Content is null) +#pragma warning disable CA1031 // Do not let sanitization failures break request pipeline + try { - return; - } + if (message.Request.Content is null) + { + return; + } - using var memoryStream = new System.IO.MemoryStream(); - message.Request.Content.WriteTo(memoryStream, default); - byte[] bytes = memoryStream.ToArray(); - if (bytes.Length == 0) - { - return; - } + using var memoryStream = new System.IO.MemoryStream(); + message.Request.Content.WriteTo(memoryStream, default); + byte[] bytes = memoryStream.ToArray(); + if (bytes.Length == 0) + { + return; + } - string rawJson = System.Text.Encoding.UTF8.GetString(bytes); - if (!rawJson.StartsWith('{')) - { - return; - } + string rawJson = System.Text.Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); + if (!rawJson.StartsWith('{')) + { + return; + } - string cleanJson = DeduplicateTopLevelJsonKeys(rawJson); - if (!string.Equals(rawJson, cleanJson, StringComparison.Ordinal)) + string cleanJson = DeduplicateTopLevelJsonKeys(rawJson); + if (!string.Equals(rawJson, cleanJson, StringComparison.Ordinal)) + { + message.Request.Content = System.ClientModel.BinaryContent.Create(BinaryData.FromString(cleanJson)); + } + } + catch { - message.Request.Content = System.ClientModel.BinaryContent.Create(BinaryData.FromString(cleanJson)); + return; } +#pragma warning restore CA1031 } private static string DeduplicateTopLevelJsonKeys(string rawJson) @@ -329,11 +338,18 @@ private static string DeduplicateTopLevelJsonKeys(string rawJson) } var dictionary = new Dictionary(StringComparer.Ordinal); + bool hadDuplicates = false; foreach (var prop in root.EnumerateObject()) { + hadDuplicates |= dictionary.ContainsKey(prop.Name); dictionary[prop.Name] = prop.Value.Clone(); } + if (!hadDuplicates) + { + return rawJson; + } + using var stream = new System.IO.MemoryStream(); using (var writer = new System.Text.Json.Utf8JsonWriter(stream)) { From 6eecaf5a6bd73ba9f6a9c1270ab1ab7823108f34 Mon Sep 17 00:00:00 2001 From: Nithin Date: Tue, 4 Aug 2026 10:18:01 -0400 Subject: [PATCH 4/6] test(dotnet/connectors/openai): use JsonDocument property enumeration and clean unused usings --- .../Services/OpenAIChatCompletionExtraBodyTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 778619500932..422c1d473d21 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -223,8 +223,8 @@ public async Task ExtraBodyToolsDoesNotEmitDuplicateToolsKeyInRequestBodyAsync() await service.GetChatMessageContentsAsync(this._chatHistory, settings); // Assert - var jsonString = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); - int toolsKeyCount = System.Text.RegularExpressions.Regex.Matches(jsonString, "\"tools\"\\s*:").Count; + using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); + int toolsKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("tools")); Assert.Equal(1, toolsKeyCount); } @@ -246,8 +246,8 @@ public async Task ExtraBodyDoesNotEmitDuplicateTopLevelKeysInRequestBodyAsync() await service.GetChatMessageContentsAsync(this._chatHistory, settings); // Assert - var jsonString = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent!); - int tempKeyCount = System.Text.RegularExpressions.Regex.Matches(jsonString, "\"temperature\"\\s*:").Count; + using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); + int tempKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("temperature")); Assert.Equal(1, tempKeyCount); } From 23553c86f16bfa58cc4edcc1d397216512df943a Mon Sep 17 00:00:00 2001 From: Nithin Date: Tue, 4 Aug 2026 11:22:49 -0400 Subject: [PATCH 5/6] perf(dotnet/connectors/openai): add Content-Type short-circuit and assert surviving patched values in tests --- .../Services/OpenAIChatCompletionExtraBodyTests.cs | 2 ++ dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 422c1d473d21..5eb2ef630ddc 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -226,6 +226,7 @@ public async Task ExtraBodyToolsDoesNotEmitDuplicateToolsKeyInRequestBodyAsync() using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); int toolsKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("tools")); Assert.Equal(1, toolsKeyCount); + Assert.Equal("web_search", doc.RootElement.GetProperty("tools")[0].GetProperty("type").GetString()); } [Fact] @@ -249,6 +250,7 @@ public async Task ExtraBodyDoesNotEmitDuplicateTopLevelKeysInRequestBodyAsync() using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); int tempKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("temperature")); Assert.Equal(1, tempKeyCount); + Assert.Equal(0.5, doc.RootElement.GetProperty("temperature").GetDouble()); } [Fact] diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs index 5365de4e7286..2475efb0d99b 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs @@ -298,6 +298,13 @@ private static void SanitizeMessageContent(PipelineMessage message) return; } + if (message.Request.Headers.TryGetValue("Content-Type", out string? contentType) && + contentType is not null && + !contentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + { + return; + } + using var memoryStream = new System.IO.MemoryStream(); message.Request.Content.WriteTo(memoryStream, default); byte[] bytes = memoryStream.ToArray(); From c757ca6af86198344063af073bbd9dfe4325ca48 Mon Sep 17 00:00:00 2001 From: Nithin Date: Tue, 4 Aug 2026 12:09:33 -0400 Subject: [PATCH 6/6] perf(dotnet/connectors/openai): eliminate MemoryStream allocations and add JSONPath regression tests --- .../OpenAIChatCompletionExtraBodyTests.cs | 46 +++++++++++++++++++ .../Connectors.OpenAI/Core/ClientCore.cs | 19 ++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 5eb2ef630ddc..8af5ebff4cf1 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs @@ -253,6 +253,52 @@ public async Task ExtraBodyDoesNotEmitDuplicateTopLevelKeysInRequestBodyAsync() Assert.Equal(0.5, doc.RootElement.GetProperty("temperature").GetDouble()); } + [Fact] + public async Task ExtraBodyJsonPathToolsDoesNotEmitDuplicateToolsKeyAsync() + { + // Arrange - JSONPath root notation: ["$.tools"] + var service = new OpenAIChatCompletionService("gpt-4o", apiKey: "NOKEY", httpClient: this._httpClient); + var settings = new OpenAIPromptExecutionSettings + { + ExtraBody = new Dictionary + { + ["$.tools"] = new[] { new { type = "web_search" } }, + }, + }; + + // Act + await service.GetChatMessageContentsAsync(this._chatHistory, settings); + + // Assert + using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); + int toolsKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("tools")); + Assert.Equal(1, toolsKeyCount); + Assert.Equal("web_search", doc.RootElement.GetProperty("tools")[0].GetProperty("type").GetString()); + } + + [Fact] + public async Task ExtraBodyJsonPathNestedToolsDoesNotEmitDuplicateToolsKeyAsync() + { + // Arrange - JSONPath array indexing notation: ["$.tools[0].type"] + 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 + using var doc = JsonDocument.Parse(this._messageHandlerStub.RequestContent!); + int toolsKeyCount = doc.RootElement.EnumerateObject().Count(p => p.NameEquals("tools")); + Assert.Equal(1, toolsKeyCount); + Assert.Equal("web_search", doc.RootElement.GetProperty("tools")[0].GetProperty("type").GetString()); + } + [Fact] public void FromExecutionSettingsRoundTripPreservesExtraBody() diff --git a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs index 2475efb0d99b..d89456388cf2 100644 --- a/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs +++ b/dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs @@ -307,13 +307,18 @@ contentType is not null && using var memoryStream = new System.IO.MemoryStream(); message.Request.Content.WriteTo(memoryStream, default); - byte[] bytes = memoryStream.ToArray(); - if (bytes.Length == 0) + if (memoryStream.Length == 0) { return; } - string rawJson = System.Text.Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); + byte[] bytes = memoryStream.TryGetBuffer(out ArraySegment buffer) + ? buffer.Array! + : memoryStream.ToArray(); + int offset = memoryStream.TryGetBuffer(out buffer) ? buffer.Offset : 0; + int count = (int)memoryStream.Length; + + string rawJson = System.Text.Encoding.UTF8.GetString(bytes, offset, count).TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); if (!rawJson.StartsWith('{')) { return; @@ -369,7 +374,13 @@ private static string DeduplicateTopLevelJsonKeys(string rawJson) writer.WriteEndObject(); } - return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + byte[] streamBytes = stream.TryGetBuffer(out ArraySegment streamBuffer) + ? streamBuffer.Array! + : stream.ToArray(); + int streamOffset = stream.TryGetBuffer(out streamBuffer) ? streamBuffer.Offset : 0; + int streamCount = (int)stream.Length; + + return System.Text.Encoding.UTF8.GetString(streamBytes, streamOffset, streamCount); } catch {