From b6307598c73d16fc4fb608585ffc148d9bfa9487 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 9 Sep 2025 16:44:41 -0400 Subject: [PATCH 1/2] Add ChatResponseFormat.ForJsonSchema Moves the implementation used by `GetResponseAsync` to be exposed as its own helper that can be used directly, for cases where folks want the formatting but don't want to use the generic `GetResponseAsync`. --- .../ChatCompletion/ChatResponseFormat.cs | 61 +++++++++-- .../Microsoft.Extensions.AI.Abstractions.json | 4 + .../ChatClientStructuredOutputExtensions.cs | 101 +++++------------- .../ChatCompletion/ChatResponseFormatTests.cs | 73 +++++++++++++ .../TestJsonSerializerContext.cs | 1 + 5 files changed, 161 insertions(+), 79 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index ac59cfc263e..aba670da9d3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,19 +1,34 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; 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, + TransformOptions = new AIJsonSchemaTransformOptions + { + DisallowAdditionalProperties = true, + RequireAllProperties = true, + MoveDefaultKeywordToDescription = 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 +48,41 @@ 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 the specified schema. + /// 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) + { + var schema = AIJsonUtilities.CreateJsonSchema( + type: typeof(T), + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + inferenceOptions: _inferenceOptions); + + return ForJsonSchema( + schema, + schemaName ?? InvalidNameCharsRegex().Replace(typeof(T).Name, "_"), + schemaDescription ?? typeof(T).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/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index ca42fdacd20..7d5b79370b8 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,10 @@ { "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" } ], "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..e44b425ead7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ComponentModel; using System.Text.Json; using Xunit; @@ -81,4 +82,76 @@ public void Serialization_ForJsonSchemaRoundtrips() Assert.Equal("name", actual.SchemaName); Assert.Equal("description", actual.SchemaDescription); } + + [Fact] + public void ForJsonSchema_PrimitiveType_Succeeds() + { + ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema(); + 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); + } + + [Fact] + public void ForJsonSchema_IncludedType_Succeeds() + { + ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema(); + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Contains("\"uri\"", format.Schema.ToString()); + Assert.Equal("DataContent", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + [Theory] + [InlineData(null, null)] + [InlineData("AnotherName", null)] + [InlineData(null, "another description")] + [InlineData("AnotherName", "another description")] + public void ForJsonSchema_ComplexType_Succeeds(string? name, string? description) + { + ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema(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" + ] + } + }, + "additionalProperties": false, + "required": [ + "someInteger", + "someString" + ] + } + """, + 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; From ff2e1be1fc1fc2f848653afe0ca6ff12e581379e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 11 Sep 2025 20:53:29 -0400 Subject: [PATCH 2/2] Address PR feedback --- .../CHANGELOG.md | 2 + .../ChatCompletion/ChatResponseFormat.cs | 38 +++++--- .../ChatCompletion/DelegatingChatClient.cs | 1 + .../Microsoft.Extensions.AI.Abstractions.json | 4 + .../ChatCompletion/ChatResponseFormatTests.cs | 58 +++++++++---- ...atClientStructuredOutputExtensionsTests.cs | 86 ++++++++----------- 6 files changed, 112 insertions(+), 77 deletions(-) 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 aba670da9d3..088fc533d05 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,11 +1,13 @@ // 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; @@ -21,12 +23,6 @@ public partial class ChatResponseFormat private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() { IncludeSchemaKeyword = true, - TransformOptions = new AIJsonSchemaTransformOptions - { - DisallowAdditionalProperties = true, - RequireAllProperties = true, - MoveDefaultKeywordToDescription = true, - }, }; /// Initializes a new instance of the class. @@ -50,7 +46,7 @@ public static ChatResponseFormatJson ForJsonSchema( JsonElement schema, string? schemaName = null, string? schemaDescription = null) => new(schema, schemaName, schemaDescription); - /// Creates a representing structured JSON data with the specified schema. + /// 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 . @@ -64,17 +60,37 @@ public static ChatResponseFormatJson ForJsonSchema( /// 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) + 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( - type: typeof(T), + schemaType, serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, inferenceOptions: _inferenceOptions); return ForJsonSchema( schema, - schemaName ?? InvalidNameCharsRegex().Replace(typeof(T).Name, "_"), - schemaDescription ?? typeof(T).GetCustomAttribute()?.Description); + schemaName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), + schemaDescription ?? schemaType.GetCustomAttribute()?.Description); } /// Regex that flags any character other than ASCII digits, ASCII letters, or underscore. 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 7d5b79370b8..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 @@ -1160,6 +1160,10 @@ { "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/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs index e44b425ead7..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,10 +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 @@ -84,9 +88,23 @@ public void Serialization_ForJsonSchemaRoundtrips() } [Fact] - public void ForJsonSchema_PrimitiveType_Succeeds() + 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 = ChatResponseFormat.ForJsonSchema(); + 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()); @@ -94,10 +112,15 @@ public void ForJsonSchema_PrimitiveType_Succeeds() Assert.Null(format.SchemaDescription); } - [Fact] - public void ForJsonSchema_IncludedType_Succeeds() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_IncludedType_Succeeds(bool generic) { - ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema(); + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(DataContent)); + Assert.NotNull(format); Assert.NotNull(format.Schema); Assert.Contains("\"uri\"", format.Schema.ToString()); @@ -105,14 +128,20 @@ public void ForJsonSchema_IncludedType_Succeeds() 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] - [InlineData(null, null)] - [InlineData("AnotherName", null)] - [InlineData(null, "another description")] - [InlineData("AnotherName", "another description")] - public void ForJsonSchema_ComplexType_Succeeds(string? name, string? description) + [MemberData(nameof(ForJsonSchema_ComplexType_Succeeds_MemberData))] + public void ForJsonSchema_ComplexType_Succeeds(bool generic, string? name, string? description) { - ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, name, 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( """ @@ -132,12 +161,7 @@ public void ForJsonSchema_ComplexType_Succeeds(string? name, string? description "null" ] } - }, - "additionalProperties": false, - "required": [ - "someInteger", - "someString" - ] + } } """, JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))); 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);