diff --git a/AGENTS.md b/AGENTS.md
index 7b43e1d..389b508 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@
- `IClientProvider` and `IClientFactory` are the canonical provider/factory APIs; do not add chat-only compatibility abstractions.
- Resolve section-bound `IClientFactory` instances through `ClientFactoryResolver`; do not register or depend on an unbound singleton `IClientFactory`.
- Provider-created clients should expose the bound provider options through `GetService(typeof(object), "options")` and typed options requests.
-- `AddClients` registers keyed `IClientFactory` (for sections with direct `apikey`) and keyed `IChatClient` (for sections with `modelid`) by full configuration section path. An optional `id` adds a secondary lookup key without replacing the section-path key. There is no separate `AddChatClients` — all registration flows through `AddClients`.
+- `AddClients` registers keyed `IClientFactory` (for sections with direct `apikey`) and keyed `IChatClient` (for sections with `modelid`) by full configuration section path. Alternate lookup keys are registered in priority order (first wins): configured `id` attribute, then generated aliases: lowercase path, dotted path (`:` → `.`), dotted lowercase path, last path segment, last path segment lowercase. There is no separate `AddChatClients` — all registration flows through `AddClients`.
- `IChatClient` registrations use `ConfigurableChatClient` for auto-reload; on every configuration reload, the inner client is recreated via the held `IClientFactory`, which applies defaults through `DefaultsApplyingClientFactory` — there is no separate defaults wrapping at the registration level.
- Keyed `IClientFactory` registrations in `AddClients` are `DefaultsApplyingClientFactory(ConfigurationBoundClientFactory(...))` singletons; the `ConfigurationBoundClientFactory` re-resolves the provider on every `Create*Client` call to reflect configuration changes including provider switches — no reload required for factory-created clients.
- `ConfigurableChatClient` holds an `IClientFactory` reference (not `IClientFactoryResolver`); the public resolver-based constructors wrap the resolver in `ConfigurationBoundClientFactory` for backwards compatibility.
diff --git a/readme.md b/readme.md
index b5542f6..d4e9d80 100644
--- a/readme.md
+++ b/readme.md
@@ -35,7 +35,7 @@ builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnC
builder.AddAIClients();
var app = builder.Build();
-var chat = app.Services.GetChatClient("AI:Clients:Grok");
+var chat = app.Services.GetChatClient("Grok");
```
The default configuration prefix is `ai:clients`. A minimal configuration section looks like this:
@@ -60,10 +60,11 @@ Useful rules:
| --- | --- |
| section has `apikey` | keyed `IClientFactory` |
| section has `modelid` | keyed `IChatClient` |
+| section path ends in `Grok` | adds `Grok` as an implicit lookup key |
| section has `id` | adds an extra lookup key |
| section has `lifetime` | controls the chat client lifetime |
-`IChatClient` and `IClientFactory` registrations are keyed by their full configuration section path. An optional `id` adds a secondary lookup key without replacing the section-path key. `IChatClient` registrations are reloadable. `IClientFactory` registrations stay valid across configuration changes and create fresh clients each time you call `CreateChatClient`, `CreateSpeechToTextClient`, or `CreateTextToSpeechClient`.
+`IChatClient` and `IClientFactory` registrations are keyed by their full configuration section path and by the last section path segment. An optional `id` adds another lookup key without replacing the section-path key. `IChatClient` registrations are reloadable. `IClientFactory` registrations stay valid across configuration changes and create fresh clients each time you call `CreateChatClient`, `CreateSpeechToTextClient`, or `CreateTextToSpeechClient`.
Built-in provider support:
diff --git a/src/Extensions/AIClientExtensions.cs b/src/Extensions/AIClientExtensions.cs
index 4f67c0f..b3692b7 100644
--- a/src/Extensions/AIClientExtensions.cs
+++ b/src/Extensions/AIClientExtensions.cs
@@ -18,8 +18,16 @@ public static class AIClientExtensions
/// that applies (and speech/text-to-speech
/// equivalents) registered via Configure*ClientDefaults. Also registers keyed entries
/// by full section path for sections with a modelid, using for auto-reload
- /// support. A configured id adds a secondary lookup key without replacing the section path key. Chat defaults flow
- /// through the shared factory so they are applied once, including on every configuration reload.
+ /// support. The following alternate lookup keys are registered for each service (first registration wins):
+ ///
+ /// - Configured id attribute from the section (highest-priority alias).
+ /// - Section path, lowercase invariant (e.g., ai:clients:grok for ai:clients:Grok).
+ /// - Section path with : replaced by . (e.g., ai.clients.Grok).
+ /// - Section path with : replaced by ., lowercase (e.g., ai.clients.grok).
+ /// - Last path segment (e.g., Grok).
+ /// - Last path segment, lowercase (e.g., grok).
+ ///
+ /// Chat defaults flow through the shared factory so they are applied once, including on every configuration reload.
///
/// The service collection.
/// The application configuration.
@@ -31,10 +39,10 @@ public static IServiceCollection AddAIClients(this IServiceCollection services,
services.AddClientFactoryResolver(useDefaultProviders);
services.AddLogging();
- foreach (var section in EnumerateFactorySections(configuration, prefix))
+ var factorySections = EnumerateFactorySections(configuration, prefix).ToList();
+ foreach (var section in factorySections)
{
var path = section.Path;
- var options = section.Get();
services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), path,
factory: (sp, _) =>
@@ -46,49 +54,61 @@ public static IServiceCollection AddAIClients(this IServiceCollection services,
return new DefaultsApplyingClientFactory(configurable, path, sp);
},
ServiceLifetime.Singleton));
+ }
- if (!string.IsNullOrEmpty(options?.Id) && !string.Equals(options.Id, path, StringComparison.Ordinal))
- {
- services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), options.Id,
- factory: (sp, _) => sp.GetRequiredKeyedService(path),
- ServiceLifetime.Singleton));
- }
+ foreach (var section in factorySections)
+ TryAddKeyedAlias(services, section.Get()?.Id, section.Path, ServiceLifetime.Singleton);
+
+ foreach (var section in factorySections)
+ {
+ foreach (var alias in GetGeneratedAliases(section.Path))
+ TryAddKeyedAlias(services, alias, section.Path, ServiceLifetime.Singleton);
}
var normalizedPrefix = prefix.TrimEnd(':') + ":";
- foreach (var entry in configuration.AsEnumerable().Where(x =>
- x.Value is not null &&
- x.Key.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase) &&
- x.Key.EndsWith(":modelid", StringComparison.OrdinalIgnoreCase)))
- {
- var path = string.Join(':', entry.Key.Split(':')[..^1]);
- var options = configuration.GetRequiredSection(path).Get();
- var id = string.IsNullOrEmpty(options?.Id) ? path : options.Id;
- var lifetime = options?.Lifetime ?? ServiceLifetime.Singleton;
+ var chatSections = configuration.AsEnumerable().Where(x =>
+ x.Value is not null &&
+ x.Key.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase) &&
+ x.Key.EndsWith(":modelid", StringComparison.OrdinalIgnoreCase))
+ .Select(entry =>
+ {
+ var path = string.Join(':', entry.Key.Split(':')[..^1]);
+ var options = configuration.GetRequiredSection(path).Get();
+ var defaultId = GetDefaultId(path);
+ var id = string.IsNullOrEmpty(options?.Id) ? defaultId : options.Id;
+ var lifetime = options?.Lifetime ?? ServiceLifetime.Singleton;
+
+ return (Path: path, Id: id, DefaultId: defaultId, ConfiguredId: options?.Id, Lifetime: lifetime);
+ })
+ .ToList();
- services.TryAdd(new ServiceDescriptor(typeof(IChatClient), path,
+ foreach (var section in chatSections)
+ {
+ services.TryAdd(new ServiceDescriptor(typeof(IChatClient), section.Path,
factory: (sp, _) =>
{
// The keyed IClientFactory exists for sections with a direct apikey.
// For sub-sections that inherit their apikey from a parent, create a
// section-specific defaults-applying factory on the fly.
- var sectionFactory = sp.GetKeyedService(path)
+ var sectionFactory = sp.GetKeyedService(section.Path)
?? new DefaultsApplyingClientFactory(
- new ConfigurableClientFactory(configuration, path, sp.GetRequiredService()),
- path, sp);
+ new ConfigurableClientFactory(configuration, section.Path, sp.GetRequiredService()),
+ section.Path, sp);
return new ConfigurableChatClient(sectionFactory, configuration,
sp.GetRequiredService>(),
- path, id);
+ section.Path, section.Id);
},
- lifetime));
+ section.Lifetime));
+ }
- if (!string.Equals(id, path, StringComparison.Ordinal))
- {
- services.TryAdd(new ServiceDescriptor(typeof(IChatClient), id,
- factory: (sp, _) => sp.GetRequiredKeyedService(path),
- lifetime));
- }
+ foreach (var section in chatSections)
+ TryAddKeyedAlias(services, section.ConfiguredId, section.Path, section.Lifetime);
+
+ foreach (var section in chatSections)
+ {
+ foreach (var alias in GetGeneratedAliases(section.Path))
+ TryAddKeyedAlias(services, alias, section.Path, section.Lifetime);
}
return services;
@@ -178,6 +198,34 @@ x.Value is not null &&
}
}
+ static string GetDefaultId(string path)
+ {
+ var separator = path.LastIndexOf(':');
+ return separator < 0 ? path : path[(separator + 1)..];
+ }
+
+ static IEnumerable GetGeneratedAliases(string path)
+ {
+ yield return path.ToLowerInvariant();
+ var dotted = path.Replace(':', '.');
+ yield return dotted;
+ yield return dotted.ToLowerInvariant();
+ var lastSegment = GetDefaultId(path);
+ yield return lastSegment;
+ yield return lastSegment.ToLowerInvariant();
+ }
+
+ static void TryAddKeyedAlias(IServiceCollection services, string? id, string path, ServiceLifetime lifetime)
+ where TService : class
+ {
+ if (string.IsNullOrEmpty(id) || string.Equals(id, path, StringComparison.Ordinal))
+ return;
+
+ services.TryAdd(new ServiceDescriptor(typeof(TService), id,
+ factory: (sp, _) => sp.GetRequiredKeyedService(path),
+ lifetime));
+ }
+
internal class ClientOptions
{
public string? ApiKey { get; set; }
diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs
index d56cd8c..3f5a9ac 100644
--- a/src/Tests/ConfigurableClientTests.cs
+++ b/src/Tests/ConfigurableClientTests.cs
@@ -66,7 +66,7 @@ public void CanGetClientOptions()
}
[Fact]
- public void DoesNotRegisterShortNameWithoutConfiguredId()
+ public void CanResolveClientByImplicitSectionId()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary
@@ -82,9 +82,11 @@ public void DoesNotRegisterShortNameWithoutConfiguredId()
.AddAIClients(configuration)
.BuildServiceProvider();
- Assert.Null(services.GetKeyedService("grok"));
-
var grok = services.GetRequiredKeyedService("ai:clients:Grok");
+ var alias = services.GetRequiredKeyedService("Grok");
+
+ Assert.Same(grok, alias);
+ Assert.Same(grok, services.GetRequiredKeyedService("grok"));
Assert.Equal("xai", grok.GetRequiredService().ProviderName);
}
@@ -108,9 +110,11 @@ public void CanGetSectionAndIdFromMetadata()
var grok = services.GetRequiredKeyedService("ai:clients:Grok");
var alias = services.GetRequiredKeyedService("groked");
+ var defaultAlias = services.GetRequiredKeyedService("Grok");
var metadata = grok.GetRequiredService();
Assert.Same(grok, alias);
+ Assert.Same(grok, defaultAlias);
Assert.Equal("groked", metadata.Id);
Assert.Equal("ai:clients:Grok", metadata.ConfigurationSection);
}
@@ -135,11 +139,99 @@ public void CanResolveClientByConfiguredId()
var grok = services.GetRequiredKeyedService("ai:clients:grok");
var alias = services.GetRequiredKeyedService("xai");
+ var defaultAlias = services.GetRequiredKeyedService("grok");
Assert.Same(grok, alias);
+ Assert.Same(grok, defaultAlias);
Assert.Equal("xai", grok.GetRequiredService().ProviderName);
}
+ [Fact]
+ public void CanResolveClientByLowercasePath()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ai:clients:Grok:modelid"] = "grok-4-fast",
+ ["ai:clients:Grok:ApiKey"] = "xai-asdfasdf",
+ ["ai:clients:Grok:endpoint"] = "https://api.x.ai",
+ })
+ .Build();
+
+ var services = new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddAIClients(configuration)
+ .BuildServiceProvider();
+
+ var grok = services.GetRequiredKeyedService("ai:clients:Grok");
+
+ // lowercase path
+ Assert.Same(grok, services.GetRequiredKeyedService("ai:clients:grok"));
+ // dotted path (original casing)
+ Assert.Same(grok, services.GetRequiredKeyedService("ai.clients.Grok"));
+ // dotted path (lowercase)
+ Assert.Same(grok, services.GetRequiredKeyedService("ai.clients.grok"));
+ // last segment (already tested elsewhere, but confirms it too)
+ Assert.Same(grok, services.GetRequiredKeyedService("Grok"));
+ // last segment lowercase
+ Assert.Same(grok, services.GetRequiredKeyedService("grok"));
+ }
+
+ [Fact]
+ public void ConfiguredIdWinsOverGeneratedAliasConflict()
+ {
+ // Section B's path segment is "fast", and section A has id="fast".
+ // Configured id should win (registered before generated aliases).
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ai:clients:grok:id"] = "fast",
+ ["ai:clients:grok:modelid"] = "grok-4-fast",
+ ["ai:clients:grok:apikey"] = "xai-asdfasdf",
+ ["ai:clients:grok:endpoint"] = "https://api.x.ai",
+ ["ai:clients:fast:modelid"] = "gpt-4.1.nano",
+ ["ai:clients:fast:apikey"] = "sk-asdfasdf",
+ })
+ .Build();
+
+ var services = new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddAIClients(configuration)
+ .BuildServiceProvider();
+
+ var grok = services.GetRequiredKeyedService("ai:clients:grok");
+ var fast = services.GetRequiredKeyedService("ai:clients:fast");
+
+ // "fast" as an alias key was claimed first by grok's configured id
+ Assert.Same(grok, services.GetRequiredKeyedService("fast"));
+ Assert.NotSame(fast, services.GetRequiredKeyedService("fast"));
+ }
+
+ [Fact]
+ public void CanResolveFactoryByAlternateKeys()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["ai:clients:Grok:apikey"] = "xai-asdfasdf",
+ ["ai:clients:Grok:endpoint"] = "https://api.x.ai",
+ })
+ .Build();
+
+ var services = new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddAIClients(configuration)
+ .BuildServiceProvider();
+
+ var factory = services.GetRequiredKeyedService("ai:clients:Grok");
+
+ Assert.Same(factory, services.GetRequiredKeyedService("ai:clients:grok"));
+ Assert.Same(factory, services.GetRequiredKeyedService("ai.clients.Grok"));
+ Assert.Same(factory, services.GetRequiredKeyedService("ai.clients.grok"));
+ Assert.Same(factory, services.GetRequiredKeyedService("Grok"));
+ Assert.Same(factory, services.GetRequiredKeyedService("grok"));
+ }
+
[Fact]
public void CanSetApiKeyToConfiguration()
{
@@ -531,11 +623,12 @@ public void CanResolveKeyedClientFactoryBySectionPath()
var factory = services.GetRequiredKeyedService("ai:clients:openai");
var alias = services.GetRequiredKeyedService("default-openai");
+ var defaultAlias = services.GetRequiredKeyedService("openai");
var client = factory.CreateChatClient();
Assert.Same(factory, alias);
- Assert.Null(services.GetKeyedService("ai.clients.openai"));
- Assert.Null(services.GetKeyedService("openai"));
+ Assert.Same(factory, defaultAlias);
+ Assert.Same(factory, services.GetRequiredKeyedService("ai.clients.openai"));
Assert.Equal("gpt-4.1.nano", client.GetRequiredService().DefaultModelId);
}
@@ -557,6 +650,7 @@ public void AddClientsOnlyRegistersSectionsWithDirectApiKey()
.BuildServiceProvider();
Assert.NotNull(services.GetKeyedService("ai:clients:grok"));
+ Assert.NotNull(services.GetKeyedService("grok"));
Assert.Null(services.GetKeyedService("ai:clients:grok:router"));
}
@@ -741,9 +835,11 @@ public void AddClientsAppliesDefaultsToChatFactory()
.BuildServiceProvider();
var factory = services.GetRequiredKeyedService("ai:clients:openai");
+ var alias = services.GetRequiredKeyedService("openai");
var client = factory.CreateChatClient();
- Assert.Null(services.GetKeyedService("ai.clients.openai"));
+ Assert.Same(factory, alias);
+ Assert.Same(factory, services.GetRequiredKeyedService("ai.clients.openai"));
Assert.NotNull(client.GetService());
}
@@ -886,6 +982,8 @@ public void SubSectionChatClientInheritsParentApiKey()
// But IChatClient resolves via apikey inheritance from the parent section
var client = services.GetRequiredKeyedService("ai:clients:grok:chat");
+ var alias = services.GetRequiredKeyedService("chat");
+ Assert.Same(client, alias);
Assert.Equal("xai", client.GetRequiredService().ProviderName);
}