Skip to content

Commit 1de9934

Browse files
committed
Allow case-insensitive agents and clients resolution
Configuration-driven sections can change case when a subsequent provider overrides a section value but uses a different casing (since config is inherently case-insensitive). This means we may end up with a client ID with an unexpected casing at run-time. In order to solve this, we register both clients and agents using an alternative ServiceKey object which performs a comparer-aware comparison (defaults to ordinal ignore case). Users would need to look for this alternative key explicitly, since we don't want to pollute the container with additional string-only registrations. But to improve discoverability of this case-insensitive lookup, we provide GetChatClient and GetAIAgent extension methods for IServiceProvider.
1 parent 8d5bddc commit 1de9934

14 files changed

+118
-199
lines changed

AI.slnx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
<Platform Name="x64" />
55
<Platform Name="x86" />
66
</Configurations>
7+
<Folder Name="/Sample/">
8+
<Project Path="sample/Aspire/Aspire.csproj" Id="6166be22-a13f-4074-91a9-b0f3b3a6c4fe" />
9+
<Project Path="sample/Client/Client.csproj" />
10+
<Project Path="sample/Server/Server.csproj" Id="34619937-085f-453d-bc12-9ab2d4abccb7" />
11+
</Folder>
712
<Project Path="src/Agents/Agents.csproj" Id="90827430-b415-47d6-aac9-2dbe4911b348" />
813
<Project Path="src/Extensions.CodeAnalysis/Extensions.CodeAnalysis.csproj" />
914
<Project Path="src/Extensions/Extensions.csproj" />
10-
<Project Path="src/SampleChat/SampleChat.csproj" Id="63ca9077-db60-473a-813d-d3bb5befdf35" />
1115
<Project Path="src/Tests/Tests.csproj" />
1216
</Solution>

src/Agents/ConfigurableAIAgent.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
9191

9292
var client = services.GetKeyedService<IChatClient>(options?.Client
9393
?? throw new InvalidOperationException($"A client must be specified for agent '{name}' in configuration section '{section}'."))
94-
?? throw new InvalidOperationException($"Specified chat client '{options?.Client}' for agent '{name}' is not registered.");
94+
?? services.GetKeyedService<IChatClient>(new ServiceKey(options!.Client))
95+
?? throw new InvalidOperationException($"Specified chat client '{options!.Client}' for agent '{name}' is not registered.");
9596

9697
var provider = client.GetService<ChatClientMetadata>()?.ProviderName;
9798
ChatOptions? chat = provider == "xai"

src/Agents/AddAIAgentsExtensions.cs renamed to src/Agents/ConfigurableAgentsExtensions.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
using System.ComponentModel;
2+
using Devlooped.Agents.AI;
23
using Devlooped.Extensions.AI;
34
using Microsoft.Agents.AI;
45
using Microsoft.Agents.AI.Hosting;
6+
using Microsoft.Extensions.AI;
57
using Microsoft.Extensions.Configuration;
6-
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.DependencyInjection.Extensions;
79
using Microsoft.Extensions.Hosting;
810

9-
namespace Devlooped.Agents.AI;
11+
namespace Microsoft.Extensions.DependencyInjection;
1012

1113
/// <summary>
1214
/// Adds configuration-driven agents to an application host.
1315
/// </summary>
1416
[EditorBrowsable(EditorBrowsableState.Never)]
15-
public static class AddAIAgentsExtensions
17+
public static class ConfigurableAgentsExtensions
1618
{
1719
/// <summary>
1820
/// Adds AI agents to the host application builder based on configuration.
@@ -52,8 +54,17 @@ public static TBuilder AddAIAgents<TBuilder>(this TBuilder builder, Action<strin
5254

5355
return agent;
5456
});
57+
58+
// Also register for case-insensitive lookup, but without duplicating the entry in
59+
// the AgentCatalog, since that will always resolve from above.
60+
builder.Services.TryAdd(ServiceDescriptor.KeyedSingleton(new ServiceKey(name), (sp, key)
61+
=> sp.GetRequiredKeyedService<AIAgent>(name)));
5562
}
5663

5764
return builder;
5865
}
66+
67+
/// <summary>Gets an AI agent by name (case-insensitive) from the service provider.</summary>
68+
public static AIAgent? GetIAAgent(this IServiceProvider services, string name)
69+
=> services.GetKeyedService<AIAgent>(name) ?? services.GetKeyedService<AIAgent>(new ServiceKey(name));
5970
}

src/Directory.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<Product>Devlooped AI Extensions</Product>
5-
<ImplicitUsings>enable</ImplicitUsings>
5+
<ImplicitUsings>true</ImplicitUsings>
66
<UserSecretsId>6eb457f9-16bc-49c5-81f2-33399b254e04</UserSecretsId>
77

88
<RestoreSources>https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json</RestoreSources>

