From 75ebf722ce1041893efd29efbbc38ab17330d877 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 19:49:55 +0300 Subject: [PATCH 1/4] Add resolution of function parameter level data annotation attributes. --- .../AIJsonUtilities.Schema.Create.cs | 49 +++++++++++-------- .../Utilities/AIJsonUtilitiesTests.cs | 19 +++++++ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index 17e5e4d5353..77b0944d23d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -113,7 +113,7 @@ public static JsonElement CreateFunctionJsonSchema( JsonNode parameterSchema = CreateJsonSchemaCore( type: parameter.ParameterType, - parameterName: parameter.Name, + parameter: parameter, description: parameter.GetCustomAttribute(inherit: true)?.Description, hasDefaultValue: parameter.HasDefaultValue, defaultValue: GetDefaultValueNormalized(parameter), @@ -178,7 +178,7 @@ public static JsonElement CreateJsonSchema( { serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); + JsonNode schema = CreateJsonSchemaCore(type, parameter: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); // Finally, apply any schema transformations if specified. if (inferenceOptions.TransformOptions is { } options) @@ -208,7 +208,7 @@ internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumen #endif private static JsonNode CreateJsonSchemaCore( Type? type, - string? parameterName, + ParameterInfo? parameter, string? description, bool hasDefaultValue, object? defaultValue, @@ -272,14 +272,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js // The resulting schema might be a $ref using a pointer to a different location in the document. // As JSON pointer doesn't support relative paths, parameter schemas need to fix up such paths // to accommodate the fact that they're being nested inside of a higher-level schema. - if (parameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) + if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) { // Fix up any $ref URIs to match the path from the root document. string refUri = paramName!.GetValue(); Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}"); refUri = refUri == "#" - ? $"#/{PropertiesPropertyName}/{parameterName}" - : $"#/{PropertiesPropertyName}/{parameterName}/{refUri.AsMemory("#/".Length)}"; + ? $"#/{PropertiesPropertyName}/{parameter.Name}" + : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}"; objSchema[RefPropertyName] = (JsonNode)refUri; } @@ -359,7 +359,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } - ApplyDataAnnotations(parameterName, ref schema, ctx); + ApplyDataAnnotations(ref schema, ctx); // Finally, apply any user-defined transformations if specified. if (inferenceOptions.TransformSchemaNode is { } transformer) @@ -389,30 +389,33 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) + void ApplyDataAnnotations(ref JsonNode schema, AIJsonSchemaCreateContext ctx) { - if (ctx.GetCustomAttribute() is { } displayNameAttribute) + // If this is a root schema, take any parameter info attributes into account. + ParameterInfo? effectiveParameterInfo = ctx.Path.IsEmpty ? parameter : null; + + if (ResolveAttribute() is { } displayNameAttribute) { ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; } #if NET || NETFRAMEWORK - if (ctx.GetCustomAttribute() is { } emailAttribute) + if (ResolveAttribute() is { } emailAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; } - if (ctx.GetCustomAttribute() is { } urlAttribute) + if (ResolveAttribute() is { } urlAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "uri"; } - if (ctx.GetCustomAttribute() is { } regexAttribute) + if (ResolveAttribute() is { } regexAttribute) { ConvertSchemaToObject(ref schema)[PatternPropertyName] ??= regexAttribute.Pattern; } - if (ctx.GetCustomAttribute() is { } stringLengthAttribute) + if (ResolveAttribute() is { } stringLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -424,7 +427,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; } - if (ctx.GetCustomAttribute() is { } minLengthAttribute) + if (ResolveAttribute() is { } minLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") @@ -437,7 +440,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } maxLengthAttribute) + if (ResolveAttribute() is { } maxLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") @@ -450,7 +453,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } rangeAttribute) + if (ResolveAttribute() is { } rangeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -521,12 +524,12 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche #endif #if NET - if (ctx.GetCustomAttribute() is { } base64Attribute) + if (ResolveAttribute() is { } base64Attribute) { ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; } - if (ctx.GetCustomAttribute() is { } lengthAttribute) + if (ResolveAttribute() is { } lengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -550,7 +553,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } allowedValuesAttribute) + if (ResolveAttribute() is { } allowedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (!obj.ContainsKey(EnumPropertyName)) @@ -562,7 +565,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } deniedValuesAttribute) + if (ResolveAttribute() is { } deniedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -597,7 +600,7 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali return enumArray; } - if (ctx.GetCustomAttribute() is { } dataTypeAttribute) + if (ResolveAttribute() is { } dataTypeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); switch (dataTypeAttribute.DataType) @@ -629,6 +632,10 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } } #endif + TAttribute? ResolveAttribute() + where TAttribute : Attribute => + effectiveParameterInfo?.GetCustomAttribute(inherit: true) ?? + ctx.GetCustomAttribute(inherit: true); } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index bcec2981c5b..64ed43d4e13 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -403,6 +403,25 @@ public enum MyEnumValue B = 2 } + [Fact] + public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttributes() + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + AIFunction func = AIFunctionFactory.Create(([Range(1, 10)] int num, [StringLength(100, MinimumLength = 1)] string str) => num + str.Length, serializerOptions: options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type":"object", + "properties": { + "num": { "type":"integer", "minimum": 1, "maximum": 10 }, + "str": { "type":"string", "minLength": 1, "maxLength": 100 } + }, + "required":["num","str"] + } + """).RootElement; + AssertDeepEquals(expectedSchema, func.JsonSchema); + } + [Fact] public static void CreateJsonSchema_CanBeBoolean() { From a4fea01ad528f0c07d76c0c1d9e9aac09b6f5661 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 20:04:12 +0300 Subject: [PATCH 2/4] Update test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs Co-authored-by: Stephen Toub --- .../Utilities/AIJsonUtilitiesTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 64ed43d4e13..337849bc852 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -409,7 +409,7 @@ public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttribut JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; AIFunction func = AIFunctionFactory.Create(([Range(1, 10)] int num, [StringLength(100, MinimumLength = 1)] string str) => num + str.Length, serializerOptions: options); - JsonElement expectedSchema = JsonDocument.Parse(""" + using JsonDocument expectedSchema = JsonDocument.Parse(""" { "type":"object", "properties": { @@ -418,8 +418,8 @@ public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttribut }, "required":["num","str"] } - """).RootElement; - AssertDeepEquals(expectedSchema, func.JsonSchema); + """); + AssertDeepEquals(expectedSchema.RootElement, func.JsonSchema); } [Fact] From 76fe2bd611ebb345ac5f4f03ba378c132f0f53ca Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 20:12:55 +0300 Subject: [PATCH 3/4] Style improvement --- .../Utilities/AIJsonUtilities.Schema.Create.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index 77b0944d23d..da0124639de 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -391,9 +391,6 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) void ApplyDataAnnotations(ref JsonNode schema, AIJsonSchemaCreateContext ctx) { - // If this is a root schema, take any parameter info attributes into account. - ParameterInfo? effectiveParameterInfo = ctx.Path.IsEmpty ? parameter : null; - if (ResolveAttribute() is { } displayNameAttribute) { ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; @@ -633,9 +630,16 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } #endif TAttribute? ResolveAttribute() - where TAttribute : Attribute => - effectiveParameterInfo?.GetCustomAttribute(inherit: true) ?? - ctx.GetCustomAttribute(inherit: true); + where TAttribute : Attribute + { + // If this is the root schema, check for any parameter attributes first. + if (ctx.Path.IsEmpty && parameter?.GetCustomAttribute(inherit: true) is TAttribute attr) + { + return attr; + } + + return ctx.GetCustomAttribute(inherit: true); + } } } } From 84b36f0ab65b9c861fdb5c7bce46ec4228720af8 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 31 Jul 2025 20:15:33 +0300 Subject: [PATCH 4/4] Update test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs --- .../Utilities/AIJsonUtilitiesTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 337849bc852..11926e5132e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -419,6 +419,7 @@ public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttribut "required":["num","str"] } """); + AssertDeepEquals(expectedSchema.RootElement, func.JsonSchema); }