diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs
index 5116238763f..29920409fbd 100644
--- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs
+++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs
@@ -71,6 +71,15 @@ public static partial class AIFunctionFactory
/// .
///
///
+ /// -
+ ///
+ /// By default, parameters attributed with are resolved from the
+ /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
+ /// is allowed to be ; otherwise,
+ /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
+ /// The handling of such parameters may be overridden via .
+ ///
+ ///
///
/// All other parameter types are, by default, bound from the dictionary passed into
/// and are included in the generated JSON schema. This may be overridden by the provided
@@ -131,7 +140,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
///
/// parameters are automatically bound to the passed into
/// the invocation via 's parameter. The parameter is
- /// not included in the generated JSON schema. The behavior of parameters may not be overridden.
+ /// not included in the generated JSON schema.
///
///
/// -
@@ -140,7 +149,6 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
/// is allowed to be ; otherwise,
/// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
- /// The handling of parameters may be overridden via .
///
///
/// -
@@ -149,8 +157,15 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// passed into and are not included in the JSON schema. If the
/// instance passed to is , the implementation
/// manufactures an empty instance, such that parameters of type may always be satisfied, whether
- /// optional or not. The handling of parameters may be overridden via
- /// .
+ /// optional or not.
+ ///
+ ///
+ /// -
+ ///
+ /// By default, parameters attributed with are resolved from the
+ /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
+ /// is allowed to be ; otherwise,
+ /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
///
///
///
@@ -236,6 +251,15 @@ public static AIFunction Create(Delegate method, string? name = null, string? de
/// .
///
///
+ /// -
+ ///
+ /// By default, parameters attributed with are resolved from the
+ /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
+ /// is allowed to be ; otherwise,
+ /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
+ /// The handling of such parameters may be overridden via .
+ ///
+ ///
///
/// All other parameter types are, by default, bound from the dictionary passed into
/// and are included in the generated JSON schema. This may be overridden by the provided
@@ -306,7 +330,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
///
/// parameters are automatically bound to the passed into
/// the invocation via 's parameter. The parameter is
- /// not included in the generated JSON schema. The behavior of parameters may not be overridden.
+ /// not included in the generated JSON schema.
///
///
/// -
@@ -315,7 +339,6 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
/// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
/// is allowed to be ; otherwise,
/// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
- /// The handling of parameters may be overridden via .
///
///
/// -
@@ -324,8 +347,15 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
/// passed into and are not included in the JSON schema. If the
/// instance passed to is , the implementation
/// manufactures an empty instance, such that parameters of type may always be satisfied, whether
- /// optional or not. The handling of parameters may be overridden via
- /// .
+ /// optional or not.
+ ///
+ ///
+ /// -
+ ///
+ /// By default, parameters attributed with are resolved from the
+ /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
+ /// is allowed to be ; otherwise,
+ /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
///
///
///
@@ -426,6 +456,15 @@ public static AIFunction Create(MethodInfo method, object? target, string? name
/// .
///
///
+ /// -
+ ///
+ /// By default, parameters attributed with are resolved from the
+ /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
+ /// is allowed to be ; otherwise,
+ /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter.
+ /// The handling of such parameters may be overridden via .
+ ///
+ ///
///
/// All other parameter types are, by default, bound from the dictionary passed into
/// and are included in the generated JSON schema. This may be overridden by the provided
@@ -668,6 +707,13 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
return false;
}
+ // If the parameter is attributed as [FromKeyedServices], exclude it, as we'll instead
+ // get its value from the IServiceProvider.
+ if (parameterInfo.GetCustomAttribute(inherit: true) is not null)
+ {
+ return false;
+ }
+
// If there was an existing IncludeParameter delegate, now defer to it as we've
// excluded everything we need to exclude.
if (key.SchemaOptions.IncludeParameter is { } existingIncludeParameter)
@@ -806,6 +852,25 @@ static bool IsAsyncMethod(MethodInfo method)
};
}
+ // For [FromKeyedServices] parameters, we bind to the services passed directly to InvokeAsync via AIFunctionArguments.
+ if (parameter.GetCustomAttribute(inherit: true) is { } keyedAttr)
+ {
+ return (arguments, _) =>
+ {
+ if ((arguments.Services as IKeyedServiceProvider)?.GetKeyedService(parameterType, keyedAttr.Key) is { } service)
+ {
+ return service;
+ }
+
+ if (!parameter.HasDefaultValue)
+ {
+ Throw.ArgumentException(nameof(arguments), $"No service of type '{parameterType}' with key '{keyedAttr.Key}' was found.");
+ }
+
+ return parameter.DefaultValue;
+ };
+ }
+
// For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary.
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType);
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 9501b4afe7d..b983189dac5 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -427,7 +427,7 @@ public async Task Create_NoInstance_DisposableAndAsyncDisposableInstanceCreatedD
}
[Fact]
- public async Task ConfigureParameterBinding_CanBeUsedToSupportFromKeyedServices()
+ public async Task FromKeyedServices_ResolvesFromServiceProvider()
{
MyService service = new(42);
@@ -435,38 +435,61 @@ public async Task ConfigureParameterBinding_CanBeUsedToSupportFromKeyedServices(
sc.AddKeyedSingleton("key", service);
IServiceProvider sp = sc.BuildServiceProvider();
- AIFunction f = AIFunctionFactory.Create(
- ([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger,
- new AIFunctionFactoryOptions
- {
- ConfigureParameterBinding = p =>
- {
- if (p.GetCustomAttribute() is { } attr)
- {
- return new()
- {
- BindParameter = (p, a) =>
- (a.Services as IKeyedServiceProvider)?.GetKeyedService(p.ParameterType, attr.Key) is { } s ? s :
- p.HasDefaultValue ? p.DefaultValue :
- throw new ArgumentException($"Unable to resolve argument for '{p.Name}'."),
- ExcludeFromSchema = true
- };
- }
+ AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger);
- return default;
- },
- });
+ Assert.Contains("myInteger", f.JsonSchema.ToString());
+ Assert.DoesNotContain("service", f.JsonSchema.ToString());
+
+ Exception e = await Assert.ThrowsAsync(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask());
+ Assert.Contains("No service of type", e.Message);
+
+ var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
+ Assert.Contains("43", result?.ToString());
+ }
+
+ [Fact]
+ public async Task FromKeyedServices_NullKeysBindToNonKeyedServices()
+ {
+ MyService service = new(42);
+
+ ServiceCollection sc = new();
+ sc.AddSingleton(service);
+ IServiceProvider sp = sc.BuildServiceProvider();
+
+ AIFunction f = AIFunctionFactory.Create(([FromKeyedServices(null!)] MyService service, int myInteger) => service.Value + myInteger);
Assert.Contains("myInteger", f.JsonSchema.ToString());
Assert.DoesNotContain("service", f.JsonSchema.ToString());
Exception e = await Assert.ThrowsAsync(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask());
- Assert.Contains("Unable to resolve", e.Message);
+ Assert.Contains("No service of type", e.Message);
var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
Assert.Contains("43", result?.ToString());
}
+ [Fact]
+ public async Task FromKeyedServices_OptionalDefaultsToNull()
+ {
+ MyService service = new(42);
+
+ ServiceCollection sc = new();
+ sc.AddKeyedSingleton("key", service);
+ IServiceProvider sp = sc.BuildServiceProvider();
+
+ AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService? service = null, int myInteger = 0) =>
+ service is null ? "null " + 1 : (service.Value + myInteger).ToString());
+
+ Assert.Contains("myInteger", f.JsonSchema.ToString());
+ Assert.DoesNotContain("service", f.JsonSchema.ToString());
+
+ var result = await f.InvokeAsync(new() { ["myInteger"] = 1 });
+ Assert.Contains("null 1", result?.ToString());
+
+ result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
+ Assert.Contains("43", result?.ToString());
+ }
+
[Fact]
public async Task ConfigureParameterBinding_CanBeUsedToSupportFromContext()
{