src/Extensions/ConfigurableChatClient.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.ClientModel.Primitives;
22
using System.ComponentModel;
3+
using System.Security.Cryptography;
34
using Azure;
45
using Azure.AI.Inference;
56
using Azure.AI.OpenAI;
@@ -58,11 +59,11 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri
5859
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
5960
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
6061
/// <inheritdoc/>
61-
public object? GetService(Type serviceType, object? serviceKey = null)
62-
=> innerClient.GetService(serviceType, serviceKey);
63-
/// <inheritdoc/>
6462
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
6563
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
64+
/// <inheritdoc/>
65+
public object? GetService(Type serviceType, object? serviceKey = null)
66+
=> innerClient.GetService(serviceType, serviceKey);
6667

6768
/// <summary>Exposes the optional <see cref="ClientPipelineOptions"/> configured for the client.</summary>
6869
[EditorBrowsable(EditorBrowsableState.Never)]
@@ -159,4 +160,4 @@ internal class ConfigurableAzureOptions : AzureOpenAIClientOptions
159160
public string? ApiKey { get; set; }
160161
public string? ModelId { get; set; }
161162
}
162-
}
163+
}

src/Extensions/AddChatClientsExtensions.cs renamed to src/Extensions/ConfigurableChatClientExtensions.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
using System.ComponentModel;
2+
using Devlooped.Extensions.AI;
23
using Devlooped.Extensions.AI.OpenAI;
34
using Microsoft.Extensions.AI;
45
using Microsoft.Extensions.Configuration;
5-
using Microsoft.Extensions.DependencyInjection;
66
using Microsoft.Extensions.DependencyInjection.Extensions;
77
using Microsoft.Extensions.Hosting;
88
using Microsoft.Extensions.Logging;
99
using OpenAI;
1010

11-
namespace Devlooped.Extensions.AI;
11+
namespace Microsoft.Extensions.DependencyInjection;
1212

1313
/// <summary>
1414
/// Adds configuration-driven chat clients to an application host or service collection.
1515
/// </summary>
1616
[EditorBrowsable(EditorBrowsableState.Never)]
17-
public static class AddChatClientsExtensions
17+
public static class ConfigurableChatClientExtensions
1818
{
1919
/// <summary>
2020
/// Adds configuration-driven chat clients to the host application builder.
@@ -69,11 +69,19 @@ public static IServiceCollection AddChatClients(this IServiceCollection services
6969
return client;
7070
},
7171
options?.Lifetime ?? ServiceLifetime.Singleton));
72+
73+
services.TryAdd(new ServiceDescriptor(typeof(IChatClient), new ServiceKey(id),
74+
factory: (sp, _) => sp.GetRequiredKeyedService<IChatClient>(id),
75+
options?.Lifetime ?? ServiceLifetime.Singleton));
7276
}
7377

7478
return services;
7579
}
7680

81+
/// <summary>Gets a chat client by id (case-insensitive) from the service provider.</summary>
82+
public static IChatClient? GetChatClient(this IServiceProvider services, string id)
83+
=> services.GetKeyedService<IChatClient>(id) ?? services.GetKeyedService<IChatClient>(new ServiceKey(id));
84+
7785
internal class ChatClientOptions : OpenAIClientOptions
7886
{
7987
public string? ApiKey { get; set; }

src/Extensions/ServiceKey.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace Devlooped.Extensions.AI;
2+
3+
/// <summary>
4+
/// An alternative service key that provides more flexible key comparison (case insensitive by default).
5+
/// </summary>
6+
/// <param name="key">The service key for use in the dependency injection container.</param>
7+
/// <param name="comparer">The comparer used for equality comparisons, defaulting to <see cref="StringComparer.OrdinalIgnoreCase"/> if not specified.</param>
8+
public readonly struct ServiceKey(string key, IEqualityComparer<string?>? comparer = default) : IEquatable<ServiceKey>
9+
{
10+
readonly IEqualityComparer<string?> comparer = comparer ?? StringComparer.OrdinalIgnoreCase;
11+
12+
/// <summary>
13+
/// Gets the original value of the service key.
14+
/// </summary>
15+
public string Value => key;
16+
17+
/// <inheritdoc/>
18+
public bool Equals(ServiceKey other) => comparer.Equals(Value, other.Value);
19+
20+
/// <inheritdoc/>
21+
public override bool Equals(object? obj) => obj is ServiceKey k && Equals(k);
22+
23+
/// <inheritdoc/>
24+
public override int GetHashCode() => comparer.GetHashCode(Value);
25+
26+
/// <inheritdoc/>
27+
public override string ToString() => Value;
28+
29+
/// <summary>Compares both keys for equality.</summary>
30+
public static bool operator ==(ServiceKey left, ServiceKey right) => left.Equals(right);
31+
32+
/// <summary>Compares both keys for inequality.</summary>
33+
public static bool operator !=(ServiceKey left, ServiceKey right) => !(left == right);
34+
}

src/SampleChat/AppInitializer.cs

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/SampleChat/Program.cs

Lines changed: 0 additions & 88 deletions
This file was deleted.

src/SampleChat/SampleChat.csproj

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)