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); }