Repository navigation
[API Proposal]: Accept AIJsonSchemaCreateOptions in ChatResponseFormat.ForJsonSchema<T> and GetResponseAsync<T> #7821
Description
Background and motivation
AIFunctionFactory lets an application shape the JSON schemas of its tools through AIFunctionFactoryOptions.JsonSchemaCreateOptions (AIFunctionFactoryOptions.cs L40-L49). Structured output has no such hook. ChatResponseFormat.ForJsonSchema<T>() creates its schema with a private static AIJsonSchemaCreateOptions that only sets IncludeSchemaKeyword (ChatResponseFormat.cs L22-L25, L84-L87), and GetResponseAsync<T>() calls it, then replaces any ChatOptions.ResponseFormat the caller set (ChatClientStructuredOutputExtensions.cs L151, L179-L184).
This matters for any type serialized by a custom JsonConverter, such as a strongly typed ID or a value object (an IBAN, an e-mail address, a quantity). System.Text.Json's schema exporter cannot see through a custom converter, so such a property is described as true (anything), and a List<T> of them as "items": {} (the trivial keyword that #6669 adds so that providers accept the schema). The schema tells the model nothing about it, not even that it is a string. System.Text.Json has no way yet for a converter to describe its schema (dotnet/runtime#105769). The converter still enforces its rules when the answer is read, so a wrong answer is caught only afterwards, as a JsonException from ChatResponse<T>.Result.
An application can describe such types itself with a TransformSchemaNode, and that already works for tools. The repro below uses one transform, DescribeIban, which turns Iban into {"type":"string","minLength":15,"maxLength":34,"pattern":"..."}:
AIFunctionFactory.Create(..., new AIFunctionFactoryOptions { JsonSchemaCreateOptions = schemaOptions })describes theaccountparameter.ChatResponseFormat.ForJsonSchema<Order>()andGetResponseAsync<Order>()have no parameter to take it, so theaccountproperty staystrueand thealternatesitems stay{}.GetResponseAsync<Order>(..., new ChatOptions { ResponseFormat = callerFormat })sends its own format, not the caller's. That is the intended behaviour (Microsoft.Extensions.AI Json response functionality is broken #6610 (comment): "The implementation always supplies its own ResponseFormat, ignoring whatever was supplied"), so the schema cannot be supplied that way.- The same options passed to
AIJsonUtilities.CreateJsonSchemagive the schema the application wants. It can only be used through the non-genericGetResponseAsyncand a hand-builtChatResponse<T>.
Output of the repro (Microsoft.Extensions.AI 10.10.0, Microsoft.Extensions.AI.Abstractions 10.10.1, .NET 10.0.12, Windows 11 x64; same output on Ubuntu 24.04 x64):
AIFunctionFactory.Create(PlaceOrder), default options
{"type":"object","properties":{"account":true,"quantity":{"type":"integer"}},"required":["account","quantity"]}
AIFunctionFactory.Create(PlaceOrder), JsonSchemaCreateOptions = schemaOptions
{"type":"object","properties":{"account":{"type":"string","minLength":15,"maxLength":34,"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"},"quantity":{"type":"integer"}},"required":["account","quantity"]}
ChatResponseFormat.ForJsonSchema<Order>()
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":true,"quantity":{"type":"integer"},"alternates":{"type":"array","items":{}}},"required":["account","quantity","alternates"]}
GetResponseAsync<Order>(..., new ChatOptions { ResponseFormat = callerFormat }): format the IChatClient receives
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":true,"quantity":{"type":"integer"},"alternates":{"type":"array","items":{}}},"required":["account","quantity","alternates"]}
is it callerFormat? False; Result.Account = FR7630006000011234567890189
Wanted: AIJsonUtilities.CreateJsonSchema(typeof(Order), ..., inferenceOptions: schemaOptions with { IncludeSchemaKeyword = true })
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":{"type":"string","minLength":15,"maxLength":34,"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"},"quantity":{"type":"integer"},"alternates":{"type":"array","items":{"type":"string","minLength":15,"maxLength":34,"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"}}},"required":["account","quantity","alternates"]}
Answer with "account":"FR76", .Result: JsonException: An IBAN has 15 to 34 characters.
Workaround (non-generic GetResponseAsync + new ChatResponse<Order>): format received is callerFormat? True; Result.Account = FR7630006000011234567890189
ChatResponseFormat.ForJsonSchema<AnnotatedOrder>() ([StringLength] and [RegularExpression] on both Iban properties)
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":{"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$","minLength":15,"maxLength":34},"quantity":{"type":"integer"},"alternates":{"type":"array","items":{},"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$","minLength":15,"maxLength":34}},"required":["account","quantity","alternates"]}
StringLengthAttribute targets: Property, Field, Parameter
RegularExpressionAttribute targets: Property, Field, Parameter
Repro (Program.cs, Repro.csproj): run with dotnet run
// Program.cs
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
// The schema options an application would like to use everywhere: describe Iban, which its JsonConverter writes as a
// string, with the constraints that converter enforces.
var schemaOptions = new AIJsonSchemaCreateOptions { TransformSchemaNode = DescribeIban };
// 1. Tools: AIFunctionFactory takes the options.
Print("AIFunctionFactory.Create(PlaceOrder), default options",
AIFunctionFactory.Create(Tools.PlaceOrder).JsonSchema);
Print("AIFunctionFactory.Create(PlaceOrder), JsonSchemaCreateOptions = schemaOptions",
AIFunctionFactory.Create(Tools.PlaceOrder, new AIFunctionFactoryOptions { JsonSchemaCreateOptions = schemaOptions }).JsonSchema);
// 2. Structured output: no way to pass them.
Print("ChatResponseFormat.ForJsonSchema<Order>()", ChatResponseFormat.ForJsonSchema<Order>().Schema!.Value);
// 3. GetResponseAsync<T> replaces a ResponseFormat the caller set, so the schema cannot be supplied that way either.
var callerFormat = ChatResponseFormat.ForJsonSchema(
AIJsonUtilities.CreateJsonSchema(typeof(Order), serializerOptions: AIJsonUtilities.DefaultOptions,
inferenceOptions: schemaOptions with { IncludeSchemaKeyword = true }),
"Order");
var client = new ScriptedChatClient("""{"account":"FR7630006000011234567890189","quantity":3,"alternates":["DE89370400440532013000"]}""");
ChatResponse<Order> typed = await client.GetResponseAsync<Order>(
"Place an order.", new ChatOptions { ResponseFormat = callerFormat });
Print("GetResponseAsync<Order>(..., new ChatOptions { ResponseFormat = callerFormat }): format the IChatClient receives",
client.LastFormat!.Schema!.Value);
Console.WriteLine($" is it callerFormat? {ReferenceEquals(client.LastFormat, callerFormat)}; Result.Account = {typed.Result.Account}");
Console.WriteLine();
// 4. What the application wants GetResponseAsync<T> to send: the same transform, through AIJsonUtilities.
Print("Wanted: AIJsonUtilities.CreateJsonSchema(typeof(Order), ..., inferenceOptions: schemaOptions with { IncludeSchemaKeyword = true })",
callerFormat.Schema!.Value);
// 5. Without the schema, the rule is only enforced after the model has answered.
var refusing = new ScriptedChatClient("""{"account":"FR76","quantity":3,"alternates":[]}""");
ChatResponse<Order> refused = await refusing.GetResponseAsync<Order>("Place an order.");
try
{
_ = refused.Result;
}
catch (JsonException e)
{
Console.WriteLine($"Answer with \"account\":\"FR76\", .Result: {e.GetType().Name}: {e.Message}");
Console.WriteLine();
}
// 6. Today's workaround: build the format by hand, call the non-generic GetResponseAsync, wrap the response.
var workaround = new ScriptedChatClient("""{"account":"FR7630006000011234567890189","quantity":3,"alternates":[]}""");
ChatResponse raw = await workaround.GetResponseAsync("Place an order.", new ChatOptions { ResponseFormat = callerFormat });
var wrapped = new ChatResponse<Order>(raw, AIJsonUtilities.DefaultOptions);
Console.WriteLine($"Workaround (non-generic GetResponseAsync + new ChatResponse<Order>): format received is callerFormat? " +
$"{ReferenceEquals(workaround.LastFormat, callerFormat)}; Result.Account = {wrapped.Result.Account}");
Console.WriteLine();
// 7. Data annotations on the properties: no "type", repeated on every property, and they describe the list, not its items.
Print("ChatResponseFormat.ForJsonSchema<AnnotatedOrder>() ([StringLength] and [RegularExpression] on both Iban properties)",
ChatResponseFormat.ForJsonSchema<AnnotatedOrder>().Schema!.Value);
foreach (Type attribute in new[] { typeof(StringLengthAttribute), typeof(RegularExpressionAttribute) })
{
Console.WriteLine($"{attribute.Name} targets: {attribute.GetCustomAttribute<AttributeUsageAttribute>()!.ValidOn}");
}
static JsonNode DescribeIban(AIJsonSchemaCreateContext context, JsonNode schema)
{
// The schema of a type with a custom converter is `true` (or an object holding only a description).
if (context.TypeInfo.Type == typeof(Iban))
{
return Describe(schema as JsonObject ?? []);
}
// System.Text.Json's exporter does not call the transform for a collection element whose schema is `true`, so the
// collection's own node completes its `items`.
if (context.TypeInfo.ElementType == typeof(Iban) && schema is JsonObject collection)
{
if (collection["items"] is JsonObject items)
{
Describe(items);
}
else
{
collection["items"] = Describe([]);
}
}
return schema;
static JsonObject Describe(JsonObject node)
{
node["type"] = "string";
node["minLength"] = 15;
node["maxLength"] = 34;
node["pattern"] = "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$";
return node;
}
}
static void Print(string title, JsonElement schema)
{
Console.WriteLine(title);
Console.WriteLine(" " + JsonSerializer.Serialize(schema));
Console.WriteLine();
}
/// <summary>A value object: serialized as a JSON string by its own converter, which enforces its rules.</summary>
[JsonConverter(typeof(IbanJsonConverter))]
public readonly struct Iban(string value)
{
public string Value { get; } = value;
public override string ToString() => Value;
}
public sealed class IbanJsonConverter : JsonConverter<Iban>
{
public override Iban Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException("An IBAN is a JSON string.");
}
string text = reader.GetString()!;
if (text.Length is < 15 or > 34)
{
throw new JsonException("An IBAN has 15 to 34 characters.");
}
return new Iban(text);
}
public override void Write(Utf8JsonWriter writer, Iban value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Value);
}
public sealed record Order(Iban Account, int Quantity, List<Iban> Alternates);
public sealed record AnnotatedOrder(
[property: StringLength(34, MinimumLength = 15), RegularExpression("^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$")] Iban Account,
int Quantity,
[property: StringLength(34, MinimumLength = 15), RegularExpression("^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$")] List<Iban> Alternates);
public static class Tools
{
public static string PlaceOrder(Iban account, int quantity) => $"placed {quantity} on {account}";
}
/// <summary>Stands for the model: records the response format it is sent, answers with a fixed text.</summary>
public sealed class ScriptedChatClient(string answer) : IChatClient
{
public ChatResponseFormatJson? LastFormat { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
LastFormat = options?.ResponseFormat as ChatResponseFormatJson;
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, answer)));
}
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.10.0" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.10.1" />
</ItemGroup>
</Project>The same as a project with a script: https://github.com/AdCodicem/dotnet-upstream-repros/tree/main/meai-response-format-schema-options
Found while adding Microsoft.Extensions.AI support to AdCodicem.ValueObjects. Investigation and write-up assisted by Claude (Anthropic); the linked repro was run on Windows 11 and Ubuntu, and I reviewed this proposal.
API Proposal
namespace Microsoft.Extensions.AI;
public partial class ChatResponseFormat
{
public static ChatResponseFormatJson ForJsonSchema<T>(
JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);
+ public static ChatResponseFormatJson ForJsonSchema<T>(
+ JsonSerializerOptions? serializerOptions, string? schemaName, string? schemaDescription,
+ AIJsonSchemaCreateOptions? inferenceOptions);
public static ChatResponseFormatJson ForJsonSchema(
Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);
+ public static ChatResponseFormatJson ForJsonSchema(
+ Type schemaType, JsonSerializerOptions? serializerOptions, string? schemaName, string? schemaDescription,
+ AIJsonSchemaCreateOptions? inferenceOptions);
}
public static partial class ChatClientStructuredOutputExtensions
{
+ public static Task<ChatResponse<T>> GetResponseAsync<T>(
+ this IChatClient chatClient,
+ IEnumerable<ChatMessage> messages,
+ JsonSerializerOptions serializerOptions,
+ AIJsonSchemaCreateOptions? inferenceOptions,
+ ChatOptions? options = null,
+ bool? useJsonSchemaResponseFormat = null,
+ CancellationToken cancellationToken = default);
}The parameter is named inferenceOptions, as in AIJsonUtilities.CreateJsonSchema. null keeps today's schema. A non-null value is used to create the schema, as AIFunctionFactoryOptions.JsonSchemaCreateOptions is for a tool's parameters; whether IncludeSchemaKeyword is then forced on, as it is today, is the team's call. The string and ChatMessage overloads of GetResponseAsync<T> could get the same parameter if the team wants the full set.
API Usage
// Defined once by the application, and already usable for tools today.
var schemaOptions = new AIJsonSchemaCreateOptions { TransformSchemaNode = DescribeIban };
AIFunction tool = AIFunctionFactory.Create(
Tools.PlaceOrder, new AIFunctionFactoryOptions { JsonSchemaCreateOptions = schemaOptions });
// Proposed: the same options for structured output.
ChatResponse<Order> response = await chatClient.GetResponseAsync<Order>(
messages, AIJsonUtilities.DefaultOptions, schemaOptions);
ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<Order>(
AIJsonUtilities.DefaultOptions, schemaName: null, schemaDescription: null, schemaOptions);Libraries that ship such types (value objects, strongly typed IDs) can then hand applications one AIJsonSchemaCreateOptions that works for tools and structured output alike.
Alternative Designs
- Keep a schema the caller already set.
GetResponseAsync<T>could leave aChatOptions.ResponseFormatthat is aChatResponseFormatJsonwith a schema as it is, instead of replacing it. That needs no new API, but it changes behaviour, and nothing would check that the caller's schema matchesT. It also goes against the intent stated in Microsoft.Extensions.AI Json response functionality is broken #6610. - Today's workaround: build the format with
AIJsonUtilities.CreateJsonSchema(typeof(T), ..., inferenceOptions)andChatResponseFormat.ForJsonSchema(JsonElement, ...), call the non-genericGetResponseAsync, and wrap the result innew ChatResponse<T>(response, serializerOptions)(the "Workaround" line of the output above). That works for aTwhose schema is an object. For any otherT(aList<Iban>, a number),GetResponseAsync<T>wraps the schema in adataproperty and tellsChatResponse<T>to unwrap the answer throughIsWrappedInObject, which is internal (ChatResponse{T}.csL116), so the application has to unwrap the answer itself. It also has to pass the schema name itself if it wants one:ForJsonSchema(Type, ...)derives it from the type, whileForJsonSchema(JsonElement, ...)leaves it null. - Data annotations on each property.
[StringLength]and[RegularExpression]on anIbanproperty addminLength,maxLengthandpattern, but not"type": "string". On aList<Iban>property they land on the array itself, beside"items": {}(end of the output). They must be repeated on every property, and they cannot go on the type: theirAttributeUsageisProperty, Field, Parameter. - An ambient default, such as a settable process-wide
AIJsonSchemaCreateOptionsfor structured output. Simpler to call, but global state; listed only for completeness.
Risks
- New overloads only; no behaviour changes when
inferenceOptionsis not passed. - Placement matters for source compatibility. In the
GetResponseAsync<T>overload above, the new parameter followsserializerOptions, so an existing call such asGetResponseAsync<T>(messages, serializerOptions, null), where a literalnullthird argument fits both overloads, becomes ambiguous (CS0121, checked with a scratch compile of the two signatures). TheForJsonSchemaoverloads put it last, with every parameter required; in the same scratch compile, none of the existing call shapes tried ((options, null),(options, null, null),(schemaName: "n")) became ambiguous. The team may prefer another placement or an options type. - A transform can produce a schema the answer then fails to match, or one a provider's strict mode refuses. That is already true of tools, which accept the same options.
Activity