diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index a0e240be991..8c23406cc8a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -21,7 +21,9 @@ namespace Microsoft.Extensions.AI; // These should be added in once they're no longer [Experimental]. If they're included while still // experimental, any JsonSerializerContext that includes AIContent will incur errors about using -// experimental types in its source generated files. +// experimental types in its source generated files. When [Experimental] is removed from these types, +// these lines should be uncommented and the corresponding lines in AIJsonUtilities.CreateDefaultOptions +// as well as the [JsonSerializable] attributes for them on the JsonContext should be removed. // [JsonDerivedType(typeof(FunctionApprovalRequestContent), typeDiscriminator: "functionApprovalRequest")] // [JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] // [JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] 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 4b8a4fb1576..721a08418bf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -48,6 +48,16 @@ private static JsonSerializerOptions CreateDefaultOptions() Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + // Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet, + // or else consuming assemblies that used source generation with AIContent would implicitly reference them. + // Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed. + AddAIContentType(options, typeof(FunctionApprovalRequestContent), typeDiscriminatorId: "functionApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(FunctionApprovalResponseContent), typeDiscriminatorId: "functionApprovalResponse", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolCallContent), typeDiscriminatorId: "mcpServerToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolResultContent), typeDiscriminatorId: "mcpServerToolResult", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalRequestContent), typeDiscriminatorId: "mcpServerToolApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalResponseContent), typeDiscriminatorId: "mcpServerToolApprovalResponse", checkBuiltIn: false); + if (JsonSerializer.IsReflectionEnabledByDefault) { // If reflection-based serialization is enabled by default, use it as a fallback for all other types. @@ -116,6 +126,16 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(AIContent))] [JsonSerializable(typeof(AIFunctionArguments))] + + // Temporary workaround: + // These should be added in once they're no longer [Experimental] and included via [JsonDerivedType] on AIContent. + [JsonSerializable(typeof(FunctionApprovalRequestContent))] + [JsonSerializable(typeof(FunctionApprovalResponseContent))] + [JsonSerializable(typeof(McpServerToolCallContent))] + [JsonSerializable(typeof(McpServerToolResultContent))] + [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] + [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] + [EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead. private sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs index ab66bf61317..b69d0fb2aab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs @@ -36,7 +36,7 @@ public static void AddAIContentType(this JsonSerializerOptions options _ = Throw.IfNull(options); _ = Throw.IfNull(typeDiscriminatorId); - AddAIContentTypeCore(options, typeof(TContent), typeDiscriminatorId); + AddAIContentType(options, typeof(TContent), typeDiscriminatorId, checkBuiltIn: true); } /// @@ -56,10 +56,10 @@ public static void AddAIContentType(this JsonSerializerOptions options, Type con if (!typeof(AIContent).IsAssignableFrom(contentType)) { - Throw.ArgumentException(nameof(contentType), "The content type must derive from AIContent."); + Throw.ArgumentException(nameof(contentType), $"The content type must derive from {nameof(AIContent)}."); } - AddAIContentTypeCore(options, contentType, typeDiscriminatorId); + AddAIContentType(options, contentType, typeDiscriminatorId, checkBuiltIn: true); } /// Serializes the supplied values and computes a string hash of the resulting JSON. @@ -186,11 +186,11 @@ static void NormalizeJsonNode(JsonNode? node) } } - private static void AddAIContentTypeCore(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId) + private static void AddAIContentType(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId, bool checkBuiltIn) { - if (contentType.Assembly == typeof(AIContent).Assembly) + if (checkBuiltIn && (contentType.Assembly == typeof(AIContent).Assembly)) { - Throw.ArgumentException(nameof(contentType), "Cannot register built-in AI content types."); + Throw.ArgumentException(nameof(contentType), $"Cannot register built-in {nameof(AIContent)} types."); } IJsonTypeInfoResolver resolver = options.TypeInfoResolver ?? DefaultOptions.TypeInfoResolver!; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs index 64a20fc5e4a..e5734ccd7cf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Text.Json; using Xunit; @@ -53,4 +54,40 @@ public void Serialization_Roundtrips() Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + ChatMessage message = new(ChatRole.User, + [ + new TextContent("a"), + new TextReasoningContent("reasoning text"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new UriContent("http://example.com", "application/json"), + new ErrorContent("error message"), + new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } }), + new FunctionResultContent("call123", "result data"), + new HostedFileContent("file123"), + new HostedVectorStoreContent("vectorStore123"), + new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }), + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new FunctionApprovalResponseContent("request123", approved: true, new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new McpServerToolCallContent("call123", "myTool", "myServer"), + new McpServerToolResultContent("call123"), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + new McpServerToolApprovalResponseContent("request123", approved: true) + ]); + + var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); + ChatMessage? deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + + Assert.Equal(message.Role, deserialized.Role); + Assert.Equal(message.Contents.Count, deserialized.Contents.Count); + for (int i = 0; i < message.Contents.Count; i++) + { + Assert.NotNull(message.Contents[i]); + Assert.Equal(message.Contents[i].GetType(), deserialized.Contents[i].GetType()); + } + } }