Skip to content

[API Proposal]: Accept AIJsonSchemaCreateOptions in ChatResponseFormat.ForJsonSchema<T> and GetResponseAsync<T> #7821

Open

Description

@AdCodicem

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 the account parameter.
  • ChatResponseFormat.ForJsonSchema<Order>() and GetResponseAsync<Order>() have no parameter to take it, so the account property stays true and the alternates items 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.CreateJsonSchema give the schema the application wants. It can only be used through the non-generic GetResponseAsync and a hand-built ChatResponse<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 a ChatOptions.ResponseFormat that is a ChatResponseFormatJson with 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 matches T. 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) and ChatResponseFormat.ForJsonSchema(JsonElement, ...), call the non-generic GetResponseAsync, and wrap the result in new ChatResponse<T>(response, serializerOptions) (the "Workaround" line of the output above). That works for a T whose schema is an object. For any other T (a List<Iban>, a number), GetResponseAsync<T> wraps the schema in a data property and tells ChatResponse<T> to unwrap the answer through IsWrappedInObject, which is internal (ChatResponse{T}.cs L116), 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, while ForJsonSchema(JsonElement, ...) leaves it null.
  • Data annotations on each property. [StringLength] and [RegularExpression] on an Iban property add minLength, maxLength and pattern, but not "type": "string". On a List<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: their AttributeUsage is Property, Field, Parameter.
  • An ambient default, such as a settable process-wide AIJsonSchemaCreateOptions for structured output. Simpler to call, but global state; listed only for completeness.

Risks

  • New overloads only; no behaviour changes when inferenceOptions is not passed.
  • Placement matters for source compatibility. In the GetResponseAsync<T> overload above, the new parameter follows serializerOptions, so an existing call such as GetResponseAsync<T>(messages, serializerOptions, null), where a literal null third argument fits both overloads, becomes ambiguous (CS0121, checked with a scratch compile of the two signatures). The ForJsonSchema overloads 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-aiMicrosoft.Extensions.AI librariesuntriaged

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions