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