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 67ddfcbc8d7..8ab0152c941 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -42,30 +42,18 @@ public static partial class AIJsonUtilities [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled")] private static JsonSerializerOptions CreateDefaultOptions() { - // If reflection-based serialization is enabled by default, use it, as it's the most permissive in terms of what it can serialize, - // and we want to be flexible in terms of what can be put into the various collections in the object model. - // Otherwise, use the source-generated options to enable trimming and Native AOT. - JsonSerializerOptions options; + // Copy configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; if (JsonSerializer.IsReflectionEnabledByDefault) { - // Keep in sync with the JsonSourceGenerationOptions attribute on JsonContext below. - options = new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - Converters = { new JsonStringEnumConverter() }, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - WriteIndented = true, - }; - } - else - { - options = new(JsonContext.Default.Options) - { - // Compile-time encoder setting not yet available - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - }; + // If reflection-based serialization is enabled by default, use it as a fallback for all other types. + // Also turn on string-based enum serialization for all unknown enums. + options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver()); + options.Converters.Add(new JsonStringEnumConverter()); } options.MakeReadOnly(); @@ -83,6 +71,8 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(ChatMessage[]))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] [JsonSerializable(typeof(ChatClientMetadata))] @@ -95,14 +85,24 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] + [JsonSerializable(typeof(JsonObject))] + [JsonSerializable(typeof(JsonValue))] + [JsonSerializable(typeof(JsonArray))] [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(char))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(short))] [JsonSerializable(typeof(long))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(ulong))] [JsonSerializable(typeof(float))] [JsonSerializable(typeof(double))] + [JsonSerializable(typeof(decimal))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(TimeSpan))] + [JsonSerializable(typeof(DateTime))] [JsonSerializable(typeof(DateTimeOffset))] [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 3d3d7a1af5d..6537f3aa3ab 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -487,9 +487,7 @@ static bool IsAsyncMethod(MethodInfo method) Throw.ArgumentException(nameof(parameter), "Parameter is missing a name."); } - // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. Type parameterType = parameter.ParameterType; - JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType); // For CancellationToken parameters, we always bind to the token passed directly to InvokeAsync. if (parameterType == typeof(CancellationToken)) @@ -530,6 +528,8 @@ static bool IsAsyncMethod(MethodInfo method) } // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. + // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType); return (arguments, _) => { // If the parameter has an argument specified in the dictionary, return that argument. @@ -636,14 +636,22 @@ static bool IsAsyncMethod(MethodInfo method) if (returnType.GetGenericTypeDefinition() == typeof(Task<>)) { MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); + if (marshalResult is not null) + { + return async (taskObj, cancellationToken) => + { + await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(false); + object? result = ReflectionInvoke(taskResultGetter, taskObj, null); + return await marshalResult(result, taskResultGetter.ReturnType, cancellationToken).ConfigureAwait(false); + }; + } + returnTypeInfo = serializerOptions.GetTypeInfo(taskResultGetter.ReturnType); return async (taskObj, cancellationToken) => { await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(false); object? result = ReflectionInvoke(taskResultGetter, taskObj, null); - return marshalResult is not null ? - await marshalResult(result, returnTypeInfo.Type, cancellationToken).ConfigureAwait(false) : - await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); }; } @@ -652,24 +660,37 @@ await marshalResult(result, returnTypeInfo.Type, cancellationToken).ConfigureAwa { MethodInfo valueTaskAsTask = GetMethodFromGenericMethodDefinition(returnType, _valueTaskAsTask); MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); + + if (marshalResult is not null) + { + return async (taskObj, cancellationToken) => + { + var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; + await task.ConfigureAwait(false); + object? result = ReflectionInvoke(asTaskResultGetter, task, null); + return await marshalResult(result, asTaskResultGetter.ReturnType, cancellationToken).ConfigureAwait(false); + }; + } + returnTypeInfo = serializerOptions.GetTypeInfo(asTaskResultGetter.ReturnType); return async (taskObj, cancellationToken) => { var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; await task.ConfigureAwait(false); object? result = ReflectionInvoke(asTaskResultGetter, task, null); - return marshalResult is not null ? - await marshalResult(result, returnTypeInfo.Type, cancellationToken).ConfigureAwait(false) : - await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); + return await SerializeResultAsync(result, returnTypeInfo, cancellationToken).ConfigureAwait(false); }; } } // For everything else, just serialize the result as-is. + if (marshalResult is not null) + { + return (result, cancellationToken) => marshalResult(result, returnType, cancellationToken); + } + returnTypeInfo = serializerOptions.GetTypeInfo(returnType); - return marshalResult is not null ? - (result, cancellationToken) => marshalResult(result, returnTypeInfo.Type, cancellationToken) : - (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); + return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); static async ValueTask SerializeResultAsync(object? result, JsonTypeInfo returnTypeInfo, CancellationToken cancellationToken) { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index b9b5aae0d35..72985108c6e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -55,12 +55,12 @@ public static void EqualFunctionCallResults(object? expected, object? actual, Js private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) { - options ??= JsonSerializerOptions.Default; + options ??= AIJsonUtilities.DefaultOptions; JsonElement expectedElement = NormalizeToElement(expected, options); JsonElement actualElement = NormalizeToElement(actual, options); if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedElement), - JsonSerializer.SerializeToNode(actualElement))) + JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) { string message = propertyName is null ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs index 7d1fb1fede8..c65bef12fc8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -71,7 +71,7 @@ public void Serialization_JsonRoundtrips() public void Serialization_ForJsonSchemaRoundtrips() { string json = JsonSerializer.Serialize( - ChatResponseFormat.ForJsonSchema(JsonSerializer.Deserialize("[1,2,3]"), "name", "description"), + ChatResponseFormat.ForJsonSchema(JsonSerializer.Deserialize("[1,2,3]", AIJsonUtilities.DefaultOptions), "name", "description"), TestJsonSerializerContext.Default.ChatResponseFormat); Assert.Equal("""{"$type":"json","schema":[1,2,3],"schemaName":"name","schemaDescription":"description"}""", json); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ErrorContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ErrorContentTests.cs index 8744636b6ec..db2ea302b5c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ErrorContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/ErrorContentTests.cs @@ -51,7 +51,7 @@ public void JsonSerialization_ShouldSerializeAndDeserializeCorrectly() ErrorCode = "ERR001", Details = "Something went wrong" }; - var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; // Act var json = JsonSerializer.Serialize(errorContent, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs index 76750852797..85dd68f42c2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs @@ -262,7 +262,7 @@ public static void CreateFromParsedArguments_ObjectJsonInput_ReturnsElementArgum """{"Key1":{}, "Key2":null, "Key3" : [], "Key4" : 42, "Key5" : true }""", "callId", "functionName", - argumentParser: static json => JsonSerializer.Deserialize>(json)); + argumentParser: static json => JsonSerializer.Deserialize>(json, AIJsonUtilities.DefaultOptions)); Assert.NotNull(content); Assert.Null(content.Exception); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index bc20d761cb8..0e608d0d953 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -10,6 +10,10 @@ true + + false + + true true 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 26ae69aae29..b972454c4a4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -17,7 +17,7 @@ namespace Microsoft.Extensions.AI; -public static class AIJsonUtilitiesTests +public static partial class AIJsonUtilitiesTests { [Fact] public static void DefaultOptions_HasExpectedConfiguration() @@ -53,6 +53,18 @@ public static void DefaultOptions_UsesExpectedEscaping(string input, string expe Assert.Equal($@"""{expectedJsonString}""", json); } + [Fact] + public static void DefaultOptions_UsesReflectionWhenDefault() + { + // Reflection is only turned off in .NET Core test environments. + bool isDotnetCore = Type.GetType("System.Half") is not null; + var options = AIJsonUtilities.DefaultOptions; + Type anonType = new { Name = 42 }.GetType(); + + Assert.Equal(!isDotnetCore, JsonSerializer.IsReflectionEnabledByDefault); + Assert.Equal(JsonSerializer.IsReflectionEnabledByDefault, AIJsonUtilities.DefaultOptions.TryGetTypeInfo(anonType, out _)); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -145,7 +157,7 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem } """).RootElement; - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default); + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonContext.Default.Options); Assert.True(DeepEquals(expected, actual)); } @@ -189,7 +201,7 @@ public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSc description: "alternative description", hasDefaultValue: true, defaultValue: null, - serializerOptions: JsonSerializerOptions.Default, + serializerOptions: JsonContext.Default.Options, inferenceOptions: inferenceOptions); Assert.True(DeepEquals(expected, actual)); @@ -235,7 +247,7 @@ public static void CreateJsonSchema_UserDefinedTransformer() } }; - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default, inferenceOptions: inferenceOptions); + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonContext.Default.Options, inferenceOptions: inferenceOptions); Assert.True(DeepEquals(expected, actual)); } @@ -263,7 +275,7 @@ public static void CreateJsonSchema_FiltersDisallowedKeywords() } """).RootElement; - JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonSerializerOptions.Default); + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonContext.Default.Options); Assert.True(DeepEquals(expected, actual)); } @@ -283,7 +295,7 @@ public class PocoWithTypesWithOpenAIUnsupportedKeywords [Fact] public static void CreateFunctionJsonSchema_ReturnsExpectedValue() { - JsonSerializerOptions options = new(JsonSerializerOptions.Default); + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions); AIFunction func = AIFunctionFactory.Create((int x, int y) => x + y, serializerOptions: options); Assert.NotNull(func.UnderlyingMethod); @@ -295,7 +307,7 @@ public static void CreateFunctionJsonSchema_ReturnsExpectedValue() [Fact] public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWithAllowReadingFromString() { - JsonSerializerOptions options = new(JsonSerializerOptions.Default) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; AIFunction func = AIFunctionFactory.Create((int a, int? b, long c, short d, float e, double f, decimal g) => { }, serializerOptions: options); JsonElement schemaParameters = func.JsonSchema.GetProperty("properties"); @@ -376,7 +388,11 @@ public static void CreateJsonSchema_ValidateWithTestData(ITestData testData) [Fact] public static void AddAIContentType_DerivedAIContent() { - JsonSerializerOptions options = new(); + JsonSerializerOptions options = new() + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + }; + options.AddAIContentType("derivativeContent"); AIContent c = new DerivedAIContent { DerivedValue = 42 }; @@ -465,7 +481,7 @@ public static void CreateFunctionJsonSchema_InvokesIncludeParameterCallbackForEv { names.Add(p.Name); return p.Name is "first" or "fifth"; - } + }, }); Assert.Equal(["first", "second", "third", "fifth"], names); @@ -483,14 +499,19 @@ private class DerivedAIContent : AIContent public int DerivedValue { get; set; } } + [JsonSerializable(typeof(DerivedAIContent))] + [JsonSerializable(typeof(MyPoco))] + [JsonSerializable(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords))] + private partial class JsonContext : JsonSerializerContext; + private static bool DeepEquals(JsonElement element1, JsonElement element2) { #if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); #else return JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1), - JsonSerializer.SerializeToNode(element2)); + JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); #endif } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 4477c3cdb26..557eecc3c29 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -5,18 +5,22 @@ using System.Collections.Generic; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Xunit; +#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable S103 // Lines should not be too long + namespace Microsoft.Extensions.AI; -public class ChatClientStructuredOutputExtensionsTests +public partial class ChatClientStructuredOutputExtensionsTests { [Fact] public async Task SuccessUsage_Default() { var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))) + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Animal))) { ResponseId = "test", CreatedAt = DateTimeOffset.UtcNow, @@ -74,7 +78,7 @@ public async Task SuccessUsage_Default() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); // The response contains the deserialized result and other response properties Assert.Equal(1, response.Result.Id); @@ -98,7 +102,7 @@ public async Task SuccessUsage_Default() public async Task SuccessUsage_NoJsonSchema() { var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))) + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options))) { ResponseId = "test", CreatedAt = DateTimeOffset.UtcNow, @@ -135,7 +139,7 @@ public async Task SuccessUsage_NoJsonSchema() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory, useJsonSchema: false); + var response = await client.GetResponseAsync(chatHistory, useJsonSchema: false, serializerOptions: JsonContext2.Default.Options); // The response contains the deserialized result and other response properties Assert.Equal(1, response.Result.Id); @@ -158,8 +162,8 @@ public async Task SuccessUsage_NoJsonSchema() [Fact] public async Task WrapsNonObjectValuesInDataProperty() { - var expectedResult = new { data = 123 }; - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))); + var expectedResult = new Envelope { data = 123 }; + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options))); using var client = new TestChatClient { @@ -200,7 +204,7 @@ public async Task FailureUsage_InvalidJson() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); var ex = Assert.Throws(() => response.Result); Assert.Contains("invalid", ex.Message); @@ -219,7 +223,7 @@ public async Task FailureUsage_NullJson() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); var ex = Assert.Throws(() => response.Result); Assert.Equal("The deserialized response is null.", ex.Message); @@ -238,7 +242,7 @@ public async Task FailureUsage_NoJsonInResponse() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); var ex = Assert.Throws(() => response.Result); Assert.Equal("The response did not contain JSON to be deserialized.", ex.Message); @@ -251,7 +255,7 @@ public async Task FailureUsage_NoJsonInResponse() public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() { var expectedResult = new Data { Value = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } }; - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))); + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options))); using var client = new TestChatClient { @@ -266,7 +270,7 @@ public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync>(chatHistory); + var response = await client.GetResponseAsync>(chatHistory, serializerOptions: JsonContext2.Default.Options); // The response contains the deserialized result and other response properties Assert.Equal(1, response.Result!.Value!.Id); @@ -285,8 +289,8 @@ public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() public async Task CanUseNativeStructuredOutputWithArray() { var expectedResult = new[] { new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } }; - var payload = new { data = expectedResult }; - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(payload))); + var payload = new Envelope { data = expectedResult }; + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(payload, JsonContext2.Default.Options))); using var client = new TestChatClient { @@ -294,7 +298,7 @@ public async Task CanUseNativeStructuredOutputWithArray() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); // The response contains the deserialized result and other response properties Assert.Single(response.Result!); @@ -312,9 +316,10 @@ public async Task CanUseNativeStructuredOutputWithArray() [Fact] public async Task CanSpecifyCustomJsonSerializationOptions() { - var jso = new JsonSerializerOptions + var jso = new JsonSerializerOptions(JsonContext2.Default.Options) { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = { new JsonNumberEnumConverter() }, }; var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, jso))); @@ -377,7 +382,7 @@ public async Task HandlesBackendReturningMultipleObjects() // Fortunately we can work around this without breaking any cases of valid output. var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var resultDuplicatedJson = JsonSerializer.Serialize(expectedResult) + Environment.NewLine + JsonSerializer.Serialize(expectedResult); + var resultDuplicatedJson = JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options) + Environment.NewLine + JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options); using var client = new TestChatClient { @@ -388,7 +393,7 @@ public async Task HandlesBackendReturningMultipleObjects() }; var chatHistory = new List { new(ChatRole.User, "Hello") }; - var response = await client.GetResponseAsync(chatHistory); + var response = await client.GetResponseAsync(chatHistory, serializerOptions: JsonContext2.Default.Options); // The response contains the deserialized result and other response properties Assert.Equal(1, response.Result.Id); @@ -415,4 +420,16 @@ private enum Species Tiger, Walrus, } + + private class Envelope + { + public T? data { get; set; } + } + + [JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(Animal))] + [JsonSerializable(typeof(Envelope))] + [JsonSerializable(typeof(Envelope))] + [JsonSerializable(typeof(Data))] + private partial class JsonContext2 : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index fc78ac3bd70..9501b4afe7d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -18,7 +19,7 @@ namespace Microsoft.Extensions.AI; -public class AIFunctionFactoryTest +public partial class AIFunctionFactoryTest { [Fact] public void InvalidArguments_Throw() @@ -112,8 +113,8 @@ public async Task Returns_AsyncReturnTypesSupported_Async() AssertExtensions.EqualFunctionCallResults(null, await func.InvokeAsync(new() { ["a"] = 1, ["b"] = 2L })); Assert.Equal(3, result); - func = AIFunctionFactory.Create((int count) => SimpleIAsyncEnumerable(count)); - AssertExtensions.EqualFunctionCallResults(new int[] { 0, 1, 2, 3, 4 }, await func.InvokeAsync(new() { ["count"] = 5 })); + func = AIFunctionFactory.Create((int count) => SimpleIAsyncEnumerable(count), serializerOptions: JsonContext.Default.Options); + AssertExtensions.EqualFunctionCallResults(new int[] { 0, 1, 2, 3, 4 }, await func.InvokeAsync(new() { ["count"] = 5 }), JsonContext.Default.Options); static async IAsyncEnumerable SimpleIAsyncEnumerable(int count) { @@ -124,7 +125,7 @@ static async IAsyncEnumerable SimpleIAsyncEnumerable(int count) } } - func = AIFunctionFactory.Create(() => (IAsyncEnumerable)new ThrowingAsyncEnumerable()); + func = AIFunctionFactory.Create(() => (IAsyncEnumerable)new ThrowingAsyncEnumerable(), serializerOptions: JsonContext.Default.Options); await Assert.ThrowsAsync(() => func.InvokeAsync().AsTask()); } @@ -796,4 +797,8 @@ private sealed class MyArgumentType; private class A; private class B : A; private sealed class C : B; + + [JsonSerializable(typeof(IAsyncEnumerable))] + [JsonSerializable(typeof(int[]))] + private partial class JsonContext : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index 9b8967a37ce..e4f17abb179 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -10,6 +10,10 @@ true + + false + + true diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 6b0e97bfe0d..7cfd0ce45be 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -106,7 +106,7 @@ public static IEnumerable GetTestDataCore() yield return new TestData(JsonNode.Parse("""[{ "x" : 42 }]"""), "true"); yield return new TestData((JsonValue)42, "true"); yield return new TestData(new() { ["x"] = 42 }, """{"type":["object","null"]}"""); - yield return new TestData([1, 2, 3], """{"type":["array","null"]}"""); + yield return new TestData([(JsonNode)1, (JsonNode)2, (JsonNode)3], """{"type":["array","null"]}"""); // Enum types yield return new TestData(IntEnum.A, """{"type":"integer"}""");