diff --git a/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs b/dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs index 95f0b409ed0b..8af5ebff4cf1 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.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -206,6 +207,100 @@ 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 + 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 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 + 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] + 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() { // 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..d89456388cf2 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,122 @@ 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) + { +#pragma warning disable CA1031 // Do not let sanitization failures break request pipeline + try + { + if (message.Request.Content is null) + { + 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); + if (memoryStream.Length == 0) + { + return; + } + + 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; + } + + string cleanJson = DeduplicateTopLevelJsonKeys(rawJson); + if (!string.Equals(rawJson, cleanJson, StringComparison.Ordinal)) + { + message.Request.Content = System.ClientModel.BinaryContent.Create(BinaryData.FromString(cleanJson)); + } + } + catch + { + return; + } +#pragma warning restore CA1031 + } + + 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); + 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)) + { + writer.WriteStartObject(); + foreach (var kvp in dictionary) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + writer.WriteEndObject(); + } + + 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 + { + return rawJson; + } +#pragma warning restore CA1031 + } + } }