Skip to content

Commit 213062c

Browse files
committed
Tweak OpenAI JSON schema transforms
Only require all properties and force additionalProperties: false when strict is set or when structured output is used. Also update the OpenAI assistant chat client to apply the transforms.
1 parent 694b95e commit 213062c

File tree

5 files changed

+116
-52
lines changed

5 files changed

+116
-52
lines changed

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public OpenAIAssistantChatClient(AssistantClient assistantClient, string assista
6060
// implement the abstractions directly rather than providing adapters on top of the public APIs,
6161
// the package can provide such implementations separate from what's exposed in the public API.
6262
Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
63-
?.GetValue(assistantClient) as Uri ?? OpenAIResponseChatClient.DefaultOpenAIEndpoint;
63+
?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint;
6464

6565
_metadata = new("openai", providerUrl);
6666
}
@@ -284,13 +284,18 @@ void IDisposable.Dispose()
284284
switch (tool)
285285
{
286286
case AIFunction aiFunction:
287-
bool? strict = aiFunction.AdditionalProperties.TryGetValue(nameof(strict), out var strictValue) && strictValue is bool strictBool ?
287+
bool? strict = aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out var strictValue) && strictValue is bool strictBool ?
288288
strictBool :
289289
null;
290+
291+
JsonElement jsonSchema = strict is true ?
292+
OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
293+
OpenAIClientExtensions.NonStrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction);
294+
290295
runOptions.ToolsOverride.Add(new FunctionToolDefinition(aiFunction.Name)
291296
{
292297
Description = aiFunction.Description,
293-
Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(aiFunction.JsonSchema, AssistantJsonContext.Default.JsonElement)),
298+
Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
294299
StrictParameterSchemaEnabled = strict,
295300
});
296301
break;
@@ -334,10 +339,10 @@ void IDisposable.Dispose()
334339
runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat();
335340
break;
336341

337-
case ChatResponseFormatJson jsonFormat when jsonFormat.Schema is not null:
342+
case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema:
338343
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
339344
jsonFormat.SchemaName,
340-
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonFormat.Schema, AssistantJsonContext.Default.JsonElement)),
345+
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
341346
jsonFormat.SchemaDescription);
342347
break;
343348

@@ -382,7 +387,7 @@ void AppendSystemInstructions(string? toAppend)
382387
// to include that information in its responses. System messages should ideally be instead done as instructions to
383388
// the assistant when the assistant is created.
384389
if (chatMessage.Role == ChatRole.System ||
385-
chatMessage.Role == OpenAIResponseChatClient.ChatRoleDeveloper)
390+
chatMessage.Role == OpenAIClientExtensions.ChatRoleDeveloper)
386391
{
387392
foreach (var textContent in chatMessage.Contents.OfType<TextContent>())
388393
{

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,6 @@ namespace Microsoft.Extensions.AI;
2525
/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="OpenAIClient"/> or <see cref="ChatClient"/>.</summary>
2626
internal sealed partial class OpenAIChatClient : IChatClient
2727
{
28-
/// <summary>Gets the JSON schema transformer cache conforming to OpenAI restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.</summary>
29-
internal static AIJsonSchemaTransformCache SchemaTransformCache { get; } = new(new()
30-
{
31-
RequireAllProperties = true,
32-
DisallowAdditionalProperties = true,
33-
ConvertBooleanSchemas = true,
34-
MoveDefaultKeywordToDescription = true,
35-
});
36-
3728
/// <summary>Metadata about the client.</summary>
3829
private readonly ChatClientMetadata _metadata;
3930

@@ -54,7 +45,7 @@ public OpenAIChatClient(ChatClient chatClient)
5445
// implement the abstractions directly rather than providing adapters on top of the public APIs,
5546
// the package can provide such implementations separate from what's exposed in the public API.
5647
Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
57-
?.GetValue(chatClient) as Uri ?? OpenAIResponseChatClient.DefaultOpenAIEndpoint;
48+
?.GetValue(chatClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint;
5849
string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
5950
?.GetValue(chatClient) as string;
6051

@@ -125,12 +116,12 @@ void IDisposable.Dispose()
125116
{
126117
if (input.Role == ChatRole.System ||
127118
input.Role == ChatRole.User ||
128-
input.Role == OpenAIResponseChatClient.ChatRoleDeveloper)
119+
input.Role == OpenAIClientExtensions.ChatRoleDeveloper)
129120
{
130121
var parts = ToOpenAIChatContent(input.Contents);
131122
yield return
132123
input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } :
133-
input.Role == OpenAIResponseChatClient.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } :
124+
input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } :
134125
new UserChatMessage(parts) { ParticipantName = input.AuthorName };
135126
}
136127
else if (input.Role == ChatRole.Tool)
@@ -553,7 +544,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
553544
}
554545
else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat)
555546
{
556-
result.ResponseFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
547+
result.ResponseFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
557548
OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(
558549
jsonFormat.SchemaName ?? "json_schema",
559550
BinaryData.FromBytes(
@@ -570,12 +561,14 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
570561
private static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
571562
{
572563
bool? strict =
573-
aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) &&
564+
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
574565
strictObj is bool strictValue ?
575566
strictValue : null;
576567

577568
// Perform transformations making the schema legal per OpenAI restrictions
578-
JsonElement jsonSchema = SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction);
569+
JsonElement jsonSchema = strict is true ?
570+
OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
571+
OpenAIClientExtensions.NonStrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction);
579572

580573
// Map to an intermediate model so that redundant properties are skipped.
581574
var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!;
@@ -622,7 +615,7 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) =>
622615
ChatMessageRole.User => ChatRole.User,
623616
ChatMessageRole.Assistant => ChatRole.Assistant,
624617
ChatMessageRole.Tool => ChatRole.Tool,
625-
ChatMessageRole.Developer => OpenAIResponseChatClient.ChatRoleDeveloper,
618+
ChatMessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper,
626619
_ => new ChatRole(role.ToString()),
627620
};
628621

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System;
45
using System.Diagnostics.CodeAnalysis;
56
using OpenAI;
67
using OpenAI.Assistants;
@@ -14,6 +15,36 @@ namespace Microsoft.Extensions.AI;
1415
/// <summary>Provides extension methods for working with <see cref="OpenAIClient"/>s.</summary>
1516
public static class OpenAIClientExtensions
1617
{
18+
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
19+
internal const string StrictKey = "strictJsonSchema";
20+
21+
/// <summary>Gets the default OpenAI endpoint.</summary>
22+
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
23+
24+
/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
25+
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
26+
27+
/// <summary>
28+
/// Gets the JSON schema transformer cache conforming to OpenAI restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
29+
/// </summary>
30+
internal static AIJsonSchemaTransformCache NonStrictSchemaTransformCache { get; } = new(new()
31+
{
32+
ConvertBooleanSchemas = true,
33+
MoveDefaultKeywordToDescription = true,
34+
});
35+
36+
/// <summary>
37+
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
38+
/// This adds to <see cref="NonStrictSchemaTransformCache"/> by requiring all properties to be present and disallowing additional properties.
39+
/// </summary>
40+
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
41+
{
42+
RequireAllProperties = true,
43+
DisallowAdditionalProperties = true,
44+
ConvertBooleanSchemas = true,
45+
MoveDefaultKeywordToDescription = true,
46+
});
47+
1748
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatClient"/>.</summary>
1849
/// <param name="chatClient">The client.</param>
1950
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ChatClient"/>.</returns>

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
using System.Threading.Tasks;
1414
using Microsoft.Shared.Diagnostics;
1515
using OpenAI.Responses;
16-
using static Microsoft.Extensions.AI.OpenAIChatClient;
1716

