Skip to content

Commit 3b93ae5

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 3b93ae5

File tree

7 files changed

+129
-8
lines changed

7 files changed

+129
-8
lines changed

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/Extensions/ConfigurableChatClient.cs

Lines changed: 24 additions & 3 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;
@@ -54,13 +55,24 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri
5455
/// <summary>Disposes the client and stops monitoring configuration changes.</summary>
5556
public void Dispose() => reloadToken?.Dispose();
5657

58+
/// <inheritdoc/>
59+
public object? GetService(Type serviceType, object? serviceKey = null)
60+
{
61+
if (serviceType == typeof(ConfigurableChatClientMetadata) &&
62+
innerClient.GetService(typeof(ChatClientMetadata)) is ChatClientMetadata innerMetadata)
63+
return new ConfigurableChatClientMetadata(id, section, innerMetadata.ProviderName, innerMetadata.ProviderUri, innerMetadata.DefaultModelId);
64+
65+
var service = innerClient.GetService(serviceType, serviceKey);
66+
if (service is ChatClientMetadata metadata)
67+
return new ConfigurableChatClientMetadata(id, section, metadata.ProviderName, metadata.ProviderUri, metadata.DefaultModelId);
68+
69+
return service;
70+
}
71+
5772
/// <inheritdoc/>
5873
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
5974
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
6075
/// <inheritdoc/>
61-
public object? GetService(Type serviceType, object? serviceKey = null)
62-
=> innerClient.GetService(serviceType, serviceKey);
63-
/// <inheritdoc/>
6476
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
6577
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
6678

@@ -160,3 +172,12 @@ internal class ConfigurableAzureOptions : AzureOpenAIClientOptions
160172
public string? ModelId { get; set; }
161173
}
162174
}
175+
176+
/// <summary>Provides metadata about a configurable <see cref="IChatClient"/>.</summary>
177+
public class ConfigurableChatClientMetadata(string id, string section, string? providerName = null, Uri? providerUri = null, string? defaultModelId = null) : ChatClientMetadata(providerName, providerUri, defaultModelId)
178+
{
179+
/// <summary>Gets the unique identifier for the client.</summary>
180+
public string Id => id;
181+
/// <summary>Gets the configuration section for the client.</summary>
182+
public string Section => section;
183+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Devlooped.Extensions.AI;
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/Tests/ConfigurableAgentTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ public void CanConfigureAgent()
3838
Assert.Equal("Helpful chat agent", agent.Description);
3939
}
4040

41+
[Fact]
42+
public void CanGetFromAlternativeKey()
43+
{
44+
var builder = new HostApplicationBuilder();
45+
46+
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
47+
{
48+
["ai:clients:Chat:modelid"] = "gpt-4.1-nano",
49+
["ai:clients:Chat:apikey"] = "sk-asdfasdf",
50+
// NOTE: mismatched case in client id
51+
["ai:agents:bot:client"] = "chat",
52+
});
53+
54+
builder.AddAIAgents();
55+
56+
var app = builder.Build();
57+
58+
var agent = app.Services.GetRequiredKeyedService<AIAgent>(new ServiceKey("Bot"));
59+
60+
Assert.Equal("bot", agent.Name);
61+
Assert.Same(agent, app.Services.GetIAAgent("Bot"));
62+
}
63+
4164
[Fact]
4265
public void DedentsDescriptionAndInstructions()
4366
{

src/Tests/ConfigurableClientTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ public void CanConfigureClients()
3333
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
3434
}
3535

36+
[Fact]
37+
public void CanGetFromAlternativeKey()
38+
{
39+
var configuration = new ConfigurationBuilder()
40+
.AddInMemoryCollection(new Dictionary<string, string?>
41+
{
42+
["ai:clients:Grok:modelid"] = "grok-4-fast",
43+
["ai:clients:Grok:ApiKey"] = "xai-asdfasdf",
44+
["ai:clients:Grok:endpoint"] = "https://api.x.ai",
45+
})
46+
.Build();
47+
48+
var services = new ServiceCollection()
49+
.AddSingleton<IConfiguration>(configuration)
50+
.AddChatClients(configuration)
51+
.BuildServiceProvider();
52+
53+
var grok = services.GetRequiredKeyedService<IChatClient>(new ServiceKey("grok"));
54+
55+
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
56+
Assert.Same(grok, services.GetChatClient("grok"));
57+
}
58+
3659
[Fact]
3760
public void CanOverrideClientId()
3861
{

0 commit comments

Comments
 (0)