Skip to content

Responses: Patch.Set("$.tools") writes the 'tools' key twice — API now rejects these bodies (HTTP 400 invalid_json, duplicate JSON key) #1240

Description

@joakimriedel

Service: OpenAI + Azure OpenAI (v1 endpoint)
Library version: 2.11.0 and 2.12.0 (both reproduce)
System.ClientModel: 1.10.0 and 1.14.0 (both reproduce)

Summary

Setting a JSON Patch on a root-level collection property of CreateResponseOptions — e.g. options.Patch.Set("$.tools"u8, ...) — serializes that key twice in the request body. Both copies carry the identical patched value, so this was invisible while the API parsed duplicate keys last-key-wins. As of ~2026-07-03 the Responses serving stack (api.openai.com /v1/responses) and the Azure OpenAI v1 gateway reject such bodies at parse time:

HTTP 400 (invalid_request_error: invalid_json)
Invalid body: duplicate JSON key 'tools' at 'tools'. Duplicate JSON keys are not supported.

This broke us in production: the Patch route is one of the recommended interim approaches for tools that have no typed surface yet (e.g. tool_search / namespace, #1053), so every request using it now hard-fails.

Minimal repro

using System.ClientModel.Primitives;
using OpenAI.Responses;

var options = new CreateResponseOptions { Model = "gpt-5.5" };
#pragma warning disable SCME0001
options.Patch.Set("$.tools"u8, BinaryData.FromString("""[{"type":"tool_search"}]"""));
#pragma warning restore SCME0001

Console.WriteLine(ModelReaderWriter.Write(options).ToString());
// {"model":"gpt-5.5","tools":[{"type":"tool_search"}],"tools":[{"type":"tool_search"}]}
//                     ^^^^^ once in the property slot …      ^^^^^ … and again at the end

The typed Tools collection's state is irrelevant — empty, populated, or cleared all produce the same duplicate.

Mechanism

In the generated CreateResponseOptions JsonModelWriteCore, the patch branch for tools writes the property via Patch.GetJson("$.tools"u8) + WriteRawValue. GetJson is a pure read and never flags the entry as ValueKind.ModelOwned — only the prefixed WriteTo(writer, jsonPath) overload sets that flag, and it only runs on the non-patch (typed) branch. The trailing bare Patch.WriteTo(writer) at the end of WriteCore skips only Removed|ModelOwned entries, so it re-emits the same $.tools entry as a second key (matching the server error's locator: the duplicate appears after the first).

The same pattern affects the other root collection properties handled via a Contains → GetJson → WriteRawValue branch ($.input, $.include). Scalar/object paths are written once (only by the trailing WriteTo) and are fine.

A related sharp edge with the same root cause family: patching a sub-path of a subtree whose typed property is set (e.g. options.Patch.Set("$.text.verbosity"u8, "low") with TextOptions.TextFormat = a json_schema format) causes the patch to replace the typed text object entirely, silently dropping the format. Patching the child model (options.TextOptions.Patch.Set("$.verbosity"u8, ...)) merges correctly.

Workaround (verified)

Materialize the raw JSON as base tools and add them to the typed collection instead of patching the array — this serializes a single tools key with byte-identical content:

var tool = ModelReaderWriter.Read<ResponseTool>(BinaryData.FromString("""{"type":"tool_search"}"""));
options.Tools.Add(tool);

Expected behavior

A patched root-level collection property should be written exactly once — either GetJson usage in the generated writer should mark the entry consumed, or the generated writer should route through the prefixed WriteTo(writer, jsonPath) overload on the patch branch.

Given the server-side strict validation is already live, this probably deserves a fix in both this SDK's generated writers and/or System.ClientModel's JsonPatch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: infrastructureThis item is related to general infrastructure of the repository or related tooling.blocked: upstreamThis issue is blocked on an upstream change, such as to the REST API spec or generator.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions