Skip to content

Tweak OpenAI JSON schema transforms #6523

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 18, 2025
Merged
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
Expand Up @@ -60,7 +60,7 @@ public OpenAIAssistantChatClient(AssistantClient assistantClient, string assista
// implement the abstractions directly rather than providing adapters on top of the public APIs,
// the package can provide such implementations separate from what's exposed in the public API.
Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
?.GetValue(assistantClient) as Uri ?? OpenAIResponseChatClient.DefaultOpenAIEndpoint;
?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint;

_metadata = new("openai", providerUrl);
}
Expand Down Expand Up @@ -284,13 +284,16 @@ void IDisposable.Dispose()
switch (tool)
{
case AIFunction aiFunction:
bool? strict = aiFunction.AdditionalProperties.TryGetValue(nameof(strict), out var strictValue) && strictValue is bool strictBool ?
bool? strict = aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out var strictValue) && strictValue is bool strictBool ?
strictBool :
null;

JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict);

runOptions.ToolsOverride.Add(new FunctionToolDefinition(aiFunction.Name)
{
Description = aiFunction.Description,
Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(aiFunction.JsonSchema, AssistantJsonContext.Default.JsonElement)),
Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
StrictParameterSchemaEnabled = strict,
});
break;
Expand Down Expand Up @@ -334,10 +337,10 @@ void IDisposable.Dispose()
runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat();
break;

case ChatResponseFormatJson jsonFormat when jsonFormat.Schema is not null:
case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema:
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
jsonFormat.SchemaName,
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonFormat.Schema, AssistantJsonContext.Default.JsonElement)),
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
jsonFormat.SchemaDescription);
break;

Expand Down Expand Up @@ -382,7 +385,7 @@ void AppendSystemInstructions(string? toAppend)
// to include that information in its responses. System messages should ideally be instead done as instructions to
// the assistant when the assistant is created.
if (chatMessage.Role == ChatRole.System ||
chatMessage.Role == OpenAIResponseChatClient.ChatRoleDeveloper)
chatMessage.Role == OpenAIClientExtensions.ChatRoleDeveloper)
{
foreach (var textContent in chatMessage.Contents.OfType<TextContent>())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,6 @@ namespace Microsoft.Extensions.AI;
/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="OpenAIClient"/> or <see cref="ChatClient"/>.</summary>
internal sealed partial class OpenAIChatClient : IChatClient
{
/// <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>
internal static AIJsonSchemaTransformCache SchemaTransformCache { get; } = new(new()
{
RequireAllProperties = true,
DisallowAdditionalProperties = true,
ConvertBooleanSchemas = true,
MoveDefaultKeywordToDescription = true,
});

/// <summary>Metadata about the client.</summary>
private readonly ChatClientMetadata _metadata;

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

Expand Down Expand Up @@ -125,12 +116,12 @@ void IDisposable.Dispose()
{
if (input.Role == ChatRole.System ||
input.Role == ChatRole.User ||
input.Role == OpenAIResponseChatClient.ChatRoleDeveloper)
input.Role == OpenAIClientExtensions.ChatRoleDeveloper)
{
var parts = ToOpenAIChatContent(input.Contents);
yield return
input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } :
input.Role == OpenAIResponseChatClient.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } :
input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } :
new UserChatMessage(parts) { ParticipantName = input.AuthorName };
}
else if (input.Role == ChatRole.Tool)
Expand Down Expand Up @@ -553,7 +544,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
}
else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat)
{
result.ResponseFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
result.ResponseFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(
jsonFormat.SchemaName ?? "json_schema",
BinaryData.FromBytes(
Expand All @@ -570,12 +561,12 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
private static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
{
bool? strict =
aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) &&
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
strictObj is bool strictValue ?
strictValue : null;

// Perform transformations making the schema legal per OpenAI restrictions
JsonElement jsonSchema = SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction);
JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict);

// Map to an intermediate model so that redundant properties are skipped.
var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!;
Expand Down Expand Up @@ -622,7 +613,7 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) =>
ChatMessageRole.User => ChatRole.User,
ChatMessageRole.Assistant => ChatRole.Assistant,
ChatMessageRole.Tool => ChatRole.Tool,
ChatMessageRole.Developer => OpenAIResponseChatClient.ChatRoleDeveloper,
ChatMessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper,
_ => new ChatRole(role.ToString()),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using OpenAI;
using OpenAI.Assistants;
using OpenAI.Audio;
Expand All @@ -14,6 +16,26 @@ namespace Microsoft.Extensions.AI;
/// <summary>Provides extension methods for working with <see cref="OpenAIClient"/>s.</summary>
public static class OpenAIClientExtensions
{
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
internal const string StrictKey = "strictJsonSchema";

/// <summary>Gets the default OpenAI endpoint.</summary>
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");

/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");

/// <summary>
/// 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.
/// </summary>
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
{
DisallowAdditionalProperties = true,
ConvertBooleanSchemas = true,
MoveDefaultKeywordToDescription = true,
RequireAllProperties = true,
});

