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
Comment thread
jozkee marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="name">
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// any <see cref="AIFunctionNameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
Expand Down Expand Up @@ -313,7 +314,8 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
/// </param>
/// <param name="name">
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// any <see cref="AIFunctionNameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
Expand Down Expand Up @@ -708,7 +710,7 @@ private sealed class ReflectionAIFunctionDescriptor
private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken);

/// <summary>
/// Gets or creates a descriptors using the specified method and options.
/// Gets or creates a descriptor using the specified method and options.
/// </summary>
public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options)
{
Expand Down Expand Up @@ -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.");
}
}
}

Expand All @@ -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<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Name = key.Name ?? key.Method.GetCustomAttribute<AIFunctionNameAttribute>(inherit: true)?.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
JsonSerializerOptions = serializerOptions;
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1217,9 +1224,11 @@ private static bool IsAIContentRelatedType(Type type) =>
{
return method.ReturnParameter.GetCustomAttribute<DescriptionAttribute>(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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public AIFunctionFactoryOptions()

/// <summary>Gets or sets the name to use for the function.</summary>
/// <value>
/// The name to use for the function. The default value is a name derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/> (for example, via a <see cref="DisplayNameAttribute"/> on the method).
/// The name to use for the function. The default value is a name derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/> (for example, via an <see cref="AIFunctionNameAttribute"/> or <see cref="DisplayNameAttribute"/> on the method).
/// </value>
public string? Name { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Specifies the name to use for an <see cref="AIFunction"/>.</summary>
/// <remarks>
/// 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.
/// </remarks>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class AIFunctionNameAttribute : Attribute
{
/// <summary>Initializes a new instance of the <see cref="AIFunctionNameAttribute"/> class.</summary>
/// <param name="name">The name to use for the function.</param>
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> is empty or composed entirely of whitespace.</exception>
public AIFunctionNameAttribute(string name)
{
Name = Throw.IfNullOrWhitespace(name);
}

/// <summary>Gets the name to use for the function.</summary>
public string Name { get; }
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Specifies the schema name to use for an <see cref="AIFunction"/> parameter.</summary>
/// <remarks>
/// 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.
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class AIParameterNameAttribute : Attribute
{
/// <summary>Initializes a new instance of the <see cref="AIParameterNameAttribute"/> class.</summary>
/// <param name="name">The schema name to use for the parameter.</param>
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> is empty or composed entirely of whitespace.</exception>
public AIParameterNameAttribute(string name)
{
Name = Throw.IfNullOrWhitespace(name);
}

/// <summary>Gets the schema name to use for the parameter.</summary>
public string Name { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RequiredAttribute>(inherit: true) is not null;
#endif
if (isRequired)
{
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
(requiredProperties ??= []).Add((JsonNode)parameterSchemaName);
}
}

Expand Down Expand Up @@ -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<string>();
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;
}
Expand Down Expand Up @@ -861,6 +870,30 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou
return false;
}

internal static string GetParameterSchemaName(ParameterInfo parameter) =>
parameter.GetCustomAttribute<AIParameterNameAttribute>(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();
}

/// <summary>
/// Checks whether a parameter is an F# optional parameter declared with the ?param syntax.
/// F# optional parameters are annotated with Microsoft.FSharp.Core.OptionalArgumentAttribute
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>("name", () => new AIFunctionNameAttribute(null!));
Assert.Throws<ArgumentException>("name", () => new AIFunctionNameAttribute(" "));
}

[Fact]
public void AIParameterNameAttribute_InvalidArguments_Throw()
{
Assert.Throws<ArgumentNullException>("name", () => new AIParameterNameAttribute(null!));
Assert.Throws<ArgumentException>("name", () => new AIParameterNameAttribute(" "));
}
}
Loading