diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
index 4625b168aa6..803e80ea8dd 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
@@ -135,7 +135,8 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// The method to be represented via the created .
///
/// The name to use for the . If , the name will be derived from
- /// any on , if available, or else from the name of .
+ /// any on , then from any
+ /// on , if available, or else from the name of .
///
///
/// The description to use for the . If , a description will be derived from
@@ -313,7 +314,8 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
///
///
/// The name to use for the . If , the name will be derived from
- /// any on , if available, or else from the name of .
+ /// any on , then from any
+ /// on , if available, or else from the name of .
///
///
/// The description to use for the . If , a description will be derived from
@@ -708,7 +710,7 @@ private sealed class ReflectionAIFunctionDescriptor
private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken);
///
- /// Gets or creates a descriptors using the specified method and options.
+ /// Gets or creates a descriptor using the specified method and options.
///
public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options)
{
@@ -806,7 +808,11 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
pType != typeof(IServiceProvider) &&
!string.IsNullOrEmpty(parameters[i].Name))
{
- _ = expectedArgumentNames.Add(parameters[i].Name!);
+ string effectiveName = AIJsonUtilities.GetParameterSchemaName(parameters[i]);
+ if (!expectedArgumentNames.Add(effectiveName))
+ {
+ Throw.ArgumentException("method", $"Multiple parameters are mapped to the same name '{effectiveName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
+ }
}
}
@@ -815,7 +821,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
Method = key.Method;
- Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
+ Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty;
JsonSerializerOptions = serializerOptions;
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
@@ -949,10 +955,11 @@ static bool IsAsyncMethod(MethodInfo method)
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType);
bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue);
+ string argumentName = AIJsonUtilities.GetParameterSchemaName(parameter);
return (arguments, _) =>
{
// If the parameter has an argument specified in the dictionary, return that argument.
- if (arguments.TryGetValue(parameter.Name, out object? value))
+ if (arguments.TryGetValue(argumentName, out object? value))
{
return value switch
{
@@ -999,7 +1006,7 @@ static bool IsAsyncMethod(MethodInfo method)
// If the parameter is required and there's no argument specified for it, throw.
if (!hasDefaultValue)
{
- Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
+ Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{argumentName}'.");
}
// Otherwise, use the optional parameter's default value.
@@ -1217,9 +1224,11 @@ private static bool IsAIContentRelatedType(Type type) =>
{
return method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description;
}
- catch (Exception e) when (e is ArgumentNullException or NullReferenceException)
+ catch (Exception e) when (e is ArgumentNullException or NullReferenceException or IndexOutOfRangeException)
{
- // DynamicMethod return parameters don't support GetCustomAttribute.
+ // DynamicMethod return parameters don't support GetCustomAttribute. Additionally, on .NET Framework,
+ // querying inherited attributes on the return parameter of an overriding method can throw
+ // IndexOutOfRangeException. In either case, treat the description as absent.
return null;
}
}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
index 094bb09337a..ff80e6978ed 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
@@ -50,7 +50,7 @@ public AIFunctionFactoryOptions()
/// Gets or sets the name to use for the function.
///
- /// The name to use for the function. The default value is a name derived from the passed or (for example, via a on the method).
+ /// The name to use for the function. The default value is a name derived from the passed or (for example, via an or on the method).
///
public string? Name { get; set; }
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
new file mode 100644
index 00000000000..6eb0ba15d42
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
@@ -0,0 +1,31 @@
+// 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.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Specifies the name to use for an .
+///
+/// The name is the identifier a model sees and uses to invoke a function.
+/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
+[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class AIFunctionNameAttribute : Attribute
+{
+ /// Initializes a new instance of the class.
+ /// The name to use for the function.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public AIFunctionNameAttribute(string name)
+ {
+ Name = Throw.IfNullOrWhitespace(name);
+ }
+
+ /// Gets the name to use for the function.
+ public string Name { get; }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
new file mode 100644
index 00000000000..2550b779c03
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
@@ -0,0 +1,31 @@
+// 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.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Specifies the schema name to use for an parameter.
+///
+/// The name is the identifier a model sees and uses when supplying an argument to a function.
+/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
+///
+[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
+[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class AIParameterNameAttribute : Attribute
+{
+ /// Initializes a new instance of the class.
+ /// The schema name to use for the parameter.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public AIParameterNameAttribute(string name)
+ {
+ Name = Throw.IfNullOrWhitespace(name);
+ }
+
+ /// Gets the schema name to use for the parameter.
+ public string Name { get; }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
index 2d105bc2a32..dc4542d4ae7 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
@@ -417,6 +417,38 @@
}
]
},
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.AIFunctionNameAttribute : System.Attribute",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.AIFunctionNameAttribute.AIFunctionNameAttribute(string name);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "string Microsoft.Extensions.AI.AIFunctionNameAttribute.Name { get; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.AIParameterNameAttribute : System.Attribute",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.AIParameterNameAttribute.AIParameterNameAttribute(string name);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "string Microsoft.Extensions.AI.AIParameterNameAttribute.Name { get; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
{
"Type": "readonly struct Microsoft.Extensions.AI.AIJsonSchemaCreateContext",
"Stage": "Stable",
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 c7d2a34f005..9913c33a18f 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
@@ -10,6 +10,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
@@ -128,14 +129,20 @@ public static JsonElement CreateFunctionJsonSchema(
serializerOptions,
inferenceOptions);
- parameterSchemas.Add(parameter.Name, parameterSchema);
+ string parameterSchemaName = GetParameterSchemaName(parameter);
+ if (parameterSchemas.ContainsKey(parameterSchemaName))
+ {
+ Throw.ArgumentException(nameof(method), $"Multiple parameters are mapped to the same name '{parameterSchemaName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
+ }
+
+ parameterSchemas.Add(parameterSchemaName, parameterSchema);
bool isRequired = !parameter.IsOptional && !hasDefaultValue;
#if NET || NETFRAMEWORK
isRequired = isRequired || parameter.GetCustomAttribute(inherit: true) is not null;
#endif
if (isRequired)
{
- (requiredProperties ??= []).Add((JsonNode)parameter.Name);
+ (requiredProperties ??= []).Add((JsonNode)parameterSchemaName);
}
}
@@ -282,12 +289,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
// to accommodate the fact that they're being nested inside of a higher-level schema.
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.
+ // Fix up any $ref URIs to match the path from the root document. The schema name becomes a
+ // JSON Pointer segment, so escape it per RFC 6901 ('~' => "~0", '/' => "~1").
+ string parameterSchemaName = EscapeJsonPointerSegment(GetParameterSchemaName(parameter));
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}/{parameter.Name}"
- : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}";
+ ? $"#/{PropertiesPropertyName}/{parameterSchemaName}"
+ : $"#/{PropertiesPropertyName}/{parameterSchemaName}/{refUri.AsMemory("#/".Length)}";
objSchema[RefPropertyName] = (JsonNode)refUri;
}
@@ -861,6 +870,30 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou
return false;
}
+ internal static string GetParameterSchemaName(ParameterInfo parameter) =>
+ parameter.GetCustomAttribute(inherit: true)?.Name ?? parameter.Name!;
+
+ private static string EscapeJsonPointerSegment(string segment)
+ {
+ if (segment.IndexOfAny(['~', '/']) < 0)
+ {
+ return segment;
+ }
+
+ StringBuilder sb = new(segment.Length + 2);
+ foreach (char c in segment)
+ {
+ _ = c switch
+ {
+ '~' => sb.Append("~0"),
+ '/' => sb.Append("~1"),
+ _ => sb.Append(c),
+ };
+ }
+
+ return sb.ToString();
+ }
+
///
/// Checks whether a parameter is an F# optional parameter declared with the ?param syntax.
/// F# optional parameters are annotated with Microsoft.FSharp.Core.OptionalArgumentAttribute
diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs
index 4b4925850a4..abddbf61c5a 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -54,6 +54,7 @@ internal static class Experiments
internal const string AIMcpServers = AIExperiments;
internal const string AIFunctionApprovals = AIExperiments;
internal const string AIApprovalsInvocationRequired = AIExperiments;
+ internal const string AIFunctionAndParameterName = AIExperiments;
internal const string AIChatReduction = AIExperiments;
internal const string AIToolSearch = AIExperiments;
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs
new file mode 100644
index 00000000000..ec439f4885c
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs
@@ -0,0 +1,24 @@
+// 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 Xunit;
+
+namespace Microsoft.Extensions.AI;
+
+public class AINameAttributesTests
+{
+ [Fact]
+ public void AIFunctionNameAttribute_InvalidArguments_Throw()
+ {
+ Assert.Throws("name", () => new AIFunctionNameAttribute(null!));
+ Assert.Throws("name", () => new AIFunctionNameAttribute(" "));
+ }
+
+ [Fact]
+ public void AIParameterNameAttribute_InvalidArguments_Throw()
+ {
+ Assert.Throws("name", () => new AIParameterNameAttribute(null!));
+ Assert.Throws("name", () => new AIParameterNameAttribute(" "));
+ }
+}
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 606fdea9721..3fd1169ebdf 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -19,6 +19,7 @@
using Xunit;
#pragma warning disable SA1114 // parameter list should follow declaration
+#pragma warning disable S1144 // Unused private types or members should be removed (members are used via reflection in schema tests)
namespace Microsoft.Extensions.AI;
@@ -563,6 +564,25 @@ static void TestMethod(int x, int y)
Assert.Equal("Method description", descElement.GetString());
}
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForTitle()
+ {
+ MethodInfo method = ((Action)MethodWithAIFunctionName).Method;
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
+
+ // The schema title is derived from the method name, not from AIFunctionNameAttribute.
+ Assert.True(schema.TryGetProperty("title", out JsonElement titleElement));
+ Assert.Equal(nameof(MethodWithAIFunctionName), titleElement.GetString());
+ Assert.NotEqual("my_tool", titleElement.GetString());
+ }
+
+ // Local functions get unspeakable names; define as a private method instead.
+ [AIFunctionName("my_tool")]
+ private static void MethodWithAIFunctionName()
+ {
+ // Test method for schema generation
+ }
+
[Fact]
public static void CreateFunctionJsonSchema_DisplayNameAttribute_CanBeOverridden()
{
@@ -580,6 +600,63 @@ static void TestMethod()
Assert.Equal("override_title", titleElement.GetString());
}
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName()
+ {
+ static void TestMethod([AIParameterName("custom_property_name")] string select, int top)
+ {
+ // Test method for schema generation
+ }
+
+ var method = ((Action)TestMethod).Method;
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
+
+ JsonElement properties = schema.GetProperty("properties");
+ Assert.True(properties.TryGetProperty("custom_property_name", out _));
+ Assert.False(properties.TryGetProperty("select", out _));
+
+ string[] required = schema.GetProperty("required").EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Contains("custom_property_name", required);
+ Assert.Contains("top", required);
+ }
+
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_DuplicateNamesThrow()
+ {
+ static void DuplicateByAttribute([AIParameterName("dup")] string first, [AIParameterName("dup")] string second)
+ {
+ // Test method for schema generation
+ }
+
+ ArgumentException ex = Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByAttribute).Method));
+ Assert.Contains("dup", ex.Message);
+ Assert.Equal("method", ex.ParamName);
+
+ static void DuplicateByCollision([AIParameterName("filter")] string select, string filter)
+ {
+ // Test method for schema generation
+ }
+
+ ArgumentException ex2 = Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByCollision).Method));
+ Assert.Contains("filter", ex2.Message);
+ Assert.Equal("method", ex2.ParamName);
+ }
+
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJsonPointerSegment()
+ {
+ JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
+ static void TestMethod([AIParameterName("a/b~c")] RecursiveNode node)
+ {
+ // Test method for schema generation
+ }
+
+ string schema = AIJsonUtilities.CreateFunctionJsonSchema(((Action)TestMethod).Method, serializerOptions: options).ToString();
+
+ Assert.Contains("#/properties/a~1b~0c", schema);
+ Assert.DoesNotContain("#/properties/a/b~c", schema);
+ }
+
[Fact]
public static void CreateJsonSchema_CanBeBoolean()
{
@@ -1862,6 +1939,11 @@ public DerivedToolCallContent()
public int CustomValue { get; set; }
}
+ private sealed class RecursiveNode
+ {
+ public RecursiveNode? Next { get; set; }
+ }
+
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))]
[JsonSerializable(typeof(DerivedAIContent))]
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 1bd803aded3..cbe6e2812a5 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -10,6 +10,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,7 @@
#pragma warning disable IDE0004 // Remove Unnecessary Cast
#pragma warning disable S103 // Lines should not be too long
#pragma warning disable S107 // Methods should not have too many parameters
+#pragma warning disable S1144 // Unused private types or members should be removed (accessed via reflection)
#pragma warning disable S2760 // Sequential tests should not check the same condition
#pragma warning disable S3358 // Ternary operators should not be nested
#pragma warning disable S5034 // "ValueTask" should be consumed correctly
@@ -115,6 +117,7 @@ public async Task Parameters_MissingRequiredParametersFail_Async()
AIFunctionFactory.Create((string? theParam) => theParam + " " + theParam),
AIFunctionFactory.Create((int theParam) => theParam * 2),
AIFunctionFactory.Create((int? theParam) => theParam * 2),
+ AIFunctionFactory.Create(([AIParameterName("theParam")] string otherName) => otherName),
];
foreach (AIFunction f in funcs)
@@ -315,6 +318,172 @@ public void Metadata_DisplayNameAttribute()
Assert.Contains("Metadata_DisplayNameAttribute", func.Name); // Will contain the lambda method name
}
+ [Fact]
+ public void Metadata_AIFunctionNameAttribute()
+ {
+ Func funcWithAttribute = [AIFunctionName("get_user")] () => "test";
+ AIFunction func = AIFunctionFactory.Create(funcWithAttribute);
+ Assert.Equal("get_user", func.Name);
+
+ Func funcWithBoth = [AIFunctionName("my_function")][DisplayName("display_name")] () => "test";
+ func = AIFunctionFactory.Create(funcWithBoth);
+ Assert.Equal("my_function", func.Name);
+
+ func = AIFunctionFactory.Create(funcWithAttribute, name: "explicit_name");
+ Assert.Equal("explicit_name", func.Name);
+
+ func = AIFunctionFactory.Create(funcWithAttribute, new AIFunctionFactoryOptions { Name = "options_name" });
+ Assert.Equal("options_name", func.Name);
+ }
+
+ [Fact]
+ public void Metadata_AIFunctionAndParameterNameAttributes_PreservedByAsDeclarationOnly()
+ {
+ AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
+
+ AIFunctionDeclaration declaration = func.AsDeclarationOnly();
+
+ Assert.Equal("my_tool", declaration.Name);
+ Assert.Equal(func.JsonSchema.ToString(), declaration.JsonSchema.ToString());
+ Assert.Contains("my_param", declaration.JsonSchema.ToString());
+ Assert.IsNotAssignableFrom(declaration);
+ }
+
+ [Fact]
+ public async Task Parameters_MappedByAIParameterNameAttribute_Async()
+ {
+ AIFunction func = AIFunctionFactory.Create(([AIParameterName("$select")] string select, int top) => select + top);
+
+ AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new() { ["$select"] = "Name", ["top"] = 2 }));
+ }
+
+ [Fact]
+ public void Parameters_AIParameterNameAttribute_OverridesSchemaPropertyName()
+ {
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("my_param")] string myParam, int top) => myParam + top);
+
+ JsonElement expectedSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": {
+ "my_param": { "type": "string" },
+ "top": { "type": "integer" }
+ },
+ "required": ["my_param", "top"]
+ }
+ """).RootElement;
+
+ AssertExtensions.EqualJsonValues(expectedSchema, func.JsonSchema);
+ }
+
+ [Fact]
+ public async Task Parameters_AIParameterNameAttribute_StrictUnmappedMemberHandling_Async()
+ {
+ JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ };
+
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("my_param")] string myParam) => myParam,
+ new AIFunctionFactoryOptions { SerializerOptions = strictOptions });
+
+ // The overridden name is "expected", so it passes strict validation.
+ AssertExtensions.EqualFunctionCallResults("Name", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
+
+ // The underlying C# name is now an unexpected argument.
+ ArgumentException ex = await Assert.ThrowsAsync("arguments", async () =>
+ await func.InvokeAsync(new() { ["myParam"] = "Name" }));
+ Assert.Contains("myParam", ex.Message);
+ }
+
+ [Fact]
+ public async Task Parameters_AIParameterNameAttribute_InheritedByOverride_Async()
+ {
+ MethodInfo overrideMethod = typeof(MyDerivedType).GetMethod(nameof(MyDerivedType.Method))!;
+ AIFunction func = AIFunctionFactory.Create(overrideMethod, new MyDerivedType());
+
+ Assert.Contains("my_param", func.JsonSchema.ToString());
+ Assert.DoesNotContain("\"myParam\"", func.JsonSchema.ToString());
+
+ AssertExtensions.EqualFunctionCallResults("param='Name'", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
+ }
+
+ [Fact]
+ public void Parameters_AIParameterNameAttribute_EscapesJsonPointerRef()
+ {
+ JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
+
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("a/b~c")] AIParameterNameAttributeRecursiveNode node) => node.ToString(),
+ new AIFunctionFactoryOptions { SerializerOptions = options });
+
+ string schema = func.JsonSchema.ToString();
+
+ Assert.Contains("#/properties/a~1b~0c", schema);
+ Assert.DoesNotContain("#/properties/a/b~c", schema);
+ }
+
+ [Fact]
+ public void Parameters_AIParameterNameAttribute_DuplicateNames_Throw()
+ {
+ ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second));
+ Assert.Contains("dup", ex.Message);
+ Assert.Equal("method", ex.ParamName);
+
+ ArgumentException ex2 = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("filter")] string select, string filter) => select + filter));
+ Assert.Contains("filter", ex2.Message);
+ Assert.Equal("method", ex2.ParamName);
+ }
+
+ [Fact]
+ public void Parameters_AIParameterNameAttribute_DuplicateNames_ExcludedFromSchema()
+ {
+ // Validates that collision detection occurs in ExpectedArgumentNames collection
+ // even when one of the colliding parameters is excluded from schema generation.
+ // This addresses the concern that collisions might go undetected when
+ // ExcludeFromSchema = true for one of the parameters.
+
+ var options = new AIFunctionFactoryOptions
+ {
+ ConfigureParameterBinding = p => p.Name == "second"
+ ? new AIFunctionFactoryOptions.ParameterBindingOptions { ExcludeFromSchema = true }
+ : default
+ };
+
+ ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second,
+ options));
+ Assert.Contains("dup", ex.Message);
+ Assert.Contains("AIParameterNameAttribute", ex.Message);
+ Assert.Equal("method", ex.ParamName);
+ }
+
+ [Fact]
+ public void AIFunctionFactory_InheritedDescriptionAttributes_OnOverride()
+ {
+ MethodInfo overrideMethod = typeof(DerivedDescribed).GetMethod(nameof(DerivedDescribed.Compute))!;
+ AIFunction f = AIFunctionFactory.Create(overrideMethod, new DerivedDescribed());
+
+ Assert.Equal("The compute method", f.Description);
+
+ JsonElement valueParam = f.JsonSchema.GetProperty("properties").GetProperty("value");
+ Assert.Equal("The input value", valueParam.GetProperty("description").GetString());
+
+ Assert.NotNull(f.ReturnJsonSchema);
+ Assert.Equal("integer", f.ReturnJsonSchema!.Value.GetProperty("type").GetString());
+#if NET
+ // On modern .NET the return-parameter inheritance walk is fixed, so the inherited description is read.
+ Assert.Equal("The computed result", f.ReturnJsonSchema!.Value.GetProperty("description").GetString());
+#else
+ // On .NET Framework the inherited return-parameter description cannot be read and is silently dropped.
+ Assert.False(f.ReturnJsonSchema!.Value.TryGetProperty("description", out _));
+#endif
+ }
+
[Fact]
public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction()
{
@@ -1592,4 +1761,31 @@ public async Task Parameters_UnmappedMemberHandlingDisallow_CustomBindParameter_
[JsonSerializable(typeof(int?))]
[JsonSerializable(typeof(DateTime?))]
private partial class JsonContext : JsonSerializerContext;
+
+ private abstract class MyBaseType
+ {
+ public abstract string Method([AIParameterName("my_param")] string myParam);
+ }
+
+ private sealed class MyDerivedType : MyBaseType
+ {
+ public override string Method(string myParam) => $"param='{myParam}'";
+ }
+
+ private sealed class AIParameterNameAttributeRecursiveNode
+ {
+ public AIParameterNameAttributeRecursiveNode? Next { get; set; }
+ }
+
+ private abstract class BaseDescribed
+ {
+ [Description("The compute method")]
+ [return: Description("The computed result")]
+ public abstract int Compute([Description("The input value")] int value);
+ }
+
+ private sealed class DerivedDescribed : BaseDescribed
+ {
+ public override int Compute(int value) => value;
+ }
}