1817
#pragma warning disable S907 // "goto" statement should not be used
1918
#pragma warning disable S1067 // Expressions should not be too complex
@@ -26,12 +25,6 @@ namespace Microsoft.Extensions.AI;
2625
/// <summary>Represents an <see cref="IChatClient"/> for an <see cref="OpenAIResponseClient"/>.</summary>
2726
internal sealed partial class OpenAIResponseChatClient : IChatClient
2827
{
29-
/// <summary>Gets the default OpenAI endpoint.</summary>
30-
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
31-
32-
/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
33-
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
34-
3528
/// <summary>Metadata about the client.</summary>
3629
private readonly ChatClientMetadata _metadata;
3730

@@ -52,7 +45,7 @@ public OpenAIResponseChatClient(OpenAIResponseClient responseClient)
5245
// implement the abstractions directly rather than providing adapters on top of the public APIs,
5346
// the package can provide such implementations separate from what's exposed in the public API.
5447
Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
55-
?.GetValue(responseClient) as Uri ?? DefaultOpenAIEndpoint;
48+
?.GetValue(responseClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint;
5649
string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
5750
?.GetValue(responseClient) as string;
5851

@@ -336,7 +329,7 @@ private static ChatRole ToChatRole(MessageRole? role) =>
336329
role switch
337330
{
338331
MessageRole.System => ChatRole.System,
339-
MessageRole.Developer => ChatRoleDeveloper,
332+
MessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper,
340333
MessageRole.User => ChatRole.User,
341334
_ => ChatRole.Assistant,
342335
};
@@ -381,9 +374,18 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
381374
switch (tool)
382375
{
383376
case AIFunction af:
384-
var oaitool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(af), ResponseClientJsonContext.Default.ResponseToolJson)!;
377+
bool strict =
378+
af.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
379+
strictObj is bool strictValue &&
380+
strictValue;
381+
382+
JsonElement jsonSchema = strict ?
383+
OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(af) :
384+
OpenAIClientExtensions.NonStrictSchemaTransformCache.GetOrCreateTransformedSchema(af);
385+
386+
var oaitool = JsonSerializer.Deserialize(jsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!;
385387
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson));
386-
result.Tools.Add(ResponseTool.CreateFunctionTool(af.Name, af.Description, functionParameters));
388+
result.Tools.Add(ResponseTool.CreateFunctionTool(af.Name, af.Description, functionParameters, strict));
387389
break;
388390

389391
case HostedWebSearchTool:
@@ -440,7 +442,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
440442
{
441443
result.TextOptions = new()
442444
{
443-
TextFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
445+
TextFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
444446
ResponseTextFormat.CreateJsonSchemaFormat(
445447
jsonFormat.SchemaName ?? "json_schema",
446448
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ResponseClientJsonContext.Default.JsonElement)),
@@ -460,7 +462,7 @@ private static IEnumerable<ResponseItem> ToOpenAIResponseItems(
460462
foreach (ChatMessage input in inputs)
461463
{
462464
if (input.Role == ChatRole.System ||
463-
input.Role == ChatRoleDeveloper)
465+
input.Role == OpenAIClientExtensions.ChatRoleDeveloper)
464466
{
465467
string text = input.Text;
466468
if (!string.IsNullOrWhiteSpace(text))

0 commit comments

Comments
 (0)