diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index 8e3269f7a9c..8c53938f481 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -38,22 +38,33 @@ public sealed record class AIJsonSchemaCreateOptions public Func? IncludeParameter { get; init; } /// - /// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums. + /// Gets a governing transformations on the JSON schema after it has been generated. /// + public AIJsonSchemaTransformOptions? TransformOptions { get; init; } + + /// + /// Gets a value indicating whether to include the type keyword in created schemas for .NET enums. + /// + [Obsolete("This property has been deprecated.")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public bool IncludeTypeInEnumSchemas { get; init; } = true; /// /// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects. /// - public bool DisallowAdditionalProperties { get; init; } = true; + [Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public bool DisallowAdditionalProperties { get; init; } /// - /// Gets a value indicating whether to include the $schema keyword in inferred schemas. + /// Gets a value indicating whether to include the $schema keyword in created schemas. /// public bool IncludeSchemaKeyword { get; init; } /// /// Gets a value indicating whether to mark all properties as required in the schema. /// + [Obsolete("This property has been deprecated. Use the equivalent property in TransformOptions instead.")] + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] public bool RequireAllProperties { get; init; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs index c7a035cbbed..46e7476afcf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs @@ -38,6 +38,11 @@ public sealed record class AIJsonSchemaTransformOptions /// public bool UseNullableKeyword { get; init; } + /// + /// Gets a value indicating whether to move the default keyword to the description field in the schema. + /// + public bool MoveDefaultKeywordToDescription { get; init; } + /// /// Gets the default options instance. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 8ab0152c941..33531661813 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -116,4 +116,11 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(AIFunctionArguments))] [EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead. private sealed partial class JsonContext : JsonSerializerContext; + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] + [JsonSerializable(typeof(JsonNode))] + private sealed partial class JsonContextNoIndentation : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index fe17a2ad449..a44836d8e96 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -5,7 +5,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; @@ -40,7 +39,7 @@ public static partial class AIJsonUtilities private const string DefaultPropertyName = "default"; private const string RefPropertyName = "$ref"; - /// The uri used when populating the $schema keyword in inferred schemas. + /// The uri used when populating the $schema keyword in created schemas. private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema"; // List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors. @@ -54,7 +53,7 @@ public static partial class AIJsonUtilities /// The title keyword used by the method schema. /// The description keyword used by the method schema. /// The options used to extract the schema from the specified type. - /// The options controlling schema inference. + /// The options controlling schema creation. /// A JSON schema document encoded as a . /// is . public static JsonElement CreateFunctionJsonSchema( @@ -106,13 +105,13 @@ public static JsonElement CreateFunctionJsonSchema( inferenceOptions); parameterSchemas.Add(parameter.Name, parameterSchema); - if (!parameter.IsOptional || inferenceOptions.RequireAllProperties) + if (!parameter.IsOptional) { (requiredProperties ??= []).Add((JsonNode)parameter.Name); } } - JsonObject schema = new(); + JsonNode schema = new JsonObject(); if (inferenceOptions.IncludeSchemaKeyword) { schema[SchemaPropertyName] = SchemaKeywordUri; @@ -136,7 +135,13 @@ public static JsonElement CreateFunctionJsonSchema( schema[RequiredPropertyName] = requiredProperties; } - return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode); + // Finally, apply any schema transformations if specified. + if (inferenceOptions.TransformOptions is { } options) + { + schema = TransformSchema(schema, options); + } + + return JsonSerializer.SerializeToElement(schema, JsonContextNoIndentation.Default.JsonNode); } /// Creates a JSON schema for the specified type. @@ -145,7 +150,7 @@ public static JsonElement CreateFunctionJsonSchema( /// if the parameter is optional; otherwise, . /// The default value of the optional parameter, if applicable. /// The options used to extract the schema from the specified type. - /// The options controlling schema inference. + /// The options controlling schema creation. /// A representing the schema. public static JsonElement CreateJsonSchema( Type? type, @@ -158,7 +163,14 @@ public static JsonElement CreateJsonSchema( serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); - return JsonSerializer.SerializeToElement(schema, JsonContext.Default.JsonNode); + + // Finally, apply any schema transformations if specified. + if (inferenceOptions.TransformOptions is { } options) + { + schema = TransformSchema(schema, options); + } + + return JsonSerializer.SerializeToElement(schema, JsonContextNoIndentation.Default.JsonNode); } /// Gets the default JSON schema to be used by types or functions. @@ -203,25 +215,11 @@ private static JsonNode CreateJsonSchemaCore( if (hasDefaultValue) { - if (inferenceOptions.RequireAllProperties) - { - // Default values are only used in the context of optional parameters. - // Do not include a default keyword (since certain AI vendors don't support it) - // and instead embed its JSON in the description as a hint to the LLM. - string defaultValueJson = defaultValue is not null - ? JsonSerializer.Serialize(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType())) - : "null"; - - description = CreateDescriptionWithDefaultValue(description, defaultValueJson); - } - else - { - JsonNode? defaultValueNode = defaultValue is not null - ? JsonSerializer.SerializeToNode(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType())) - : null; + JsonNode? defaultValueNode = defaultValue is not null + ? JsonSerializer.SerializeToNode(defaultValue, serializerOptions.GetTypeInfo(defaultValue.GetType())) + : null; - (schemaObj ??= [])[DefaultPropertyName] = defaultValueNode; - } + (schemaObj ??= [])[DefaultPropertyName] = defaultValueNode; } if (description is not null) @@ -271,41 +269,11 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js } // Include the type keyword in enum types - if (inferenceOptions.IncludeTypeInEnumSchemas && ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + if (ctx.TypeInfo.Type.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) { objSchema.InsertAtStart(TypePropertyName, "string"); } - // Disallow additional properties in object schemas - if (inferenceOptions.DisallowAdditionalProperties && - objSchema.ContainsKey(PropertiesPropertyName) && - !objSchema.ContainsKey(AdditionalPropertiesPropertyName)) - { - objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false); - } - - // Mark all properties as required - if (inferenceOptions.RequireAllProperties && - objSchema.TryGetPropertyValue(PropertiesPropertyName, out JsonNode? properties) && - properties is JsonObject propertiesObj) - { - _ = objSchema.TryGetPropertyValue(RequiredPropertyName, out JsonNode? required); - if (required is not JsonArray { } requiredArray || requiredArray.Count != propertiesObj.Count) - { - requiredArray = [.. propertiesObj.Select(prop => (JsonNode)prop.Key)]; - objSchema[RequiredPropertyName] = requiredArray; - } - } - - // Strip default keywords and embed in description where required - if (inferenceOptions.RequireAllProperties && - objSchema.TryGetPropertyValue(DefaultPropertyName, out JsonNode? defaultValue)) - { - _ = objSchema.Remove(DefaultPropertyName); - string defaultValueJson = defaultValue?.ToJsonString() ?? "null"; - localDescription = CreateDescriptionWithDefaultValue(localDescription, defaultValueJson); - } - // Filter potentially disallowed keywords. foreach (string keyword in _schemaKeywordsDisallowedByAIVendors) { @@ -328,20 +296,8 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js if (ctx.Path.IsEmpty && hasDefaultValue) { - // Add root-level default value metadata - if (inferenceOptions.RequireAllProperties) - { - // Default values are only used in the context of optional parameters. - // Do not include a default keyword (since certain AI vendors don't support it) - // and instead embed its JSON in the description as a hint to the LLM. - string defaultValueJson = JsonSerializer.Serialize(defaultValue, ctx.TypeInfo); - localDescription = CreateDescriptionWithDefaultValue(localDescription, defaultValueJson); - } - else - { - JsonNode? defaultValueNode = JsonSerializer.SerializeToNode(defaultValue, ctx.TypeInfo); - ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValueNode; - } + JsonNode? defaultValueNode = JsonSerializer.SerializeToNode(defaultValue, ctx.TypeInfo); + ConvertSchemaToObject(ref schema)[DefaultPropertyName] = defaultValueNode; } if (localDescription is not null) @@ -423,7 +379,7 @@ private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNo jsonObject.Insert(0, key, value); #else jsonObject.Remove(key); - var copiedEntries = jsonObject.ToArray(); + var copiedEntries = System.Linq.Enumerable.ToArray(jsonObject); jsonObject.Clear(); jsonObject.Add(key, value); @@ -434,13 +390,6 @@ private static void InsertAtStart(this JsonObject jsonObject, string key, JsonNo #endif } - private static string CreateDescriptionWithDefaultValue(string? existingDescription, string defaultValueJson) - { - return existingDescription is null - ? $"Default value: {defaultValueJson}" - : $"{existingDescription} (Default value: {defaultValueJson})"; - } - private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) { Utf8JsonReader reader = new(utf8Json); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Transform.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Transform.cs index 5669a3fb264..865b4543abb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Transform.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Transform.cs @@ -30,9 +30,14 @@ public static JsonElement TransformSchema(JsonElement schema, AIJsonSchemaTransf } JsonNode? nodeSchema = JsonSerializer.SerializeToNode(schema, JsonContext.Default.JsonElement); + JsonNode transformedSchema = TransformSchema(nodeSchema, transformOptions); + return JsonSerializer.SerializeToElement(transformedSchema, JsonContextNoIndentation.Default.JsonNode); + } + + private static JsonNode TransformSchema(JsonNode? schema, AIJsonSchemaTransformOptions transformOptions) + { List? path = transformOptions.TransformSchemaNode is not null ? [] : null; - JsonNode transformedSchema = TransformSchemaCore(nodeSchema, transformOptions, path); - return JsonSerializer.Deserialize(transformedSchema, JsonContext.Default.JsonElement); + return TransformSchemaCore(schema, transformOptions, path); } private static JsonNode TransformSchemaCore(JsonNode? schema, AIJsonSchemaTransformOptions transformOptions, List? path) @@ -169,6 +174,18 @@ private static JsonNode TransformSchemaCore(JsonNode? schema, AIJsonSchemaTransf } } + if (transformOptions.MoveDefaultKeywordToDescription && + schemaObj.TryGetPropertyValue(DefaultPropertyName, out JsonNode? defaultSchema)) + { + string? description = schemaObj.TryGetPropertyValue(DescriptionPropertyName, out JsonNode? descriptionSchema) ? descriptionSchema?.GetValue() : null; + string defaultValueJson = JsonSerializer.Serialize(defaultSchema, JsonContextNoIndentation.Default.JsonNode!); + description = description is null + ? $"Default value: {defaultValueJson}" + : $"{description} (Default value: {defaultValueJson})"; + schemaObj[DescriptionPropertyName] = description; + _ = schemaObj.Remove(DefaultPropertyName); + } + break; default: diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index ff62845eb0e..6c0acb8ee23 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -29,7 +29,8 @@ internal sealed class AzureAIInferenceChatClient : IChatClient { RequireAllProperties = true, DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, }); /// Metadata about the client. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 98cf49fd696..001f4d1a593 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -30,7 +30,8 @@ internal sealed partial class OpenAIChatClient : IChatClient { RequireAllProperties = true, DisallowAdditionalProperties = true, - ConvertBooleanSchemas = true + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, }); /// Gets the default OpenAI endpoint. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index e35f8b87949..69c4cc7ee89 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -26,9 +26,12 @@ public static partial class ChatClientStructuredOutputExtensions private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() { IncludeSchemaKeyword = true, - DisallowAdditionalProperties = true, - IncludeTypeInEnumSchemas = true, - RequireAllProperties = true, + TransformOptions = new AIJsonSchemaTransformOptions + { + DisallowAdditionalProperties = true, + RequireAllProperties = true, + MoveDefaultKeywordToDescription = true, + }, }; /// Sends chat messages, requesting a response matching the type . diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 2d1967b11c1..0001b8b2125 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -15,6 +15,8 @@ using Microsoft.Extensions.AI.JsonSchemaExporter; using Xunit; +#pragma warning disable 0618 // Suppress obsolete warnings + namespace Microsoft.Extensions.AI; public static partial class AIJsonUtilitiesTests @@ -72,10 +74,11 @@ public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValu { AIJsonSchemaCreateOptions options = useSingleton ? AIJsonSchemaCreateOptions.Default : new AIJsonSchemaCreateOptions(); Assert.True(options.IncludeTypeInEnumSchemas); - Assert.True(options.DisallowAdditionalProperties); + Assert.False(options.DisallowAdditionalProperties); Assert.False(options.IncludeSchemaKeyword); Assert.False(options.RequireAllProperties); Assert.Null(options.TransformSchemaNode); + Assert.Null(options.TransformOptions); } [Fact] @@ -106,6 +109,12 @@ public static void AIJsonSchemaCreateOptions_UsesStructuralEquality() property.SetValue(options2, includeParameter); break; + case null when property.PropertyType == typeof(AIJsonSchemaTransformOptions): + AIJsonSchemaTransformOptions transformOptions = new AIJsonSchemaTransformOptions { RequireAllProperties = true }; + property.SetValue(options1, transformOptions); + property.SetValue(options2, transformOptions); + break; + default: Assert.Fail($"Unexpected property type: {property.PropertyType}"); break; @@ -152,8 +161,7 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem "default": "defaultValue" } }, - "required": ["Key", "EnumValue"], - "additionalProperties": false + "required": ["Key", "EnumValue"] } """).RootElement; @@ -176,6 +184,7 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc "type": "integer" }, "EnumValue": { + "type": "string", "enum": ["A", "B"] }, "Value": { @@ -183,16 +192,20 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc "type": ["string", "null"] } }, - "required": ["Key", "EnumValue", "Value"] + "required": ["Key", "EnumValue", "Value"], + "additionalProperties": false } """).RootElement; AIJsonSchemaCreateOptions inferenceOptions = new AIJsonSchemaCreateOptions { - IncludeTypeInEnumSchemas = false, - DisallowAdditionalProperties = false, IncludeSchemaKeyword = true, - RequireAllProperties = true, + TransformOptions = new() + { + DisallowAdditionalProperties = true, + RequireAllProperties = true, + MoveDefaultKeywordToDescription = true, + } }; JsonElement actual = AIJsonUtilities.CreateJsonSchema( @@ -227,8 +240,7 @@ public static void CreateJsonSchema_UserDefinedTransformer() "default": "defaultValue" } }, - "required": ["Key", "EnumValue"], - "additionalProperties": false + "required": ["Key", "EnumValue"] } """).RootElement; @@ -268,8 +280,7 @@ public static void CreateJsonSchema_FiltersDisallowedKeywords() "Char" : { "type": "string" } - }, - "additionalProperties": false + } } """).RootElement; @@ -341,6 +352,15 @@ public static void CreateFunctionJsonSchema_OptionalParameters(bool requireAllPr } """).RootElement; + AIJsonSchemaCreateOptions inferenceOptions = new() + { + TransformOptions = new() + { + RequireAllProperties = requireAllProperties, + MoveDefaultKeywordToDescription = requireAllProperties, + } + }; + AIFunction func = AIFunctionFactory.Create(( [Description("The city to get the weather for")] string city, [Description("The unit to calculate the current temperature to")] string unit = "celsius") => "sunny", @@ -348,7 +368,7 @@ public static void CreateFunctionJsonSchema_OptionalParameters(bool requireAllPr { Name = "get_weather", Description = "Gets the current weather for a current location", - JsonSchemaCreateOptions = new AIJsonSchemaCreateOptions { RequireAllProperties = requireAllProperties } + JsonSchemaCreateOptions = inferenceOptions }); Assert.NotNull(func.UnderlyingMethod); @@ -358,7 +378,7 @@ public static void CreateFunctionJsonSchema_OptionalParameters(bool requireAllPr func.UnderlyingMethod, title: func.Name, description: func.Description, - inferenceOptions: new AIJsonSchemaCreateOptions { RequireAllProperties = requireAllProperties }); + inferenceOptions: inferenceOptions); AssertDeepEquals(expected, resolvedSchema); } @@ -423,7 +443,7 @@ public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) JsonTypeInfo typeInfo = options.GetTypeInfo(testData.Type); AIJsonSchemaCreateOptions? createOptions = typeInfo.Properties.Any(prop => prop.IsExtensionData) - ? new() { DisallowAdditionalProperties = false } // Do not append additionalProperties: false to the schema if the type has extension data. + ? new() { TransformOptions = new() { DisallowAdditionalProperties = false } } // Do not append additionalProperties: false to the schema if the type has extension data. : null; JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions); @@ -706,6 +726,33 @@ public static void TransformJsonSchema_UseNullableKeyword() AssertDeepEquals(expectedSchema, transformedSchema); } + [Fact] + public static void TransformJsonSchema_MoveDefaultKeywordToDescription() + { + JsonElement schema = JsonDocument.Parse(""" + { + "description": "My awesome schema", + "type": "array", + "default": [1,2,3] + } + """).RootElement; + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "description": "My awesome schema (Default value: [1,2,3])", + "type": "array" + } + """).RootElement; + + AIJsonSchemaTransformOptions options = new() + { + MoveDefaultKeywordToDescription = true, + }; + + JsonElement transformedSchema = AIJsonUtilities.TransformSchema(schema, options); + AssertDeepEquals(expectedSchema, transformedSchema); + } + [Theory] [MemberData(nameof(TestTypes.GetTestDataUsingAllValues), MemberType = typeof(TestTypes))] public static void TransformJsonSchema_ValidateWithTestData(ITestData testData) @@ -718,7 +765,7 @@ public static void TransformJsonSchema_ValidateWithTestData(ITestData testData) JsonTypeInfo typeInfo = options.GetTypeInfo(testData.Type); AIJsonSchemaCreateOptions? createOptions = typeInfo.Properties.Any(prop => prop.IsExtensionData) - ? new() { DisallowAdditionalProperties = false } // Do not append additionalProperties: false to the schema if the type has extension data. + ? new() { TransformOptions = new() { DisallowAdditionalProperties = false } } // Do not append additionalProperties: false to the schema if the type has extension data. : null; JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index aae985c4c4b..edd22edc41e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -34,7 +34,8 @@ public async Task SuccessUsage_Default() GetResponseAsyncCallback = (messages, options, cancellationToken) => { var responseFormat = Assert.IsType(options!.ResponseFormat); - Assert.Equal(""" + Assert.NotNull(responseFormat.Schema); + AssertDeepEquals(JsonDocument.Parse(""" { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Some test description", @@ -65,7 +66,7 @@ public async Task SuccessUsage_Default() "species" ] } - """, responseFormat.Schema.ToString()); + """).RootElement, responseFormat.Schema.Value); Assert.Equal(nameof(Animal), responseFormat.SchemaName); Assert.Equal("Some test description", responseFormat.SchemaDescription); @@ -332,7 +333,8 @@ public async Task CanSpecifyCustomJsonSerializationOptions() // - The property is named full_name, because we specified SnakeCaseLower // - The species value is an integer instead of a string, because we didn't use enum-to-string conversion var responseFormat = Assert.IsType(options!.ResponseFormat); - Assert.Equal(""" + Assert.NotNull(responseFormat.Schema); + AssertDeepEquals(JsonDocument.Parse(""" { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Some test description", @@ -358,7 +360,7 @@ public async Task CanSpecifyCustomJsonSerializationOptions() "species" ] } - """, responseFormat.Schema.ToString()); + """).RootElement, responseFormat.Schema.Value); return Task.FromResult(expectedResponse); }, @@ -432,4 +434,28 @@ private class Envelope [JsonSerializable(typeof(Envelope))] [JsonSerializable(typeof(Data))] private partial class JsonContext2 : JsonSerializerContext; + + private static void AssertDeepEquals(JsonElement element1, JsonElement element2) + { +#pragma warning disable SA1118 // Parameter should not span multiple lines + Assert.True(DeepEquals(element1, element2), $""" + Elements are not equal. + Expected: + {element1} + Actual: + {element2} + """); +#pragma warning restore SA1118 // Parameter should not span multiple lines + } + + private static bool DeepEquals(JsonElement element1, JsonElement element2) + { +#if NET9_0_OR_GREATER + return JsonElement.DeepEquals(element1, element2); +#else + return System.Text.Json.Nodes.JsonNode.DeepEquals( + JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); +#endif + } }