/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatClient"/>.</summary>
/// <param name="chatClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ChatClient"/>.</returns>
Expand Down Expand Up @@ -52,4 +74,10 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that can be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
public static IEmbeddingGenerator<string, Embedding<float>> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) =>
new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions);

/// <summary>Gets the JSON schema to use from the function.</summary>
internal static JsonElement GetSchema(AIFunction function, bool? strict) =>
strict is true ?
StrictSchemaTransformCache.GetOrCreateTransformedSchema(function) :
function.JsonSchema;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
using static Microsoft.Extensions.AI.OpenAIChatClient;

#pragma warning disable S907 // "goto" statement should not be used
#pragma warning disable S1067 // Expressions should not be too complex
Expand All @@ -26,12 +25,6 @@ namespace Microsoft.Extensions.AI;
/// <summary>Represents an <see cref="IChatClient"/> for an <see cref="OpenAIResponseClient"/>.</summary>
internal sealed partial class OpenAIResponseChatClient : IChatClient
{
/// <summary>Gets the default OpenAI endpoint.</summary>
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");

/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");

/// <summary>Metadata about the client.</summary>
private readonly ChatClientMetadata _metadata;

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

Expand Down Expand Up @@ -336,7 +329,7 @@ private static ChatRole ToChatRole(MessageRole? role) =>
role switch
{
MessageRole.System => ChatRole.System,
MessageRole.Developer => ChatRoleDeveloper,
MessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper,
MessageRole.User => ChatRole.User,
_ => ChatRole.Assistant,
};
Expand Down Expand Up @@ -380,10 +373,17 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
{
switch (tool)
{
case AIFunction af:
var oaitool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(af), ResponseClientJsonContext.Default.ResponseToolJson)!;
case AIFunction aiFunction:
bool strict =
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
strictObj is bool strictValue &&
strictValue;

JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict);

var oaitool = JsonSerializer.Deserialize(jsonSchema, ResponseClientJsonContext.Default.ResponseToolJson)!;
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(oaitool, ResponseClientJsonContext.Default.ResponseToolJson));
result.Tools.Add(ResponseTool.CreateFunctionTool(af.Name, af.Description, functionParameters));
result.Tools.Add(ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict));
break;

case HostedWebSearchTool:
Expand Down Expand Up @@ -440,7 +440,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
{
result.TextOptions = new()
{
TextFormat = SchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
TextFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
ResponseTextFormat.CreateJsonSchemaFormat(
jsonFormat.SchemaName ?? "json_schema",
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ResponseClientJsonContext.Default.JsonElement)),
Expand All @@ -460,7 +460,7 @@ private static IEnumerable<ResponseItem> ToOpenAIResponseItems(
foreach (ChatMessage input in inputs)
{
if (input.Role == ChatRole.System ||
input.Role == ChatRoleDeveloper)
input.Role == OpenAIClientExtensions.ChatRoleDeveloper)
{
string text = input.Text;
if (!string.IsNullOrWhiteSpace(text))
Expand Down
Loading
Loading