Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Address PR feedback
  • Loading branch information
stephentoub committed Sep 12, 2025
commit ff2e1be1fc1fc2f848653afe0ca6ff12e581379e
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
},
};

/// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary>
Expand All @@ -50,7 +46,7 @@ public static ChatResponseFormatJson ForJsonSchema(
JsonElement schema, string? schemaName = null, string? schemaDescription = null) =>
new(schema, schemaName, schemaDescription);

/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with the specified schema.</summary>
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with a schema based on <typeparamref name="T"/>.</summary>
/// <typeparam name="T">The type for which a schema should be exported and used as the response schema.</typeparam>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <typeparamref name="T"/>.</param>
Expand All @@ -64,17 +60,37 @@ public static ChatResponseFormatJson ForJsonSchema(
/// it serializes as a JSON object with the original type as a property of that object.
/// </remarks>
public static ChatResponseFormatJson ForJsonSchema<T>(
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null)
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) =>
ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription);

/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with a schema based on <paramref name="schemaType"/>.</summary>
/// <param name="schemaType">The <see cref="Type"/> for which a schema should be exported and used as the response schema.</param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="schemaName">An optional name of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param>
/// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <paramref name="schemaType"/>.</param>
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
/// <remarks>
/// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'.
/// If <paramref name="schemaType"/> is a primitive type like <see cref="string"/>, <see cref="int"/>, or <see cref="bool"/>,
/// 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 <paramref name="schemaType"/> 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.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="schemaType"/> is <see langword="null"/>.</exception>
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<DescriptionAttribute>()?.Description);
schemaName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"),
schemaDescription ?? schemaType.GetCustomAttribute<DescriptionAttribute>()?.Description);
}

/// <summary>Regex that flags any character other than ASCII digits, ASCII letters, or underscore.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class DelegatingChatClient : IChatClient
/// Initializes a new instance of the <see cref="DelegatingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The wrapped client instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerClient"/> is <see langword="null"/>.</exception>
protected DelegatingChatClient(IChatClient innerClient)
{
InnerClient = Throw.IfNull(innerClient);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,10 @@
{
"Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema<T>(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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,35 +88,60 @@ public void Serialization_ForJsonSchemaRoundtrips()
}

[Fact]
public void ForJsonSchema_PrimitiveType_Succeeds()
public void ForJsonSchema_NullType_Throws()
{
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!));
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options));
Assert.Throws<ArgumentNullException>("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name"));
Assert.Throws<ArgumentNullException>("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<int>();
ChatResponseFormatJson format = generic ?
ChatResponseFormat.ForJsonSchema<int>() :
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);
}

[Fact]
public void ForJsonSchema_IncludedType_Succeeds()
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ForJsonSchema_IncludedType_Succeeds(bool generic)
{
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<DataContent>();
ChatResponseFormatJson format = generic ?
ChatResponseFormat.ForJsonSchema<DataContent>() :
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<object?[]> 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<SomeType>(TestJsonSerializerContext.Default.Options, name, description);
ChatResponseFormatJson format = generic ?
ChatResponseFormat.ForJsonSchema<SomeType>(TestJsonSerializerContext.Default.Options, name, description) :
ChatResponseFormat.ForJsonSchema(typeof(SomeType), TestJsonSerializerContext.Default.Options, name, description);

Assert.NotNull(format);
Assert.Equal(
"""
Expand All @@ -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))));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
Loading