Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -206,6 +207,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<string, object?>
{
["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<string, object?>
{
["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.
Expand Down
103 changes: 102 additions & 1 deletion dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.ClientModel;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -270,4 +271,104 @@ protected static GenericActionPipelinePolicy CreateRequestHeaderPolicy(string he
}
});
}

private sealed class DeduplicateJsonKeysPipelinePolicy : PipelinePolicy
{
public static DeduplicateJsonKeysPipelinePolicy Instance { get; } = new();

public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
SanitizeMessageContent(message);
ProcessNext(message, pipeline, currentIndex);
}

public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> 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;
}

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).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<string, System.Text.Json.JsonElement>(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();
}

return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
catch
{
return rawJson;
}
#pragma warning restore CA1031
}
}
}
Loading