Skip to content

Commit 99deb9e

Browse files
stephentoubjeffhandley
authored andcommitted
Support [FromKeyedServices] in AIFunctionFactory (#6310)
1 parent 3d8f0c1 commit 99deb9e

File tree

2 files changed

+118
-30
lines changed

2 files changed

+118
-30
lines changed

src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ public static partial class AIFunctionFactory
7171
/// <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
7272
/// </description>
7373
/// </item>
74+
/// <item>
75+
/// <description>
76+
/// By default, parameters attributed with <see cref="FromKeyedServicesAttribute"/> are resolved from the <see cref="AIFunctionArguments.Services"/>
77+
/// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
78+
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
79+
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
80+
/// The handling of such parameters may be overridden via <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
81+
/// </description>
82+
/// </item>
7483
/// </list>
7584
/// All other parameter types are, by default, bound from the <see cref="AIFunctionArguments"/> dictionary passed into <see cref="AIFunction.InvokeAsync"/>
7685
/// and are included in the generated JSON schema. This may be overridden by the <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/> provided
@@ -131,7 +140,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
131140
/// <description>
132141
/// <see cref="CancellationToken"/> parameters are automatically bound to the <see cref="CancellationToken"/> passed into
133142
/// the invocation via <see cref="AIFunction.InvokeAsync"/>'s <see cref="CancellationToken"/> parameter. The parameter is
134-
/// not included in the generated JSON schema. The behavior of <see cref="CancellationToken"/> parameters may not be overridden.
143+
/// not included in the generated JSON schema.
135144
/// </description>
136145
/// </item>
137146
/// <item>
@@ -140,7 +149,6 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
140149
/// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
141150
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
142151
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
143-
/// The handling of <see cref="IServiceProvider"/> parameters may be overridden via <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
144152
/// </description>
145153
/// </item>
146154
/// <item>
@@ -149,8 +157,15 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
149157
/// passed into <see cref="AIFunction.InvokeAsync"/> and are not included in the JSON schema. If the <see cref="AIFunctionArguments"/>
150158
/// instance passed to <see cref="AIFunction.InvokeAsync"/> is <see langword="null"/>, the <see cref="AIFunction"/> implementation
151159
/// manufactures an empty instance, such that parameters of type <see cref="AIFunctionArguments"/> may always be satisfied, whether
152-
/// optional or not. The handling of <see cref="AIFunctionArguments"/> parameters may be overridden via
153-
/// <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
160+
/// optional or not.
161+
/// </description>
162+
/// </item>
163+
/// <item>
164+
/// <description>
165+
/// By default, parameters attributed with <see cref="FromKeyedServicesAttribute"/> are resolved from the <see cref="AIFunctionArguments.Services"/>
166+
/// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
167+
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
168+
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
154169
/// </description>
155170
/// </item>
156171
/// </list>
@@ -236,6 +251,15 @@ public static AIFunction Create(Delegate method, string? name = null, string? de
236251
/// <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
237252
/// </description>
238253
/// </item>
254+
/// <item>
255+
/// <description>
256+
/// By default, parameters attributed with <see cref="FromKeyedServicesAttribute"/> are resolved from the <see cref="AIFunctionArguments.Services"/>
257+
/// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
258+
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
259+
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
260+
/// The handling of such parameters may be overridden via <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
261+
/// </description>
262+
/// </item>
239263
/// </list>
240264
/// All other parameter types are, by default, bound from the <see cref="AIFunctionArguments"/> dictionary passed into <see cref="AIFunction.InvokeAsync"/>
241265
/// and are included in the generated JSON schema. This may be overridden by the <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/> provided
@@ -306,7 +330,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
306330
/// <description>
307331
/// <see cref="CancellationToken"/> parameters are automatically bound to the <see cref="CancellationToken"/> passed into
308332
/// the invocation via <see cref="AIFunction.InvokeAsync"/>'s <see cref="CancellationToken"/> parameter. The parameter is
309-
/// not included in the generated JSON schema. The behavior of <see cref="CancellationToken"/> parameters may not be overridden.
333+
/// not included in the generated JSON schema.
310334
/// </description>
311335
/// </item>
312336
/// <item>
@@ -315,7 +339,6 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
315339
/// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
316340
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
317341
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
318-
/// The handling of <see cref="IServiceProvider"/> parameters may be overridden via <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
319342
/// </description>
320343
/// </item>
321344
/// <item>
@@ -324,8 +347,15 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
324347
/// passed into <see cref="AIFunction.InvokeAsync"/> and are not included in the JSON schema. If the <see cref="AIFunctionArguments"/>
325348
/// instance passed to <see cref="AIFunction.InvokeAsync"/> is <see langword="null"/>, the <see cref="AIFunction"/> implementation
326349
/// manufactures an empty instance, such that parameters of type <see cref="AIFunctionArguments"/> may always be satisfied, whether
327-
/// optional or not. The handling of <see cref="AIFunctionArguments"/> parameters may be overridden via
328-
/// <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
350+
/// optional or not.
351+
/// </description>
352+
/// </item>
353+
/// <item>
354+
/// <description>
355+
/// By default, parameters attributed with <see cref="FromKeyedServicesAttribute"/> are resolved from the <see cref="AIFunctionArguments.Services"/>
356+
/// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
357+
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
358+
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
329359
/// </description>
330360
/// </item>
331361
/// </list>
@@ -426,6 +456,15 @@ public static AIFunction Create(MethodInfo method, object? target, string? name
426456
/// <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
427457
/// </description>
428458
/// </item>
459+
/// <item>
460+
/// <description>
461+
/// By default, parameters attributed with <see cref="FromKeyedServicesAttribute"/> are resolved from the <see cref="AIFunctionArguments.Services"/>
462+
/// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided,
463+
/// <see cref="AIFunctionArguments.Services"/> is allowed to be <see langword="null"/>; otherwise, <see cref="AIFunctionArguments.Services"/>
464+
/// must be non-<see langword="null"/>, or else the invocation will fail with an exception due to the required nature of the parameter.
465+
/// The handling of such parameters may be overridden via <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/>.
466+
/// </description>
467+
/// </item>
429468
/// </list>
430469
/// All other parameter types are, by default, bound from the <see cref="AIFunctionArguments"/> dictionary passed into <see cref="AIFunction.InvokeAsync"/>
431470
/// and are included in the generated JSON schema. This may be overridden by the <see cref="AIFunctionFactoryOptions.ConfigureParameterBinding"/> provided
@@ -668,6 +707,13 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
668707
return false;
669708
}
670709

710+
// If the parameter is attributed as [FromKeyedServices], exclude it, as we'll instead
711+
// get its value from the IServiceProvider.
712+
if (parameterInfo.GetCustomAttribute<FromKeyedServicesAttribute>(inherit: true) is not null)
713+
{
714+
return false;
715+
}
716+
671717
// If there was an existing IncludeParameter delegate, now defer to it as we've
672718
// excluded everything we need to exclude.
673719
if (key.SchemaOptions.IncludeParameter is { } existingIncludeParameter)
@@ -806,6 +852,25 @@ static bool IsAsyncMethod(MethodInfo method)
806852
};
807853
}
808854

