Skip to content

Commit b2ff66d

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 b2ff66d

11 files changed

+114
-34
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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using Azure;
44
using Azure.AI.Inference;
55
using Azure.AI.OpenAI;
6-
using Azure.Core;
76
using Devlooped.Extensions.AI.Grok;
87
using Devlooped.Extensions.AI.OpenAI;
98
using Microsoft.Extensions.AI;
@@ -58,11 +57,11 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri
5857
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
5958
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
6059
/// <inheritdoc/>
61-
public object? GetService(Type serviceType, object? serviceKey = null)
62-
=> innerClient.GetService(serviceType, serviceKey);
63-
/// <inheritdoc/>
6460
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
6561
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
62+
/// <inheritdoc/>
63+
public object? GetService(Type serviceType, object? serviceKey = null)
64+
=> innerClient.GetService(serviceType, serviceKey);
6665

6766
/// <summary>Exposes the optional <see cref="ClientPipelineOptions"/> configured for the client.</summary>
6867
[EditorBrowsable(EditorBrowsableState.Never)]
@@ -159,4 +158,4 @@ internal class ConfigurableAzureOptions : AzureOpenAIClientOptions
159158
public string? ApiKey { get; set; }
160159
public string? ModelId { get; set; }
161160
}
162-
}
161+
}

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/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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Microsoft.Extensions.AI;
22
using Microsoft.Extensions.Configuration;
33
using Microsoft.Extensions.DependencyInjection;
4-
using Microsoft.Extensions.Logging;
54

65
namespace Devlooped.Extensions.AI;
76

@@ -33,6 +32,29 @@ public void CanConfigureClients()
3332
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
3433
}
3534

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

src/Tests/Extensions/Attributes.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
#nullable enable
2-
using System;
3-
using System.Collections.Generic;
42
using System.Runtime.InteropServices;
53
using Microsoft.Extensions.Configuration;
64

src/Tests/Extensions/Configuration.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using System.Linq;
4-
using System.Reflection;
5-
using System.Text;
6-
using System.Threading.Tasks;
1+
using System.Reflection;
72
using Microsoft.Extensions.Configuration;
83
using Microsoft.Extensions.DependencyInjection;
94
using Microsoft.Extensions.Options;

src/Tests/Extensions/Logging.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using System.Linq;
4-
using System.Text;
5-
using System.Threading.Tasks;
6-
using Microsoft.Extensions.Logging;
1+
using Microsoft.Extensions.Logging;
72

83
public static class LoggerFactoryExtensions
94
{

0 commit comments

Comments
 (0)