diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs index 42eb486f4c1..e9524b91ab1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallHelpers.cs @@ -14,6 +14,7 @@ using System.Text.Json.Nodes; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Microsoft.Shared.Diagnostics; using FunctionParameterKey = (System.Type? Type, string ParameterName, string? Description, bool HasDefaultValue, object? DefaultValue); @@ -375,4 +376,20 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonDocument))] private sealed partial class FunctionCallHelperContext : JsonSerializerContext; + + /// + /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. + /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. + /// + public static string SanitizeMetadataName(string metadataName) => + InvalidNameCharsRegex().Replace(metadataName, "_"); + + /// 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/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 5d16440a8fa..84effb1737b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -14,6 +14,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; @@ -167,7 +168,7 @@ public static async Task> CompleteAsync( // the LLM backend is meant to do whatever's needed to explain the schema to the LLM. options.ResponseFormat = ChatResponseFormat.ForJsonSchema( schema, - schemaName: typeof(T).Name, + schemaName: SanitizeMetadataName(typeof(T).Name), schemaDescription: typeof(T).GetCustomAttribute()?.Description); } else diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index a3bd73c602a..84ce1fa15a0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -12,20 +12,16 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.AI.FunctionCallHelpers; namespace Microsoft.Extensions.AI; /// Provides factory methods for creating commonly-used implementations of . -public static -#if NET - partial -#endif - class AIFunctionFactory +public static class AIFunctionFactory { internal const string UsesReflectionJsonSerializerMessage = "This method uses the reflection-based JsonSerializer which can break in trimmed or AOT applications."; @@ -107,11 +103,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac return new ReflectionAIFunction(method, target, options); } - private sealed -#if NET - partial -#endif - class ReflectionAIFunction : AIFunction + private sealed class ReflectionAIFunction : AIFunction { private readonly MethodInfo _method; private readonly object? _target; @@ -474,21 +466,5 @@ private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedT #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields return specializedType.GetMethods(All).First(m => m.MetadataToken == genericMethodDefinition.MetadataToken); } - - /// - /// Remove characters from method name that are valid in metadata but shouldn't be used in a method name. - /// This is primarily intended to remove characters emitted by for compiler-generated method name mangling. - /// - private static string SanitizeMetadataName(string methodName) => - InvalidNameCharsRegex().Replace(methodName, "_"); - - /// 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.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index 0e776b4fee5..eea22abfacb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -172,6 +172,40 @@ public async Task CanUseNativeStructuredOutput() Assert.Equal("Hello", Assert.Single(chatHistory).Text); } + [Fact] + public async Task CanUseNativeStructuredOutputWithSanitizedTypeName() + { + var expectedResult = new Data { Value = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger } }; + var expectedCompletion = new ChatCompletion([new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult))]); + + using var client = new TestChatClient + { + CompleteAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + + Assert.Matches("Data_1", responseFormat.SchemaName); + + return Task.FromResult(expectedCompletion); + }, + }; + + var chatHistory = new List { new(ChatRole.User, "Hello") }; + var response = await client.CompleteAsync>(chatHistory, useNativeJsonSchema: true); + + // The completion contains the deserialized result and other completion properties + Assert.Equal(1, response.Result!.Value!.Id); + Assert.Equal("Tigger", response.Result.Value.FullName); + Assert.Equal(Species.Tiger, response.Result.Value.Species); + + // TryGetResult returns the same value + Assert.True(response.TryGetResult(out var tryGetResultOutput)); + Assert.Same(response.Result, tryGetResultOutput); + + // History remains unmutated + Assert.Equal("Hello", Assert.Single(chatHistory).Text); + } + [Fact] public async Task CanSpecifyCustomJsonSerializationOptions() { @@ -247,6 +281,11 @@ private class Animal public Species Species { get; set; } } + private class Data + { + public T? Value { get; set; } + } + private enum Species { Bear,