diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index a5cf85aba1c..4bb67980439 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -2,6 +2,8 @@ ## NOT YET RELEASED +- Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type. + ## 9.9.0 - Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index ac59cfc263e..088fc533d05 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,19 +1,30 @@ // 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.ComponentModel; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +#pragma warning disable S2333 // gratuitous partial + /// Represents the response format that is desired by the caller. [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(ChatResponseFormatText), typeDiscriminator: "text")] [JsonDerivedType(typeof(ChatResponseFormatJson), typeDiscriminator: "json")] -#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable -public class ChatResponseFormat -#pragma warning restore CA1052 +public partial class ChatResponseFormat { + private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() + { + IncludeSchemaKeyword = true, + }; + /// Initializes a new instance of the class. /// Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it. private protected ChatResponseFormat() @@ -33,7 +44,61 @@ private protected ChatResponseFormat() /// The instance. public static ChatResponseFormatJson ForJsonSchema( JsonElement schema, string? schemaName = null, string? schemaDescription = null) => - new(schema, - schemaName, - schemaDescription); + new(schema, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The type for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + public static ChatResponseFormatJson ForJsonSchema( + JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => + ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + /// is . + public static ChatResponseFormatJson ForJsonSchema( + Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) + { + _ = Throw.IfNull(schemaType); + + var schema = AIJsonUtilities.CreateJsonSchema( + schemaType, + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + inferenceOptions: _inferenceOptions); + + return ForJsonSchema( + schema, + schemaName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), + schemaDescription ?? schemaType.GetCustomAttribute()?.Description); + } + + /// Regex that flags any character other than ASCII digits, ASCII letters, or underscore. +#if NET + [GeneratedRegex("[^0-9A-Za-z_]")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs index 112e846d41f..34aa665450b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -23,6 +23,7 @@ public class DelegatingChatClient : IChatClient /// Initializes a new instance of the class. /// /// The wrapped client instance. + /// is . protected DelegatingChatClient(IChatClient innerClient) { InnerClient = Throw.IfNull(innerClient); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index ca42fdacd20..034b5787eab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1156,6 +1156,14 @@ { "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonElement schema, string? schemaName = null, string? schemaDescription = null);", "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Type schemaType, System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" } ], "Properties": [ diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 69c4cc7ee89..09ec568d749 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -3,11 +3,9 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Reflection; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI; /// Request a response with structured output. public static partial class ChatClientStructuredOutputExtensions { - private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() - { - IncludeSchemaKeyword = true, - TransformOptions = new AIJsonSchemaTransformOptions - { - DisallowAdditionalProperties = true, - RequireAllProperties = true, - MoveDefaultKeywordToDescription = true, - }, - }; - /// Sends chat messages, requesting a response matching the type . /// The . /// The chat content to send. @@ -161,20 +148,12 @@ public static async Task> GetResponseAsync( serializerOptions.MakeReadOnly(); - var schemaElement = AIJsonUtilities.CreateJsonSchema( - type: typeof(T), - serializerOptions: serializerOptions, - inferenceOptions: _inferenceOptions); + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - bool isWrappedInObject; - JsonElement schema; - if (SchemaRepresentsObject(schemaElement)) - { - // For object-representing schemas, we can use them as-is - isWrappedInObject = false; - schema = schemaElement; - } - else + Debug.Assert(responseFormat.Schema is not null, "ForJsonSchema should always populate Schema"); + var schema = responseFormat.Schema!.Value; + bool isWrappedInObject = false; + if (!SchemaRepresentsObject(schema)) { // For non-object-representing schemas, we wrap them in an object schema, because all // the real LLM providers today require an object schema as the root. This is currently @@ -184,10 +163,11 @@ public static async Task> GetResponseAsync( { { "$schema", "https://json-schema.org/draft/2020-12/schema" }, { "type", "object" }, - { "properties", new JsonObject { { "data", JsonElementToJsonNode(schemaElement) } } }, + { "properties", new JsonObject { { "data", JsonElementToJsonNode(schema) } } }, { "additionalProperties", false }, { "required", new JsonArray("data") }, }, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject))); + responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription); } ChatMessage? promptAugmentation = null; @@ -200,10 +180,7 @@ public static async Task> GetResponseAsync( { // When using native structured output, we don't add any additional prompt, because // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. - options.ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema, - schemaName: SanitizeMemberName(typeof(T).Name), - schemaDescription: typeof(T).GetCustomAttribute()?.Description); + options.ResponseFormat = responseFormat; } else { @@ -213,7 +190,7 @@ public static async Task> GetResponseAsync( promptAugmentation = new ChatMessage(ChatRole.User, $$""" Respond with a JSON value conforming to the following schema: ``` - {{schema}} + {{responseFormat.Schema}} ``` """); @@ -222,53 +199,31 @@ public static async Task> GetResponseAsync( var result = await chatClient.GetResponseAsync(messages, options, cancellationToken); return new ChatResponse(result, serializerOptions) { IsWrappedInObject = isWrappedInObject }; - } - private static bool SchemaRepresentsObject(JsonElement schemaElement) - { - if (schemaElement.ValueKind is JsonValueKind.Object) + static bool SchemaRepresentsObject(JsonElement schemaElement) { - foreach (var property in schemaElement.EnumerateObject()) + if (schemaElement.ValueKind is JsonValueKind.Object) { - if (property.NameEquals("type"u8)) + foreach (var property in schemaElement.EnumerateObject()) { - return property.Value.ValueKind == JsonValueKind.String - && property.Value.ValueEquals("object"u8); + if (property.NameEquals("type"u8)) + { + return property.Value.ValueKind == JsonValueKind.String + && property.Value.ValueEquals("object"u8); + } } } - } - return false; - } + return false; + } - private static JsonNode? JsonElementToJsonNode(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.Null => null, - JsonValueKind.Array => JsonArray.Create(element), - JsonValueKind.Object => JsonObject.Create(element), - _ => JsonValue.Create(element) - }; + static JsonNode? JsonElementToJsonNode(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Array => JsonArray.Create(element), + JsonValueKind.Object => JsonObject.Create(element), + _ => JsonValue.Create(element) + }; } - - /// - /// Removes characters from a .NET member name that shouldn't be used in an AI function name. - /// - /// The .NET member name that should be sanitized. - /// - /// Replaces non-alphanumeric characters in the identifier with the underscore character. - /// Primarily intended to remove characters produced by compiler-generated method name mangling. - /// - private static string SanitizeMemberName(string memberName) => - InvalidNameCharsRegex().Replace(memberName, "_"); - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); -#endif } 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 c65bef12fc8..9ac67ff20dc 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -2,9 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; using System.Text.Json; using Xunit; +#pragma warning disable SA1204 // Static elements should appear before instance elements + namespace Microsoft.Extensions.AI; public class ChatResponseFormatTests @@ -81,4 +86,96 @@ public void Serialization_ForJsonSchemaRoundtrips() Assert.Equal("name", actual.SchemaName); Assert.Equal("description", actual.SchemaDescription); } + + [Fact] + public void ForJsonSchema_NullType_Throws() + { + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name")); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name", "description")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_PrimitiveType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(int)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Equal("""{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"integer"}""", format.Schema.ToString()); + Assert.Equal("Int32", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_IncludedType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(DataContent)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Contains("\"uri\"", format.Schema.ToString()); + Assert.Equal("DataContent", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + public static IEnumerable ForJsonSchema_ComplexType_Succeeds_MemberData() => + from generic in new[] { false, true } + from name in new string?[] { null, "CustomName" } + from description in new string?[] { null, "CustomDescription" } + select new object?[] { generic, name, description }; + + [Theory] + [MemberData(nameof(ForJsonSchema_ComplexType_Succeeds_MemberData))] + public void ForJsonSchema_ComplexType_Succeeds(bool generic, string? name, string? description) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, name, description) : + ChatResponseFormat.ForJsonSchema(typeof(SomeType), TestJsonSerializerContext.Default.Options, name, description); + + Assert.NotNull(format); + Assert.Equal( + """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "abcd", + "type": "object", + "properties": { + "someInteger": { + "description": "efg", + "type": "integer" + }, + "someString": { + "description": "hijk", + "type": [ + "string", + "null" + ] + } + } + } + """, + JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))); + Assert.Equal(name ?? "SomeType", format.SchemaName); + Assert.Equal(description ?? "abcd", format.SchemaDescription); + } + + [Description("abcd")] + public class SomeType + { + [Description("efg")] + public int SomeInteger { get; set; } + + [Description("hijk")] + public string? SomeString { get; set; } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index 01de984d949..93c7a124e38 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -36,4 +36,5 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(Guid))] // Used in Content tests [JsonSerializable(typeof(decimal))] // Used in Content tests [JsonSerializable(typeof(HostedMcpServerToolApprovalMode))] +[JsonSerializable(typeof(ChatResponseFormatTests.SomeType))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index dcb372d0571..431b2053d62 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -37,34 +37,28 @@ public async Task SuccessUsage_Default() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "fullName": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "string", - "enum": [ - "Bear", - "Tiger", - "Walrus" - ] + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "fullName": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "string", + "enum": [ + "Bear", + "Tiger", + "Walrus" + ] + } } - }, - "additionalProperties": false, - "required": [ - "id", - "fullName", - "species" - ] } """).RootElement, responseFormat.Schema.Value); Assert.Equal(nameof(Animal), responseFormat.SchemaName); @@ -380,29 +374,23 @@ public async Task CanSpecifyCustomJsonSerializationOptions() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "integer" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "integer" + } } - }, - "additionalProperties": false, - "required": [ - "id", - "full_name", - "species" - ] } """).RootElement, responseFormat.Schema.Value);