From 8b64c7433b361fa4342e4421b1ae2411fd5273a0 Mon Sep 17 00:00:00 2001 From: Nithin Date: Mon, 3 Aug 2026 23:51:38 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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)) {