Skip to content

fix(dotnet/connectors/openai): deduplicate top-level JSON keys when using ExtraBody - #14264

Open
nithin42 wants to merge 3 commits into
microsoft:mainfrom
nithin42:fix/extra-body-duplicate-keys
Open

fix(dotnet/connectors/openai): deduplicate top-level JSON keys when using ExtraBody#14264
nithin42 wants to merge 3 commits into
microsoft:mainfrom
nithin42:fix/extra-body-duplicate-keys

Conversation

@nithin42

@nithin42 nithin42 commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #14156

Motivation and Context

When developers use OpenAIPromptExecutionSettings.ExtraBody to pass custom or preview parameters to OpenAI/Azure OpenAI models (such as web search tools via tools, temperature, reasoning_effort, or custom vendor fields), System.ClientModel's JsonPatch appends patched properties onto the outgoing JSON request body.

Because ChatCompletionOptions's default JSON model serializer already writes built-in fields (such as tools: []), JsonPatch appends a second tools property at the end of the JSON object. This creates duplicate top-level keys in the serialized HTTP request payload (e.g., {"messages":[...],"tools":[],"tools":[{"type":"web_search"}]}), causing OpenAI/Azure OpenAI API gateways to reject the request with 400 Bad Request.


Solution: Generic Pipeline Policy (Non-Hardcoded)

Instead of hardcoding if (key == "tools") workarounds in OpenAIPromptExecutionSettings.cs, this PR introduces a generic, non-intrusive solution at the HTTP pipeline level:

  1. DeduplicateJsonKeysPipelinePolicy: Added a new PipelinePolicy inside ClientCore.cs registered at PipelinePosition.PerCall.
  2. Generic Deduplication: The policy inspects outgoing JSON HTTP payloads right before dispatch. If duplicate top-level keys exist (whether from tools, $.tools, $.tools[0], temperature, or vendor extensions), it deduplicates top-level object properties (last-write-wins) using standard JsonDocument/Utf8JsonWriter parsing before transmitting the request.
  3. Format & Property Agnostic: Fixes all property names and all JSONPath key notation styles without modifying SDK model serialization contracts.

Verification & Testing

  • Unit Tests Added: Added ExtraBodyToolsDoesNotEmitDuplicateToolsKeyInRequestBodyAsync and ExtraBodyDoesNotEmitDuplicateTopLevelKeysInRequestBodyAsync in OpenAIChatCompletionExtraBodyTests.cs.
  • Test Suite Results: Verified that all 498 unit tests in Connectors.OpenAI.UnitTests pass cleanly (dotnet test).
  • Raw Payload Inspection: Confirmed raw HTTP request bytes contain exactly one "tools" key and valid JSON.

Copilot AI lite review requested due to automatic review settings August 4, 2026 03:58
@nithin42
nithin42 requested a review from a team as a code owner August 4, 2026 03:58

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 4 | Confidence: 70% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes


Automated review by nithin42's agents

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses OpenAI/Azure OpenAI request failures caused by duplicate top-level JSON keys when OpenAIPromptExecutionSettings.ExtraBody is applied via System.ClientModel’s JsonPatch, by adding a pipeline-level sanitization step that rewrites outgoing JSON to ensure unique top-level property names (last-write-wins).

Changes:

  • Added a per-call DeduplicateJsonKeysPipelinePolicy in ClientCore to rewrite JSON request bodies with deduplicated top-level keys.
  • Added unit tests to assert that tools and other top-level keys (e.g., temperature) are not duplicated in the final request payload.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.cs Registers and implements an HTTP pipeline policy that deduplicates top-level JSON object keys before sending requests.
dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs Adds tests validating that ExtraBody patching does not produce duplicate top-level keys in the request body.
Suppressed comments (2)

dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs:34

  • ITestOutputHelper is injected and stored but never used. This introduces unnecessary dependencies and can trigger compiler warnings (assigned but never used). Either use it (e.g., to log the request JSON on failure) or remove it and restore the parameterless constructor.
    private readonly ITestOutputHelper _output;

    public OpenAIChatCompletionExtraBodyTests(ITestOutputHelper output)
    {
        this._output = output;

dotnet/src/Connectors/Connectors.OpenAI.UnitTests/Services/OpenAIChatCompletionExtraBodyTests.cs:16

  • After removing the unused ITestOutputHelper injection, using Xunit.Abstractions; becomes unused as well and should be removed to avoid warnings.
using Xunit;
using Xunit.Abstractions;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +298 to +316
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));
}
Comment on lines 3 to 7
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
Comment on lines +331 to +337
var dictionary = new Dictionary<string, System.Text.Json.JsonElement>(StringComparer.Ordinal);
foreach (var prop in root.EnumerateObject())
{
dictionary[prop.Name] = prop.Value.Clone();
}

using var stream = new System.IO.MemoryStream();
@nithin42

nithin42 commented Aug 4, 2026

Copy link
Copy Markdown
Author

@nithin42 please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAIPromptExecutionSettings.ExtraBody duplicates tools JSON property when setting nested object for web search tool

2 participants