Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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,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

/// <summary>Represents the response format that is desired by the caller.</summary>
[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,
};

/// <summary>Initializes a new instance of the <see cref="ChatResponseFormat"/> class.</summary>
/// <remarks>Prevents external instantiation. Close the inheritance hierarchy for now until we have good reason to open it.</remarks>
private protected ChatResponseFormat()
Expand All @@ -33,7 +44,61 @@ private protected ChatResponseFormat()
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
public static ChatResponseFormatJson ForJsonSchema(
JsonElement schema, string? schemaName = null, string? schemaDescription = null) =>
new(schema,
schemaName,
schemaDescription);
new(schema, schemaName, schemaDescription);

/// <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>
/// <param name="schemaDescription">An optional description of the schema. By default, this will be inferred from <typeparamref name="T"/>.</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 <typeparamref name="T"/> 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 <typeparamref name="T"/> 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>
public static ChatResponseFormatJson ForJsonSchema<T>(
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(
schemaType,
serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions,
inferenceOptions: _inferenceOptions);

return ForJsonSchema(
schema,
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>
#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
}
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 @@ -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<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 @@ -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;
Expand All @@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI;
/// <related type="Article" href="https://learn.microsoft.com/dotnet/ai/quickstarts/structured-output">Request a response with structured output.</related>
public static partial class ChatClientStructuredOutputExtensions
{
private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new()
{
IncludeSchemaKeyword = true,
TransformOptions = new AIJsonSchemaTransformOptions
{
DisallowAdditionalProperties = true,
RequireAllProperties = true,
MoveDefaultKeywordToDescription = true,
},
};

/// <summary>Sends chat messages, requesting a response matching the type <typeparamref name="T"/>.</summary>
/// <param name="chatClient">The <see cref="IChatClient"/>.</param>
/// <param name="messages">The chat content to send.</param>
Expand Down Expand Up @@ -161,20 +148,12 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(

serializerOptions.MakeReadOnly();

var schemaElement = AIJsonUtilities.CreateJsonSchema(
type: typeof(T),
serializerOptions: serializerOptions,
inferenceOptions: _inferenceOptions);
var responseFormat = ChatResponseFormat.ForJsonSchema<T>(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
Expand All @@ -184,10 +163,11 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
{
{ "$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;
Expand All @@ -200,10 +180,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
{
// 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<DescriptionAttribute>()?.Description);
options.ResponseFormat = responseFormat;
}
else
{
Expand All @@ -213,7 +190,7 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(
promptAugmentation = new ChatMessage(ChatRole.User, $$"""
Respond with a JSON value conforming to the following schema:
```
{{schema}}
{{responseFormat.Schema}}
```
""");

Expand All @@ -222,53 +199,31 @@ public static async Task<ChatResponse<T>> GetResponseAsync<T>(

var result = await chatClient.GetResponseAsync(messages, options, cancellationToken);
return new ChatResponse<T>(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)
};
}

/// <summary>
/// Removes characters from a .NET member name that shouldn't be used in an AI function name.
/// </summary>
/// <param name="memberName">The .NET member name that should be sanitized.</param>
/// <returns>
/// Replaces non-alphanumeric characters in the identifier with the underscore character.
/// Primarily intended to remove characters produced by compiler-generated method name mangling.
/// </returns>
private static string SanitizeMemberName(string memberName) =>
InvalidNameCharsRegex().Replace(memberName, "_");

/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#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
}
Loading
Loading