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.
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: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
The typed
Toolscollection's state is irrelevant — empty, populated, or cleared all produce the same duplicate.Mechanism
In the generated
CreateResponseOptionsJsonModelWriteCore, the patch branch fortoolswrites the property viaPatch.GetJson("$.tools"u8)+WriteRawValue.GetJsonis a pure read and never flags the entry asValueKind.ModelOwned— only the prefixedWriteTo(writer, jsonPath)overload sets that flag, and it only runs on the non-patch (typed) branch. The trailing barePatch.WriteTo(writer)at the end ofWriteCoreskips onlyRemoved|ModelOwnedentries, so it re-emits the same$.toolsentry 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 → WriteRawValuebranch ($.input,$.include). Scalar/object paths are written once (only by the trailingWriteTo) 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")withTextOptions.TextFormat= a json_schema format) causes the patch to replace the typedtextobject 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
toolskey with byte-identical content:Expected behavior
A patched root-level collection property should be written exactly once — either
GetJsonusage in the generated writer should mark the entry consumed, or the generated writer should route through the prefixedWriteTo(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.