855+
// For [FromKeyedServices] parameters, we bind to the services passed directly to InvokeAsync via AIFunctionArguments.
856+
if (parameter.GetCustomAttribute<FromKeyedServicesAttribute>(inherit: true) is { } keyedAttr)
857+
{
858+
return (arguments, _) =>
859+
{
860+
if ((arguments.Services as IKeyedServiceProvider)?.GetKeyedService(parameterType, keyedAttr.Key) is { } service)
861+
{
862+
return service;
863+
}
864+
865+
if (!parameter.HasDefaultValue)
866+
{
867+
Throw.ArgumentException(nameof(arguments), $"No service of type '{parameterType}' with key '{keyedAttr.Key}' was found.");
868+
}
869+
870+
return parameter.DefaultValue;
871+
};
872+
}
873+
809874
// For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary.
810875
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
811876
JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(parameterType);

test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -427,46 +427,69 @@ public async Task Create_NoInstance_DisposableAndAsyncDisposableInstanceCreatedD
427427
}
428428

429429
[Fact]
430-
public async Task ConfigureParameterBinding_CanBeUsedToSupportFromKeyedServices()
430+
public async Task FromKeyedServices_ResolvesFromServiceProvider()
431431
{
432432
MyService service = new(42);
433433

434434
ServiceCollection sc = new();
435435
sc.AddKeyedSingleton("key", service);
436436
IServiceProvider sp = sc.BuildServiceProvider();
437437

438-
AIFunction f = AIFunctionFactory.Create(
439-
([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger,
440-
new AIFunctionFactoryOptions
441-
{
442-
ConfigureParameterBinding = p =>
443-
{
444-
if (p.GetCustomAttribute<FromKeyedServicesAttribute>() is { } attr)
445-
{
446-
return new()
447-
{
448-
BindParameter = (p, a) =>
449-
(a.Services as IKeyedServiceProvider)?.GetKeyedService(p.ParameterType, attr.Key) is { } s ? s :
450-
p.HasDefaultValue ? p.DefaultValue :
451-
throw new ArgumentException($"Unable to resolve argument for '{p.Name}'."),
452-
ExcludeFromSchema = true
453-
};
454-
}
438+
AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger);
455439

456-
return default;
457-
},
458-
});
440+
Assert.Contains("myInteger", f.JsonSchema.ToString());
441+
Assert.DoesNotContain("service", f.JsonSchema.ToString());
442+
443+
Exception e = await Assert.ThrowsAsync<ArgumentException>(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask());
444+
Assert.Contains("No service of type", e.Message);
445+
446+
var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
447+
Assert.Contains("43", result?.ToString());
448+
}
449+
450+
[Fact]
451+
public async Task FromKeyedServices_NullKeysBindToNonKeyedServices()
452+
{
453+
MyService service = new(42);
454+
455+
ServiceCollection sc = new();
456+
sc.AddSingleton(service);
457+
IServiceProvider sp = sc.BuildServiceProvider();
458+
459+
AIFunction f = AIFunctionFactory.Create(([FromKeyedServices(null!)] MyService service, int myInteger) => service.Value + myInteger);
459460

460461
Assert.Contains("myInteger", f.JsonSchema.ToString());
461462
Assert.DoesNotContain("service", f.JsonSchema.ToString());
462463

463464
Exception e = await Assert.ThrowsAsync<ArgumentException>(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask());
464-
Assert.Contains("Unable to resolve", e.Message);
465+
Assert.Contains("No service of type", e.Message);
465466

466467
var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
467468
Assert.Contains("43", result?.ToString());
468469
}
469470

471+
[Fact]
472+
public async Task FromKeyedServices_OptionalDefaultsToNull()
473+
{
474+
MyService service = new(42);
475+
476+
ServiceCollection sc = new();
477+
sc.AddKeyedSingleton("key", service);
478+
IServiceProvider sp = sc.BuildServiceProvider();
479+
480+
AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService? service = null, int myInteger = 0) =>
481+
service is null ? "null " + 1 : (service.Value + myInteger).ToString());
482+
483+
Assert.Contains("myInteger", f.JsonSchema.ToString());
484+
Assert.DoesNotContain("service", f.JsonSchema.ToString());
485+
486+
var result = await f.InvokeAsync(new() { ["myInteger"] = 1 });
487+
Assert.Contains("null 1", result?.ToString());
488+
489+
result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp });
490+
Assert.Contains("43", result?.ToString());
491+
}
492+
470493
[Fact]
471494
public async Task ConfigureParameterBinding_CanBeUsedToSupportFromContext()
472495
{

0 commit comments

Comments
 (0)