From 59f2a00f6e28b620774bdcd76be18c04d4bb7b39 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 9 May 2026 00:53:17 -0300 Subject: [PATCH] refactor: resolve section-bound client factories Bind client factories to configuration sections and resolve them through IClientFactoryResolver and ClientFactoryResolver so configurable clients and DI registrations are driven by section paths and service keys. Validation: - dotnet build Extensions.AI.slnx - dotnet format whitespace -v:diag --exclude ~/.nuget - dotnet format style -v:diag --exclude ~/.nuget - dotnet test src\Tests\Tests.csproj --no-build --filter "FullyQualifiedName~ConfigurableClientTests" - dotnet test src\Tests\Tests.csproj --no-build --filter "FullyQualifiedName!~Devlooped.EndToEnd" Known issue: - dnx --yes retest and the full test project currently fail because Devlooped.EndToEnd.GetText in src/Tests/EndToEnd.cs asserts GetChatClient("XAI") is not null for a config shape that does not register that chat client id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + readme.md | 14 +- src/Extensions/ClientFactoryExtensions.cs | 154 ++++++++---------- ...entFactory.cs => ClientFactoryResolver.cs} | 26 +-- src/Extensions/ClientProviders.cs | 48 +++--- src/Extensions/ConfigurableChatClient.cs | 63 ++++++- .../ConfigurableChatClientExtensions.cs | 10 +- .../ConfigurableClientExtensions.cs | 30 ++++ src/Extensions/IClientFactory.cs | 31 +--- src/Extensions/IClientFactoryResolver.cs | 12 ++ src/Extensions/IClientProvider.cs | 6 +- src/Tests/ConfigurableClientTests.cs | 121 ++++++++++++-- src/Tests/EndToEnd.cs | 35 ++++ src/Tests/EndToEnd.json5 | 10 ++ src/Tests/Tests.csproj | 2 + 15 files changed, 377 insertions(+), 186 deletions(-) rename src/Extensions/{ClientFactory.cs => ClientFactoryResolver.cs} (81%) create mode 100644 src/Extensions/ConfigurableClientExtensions.cs create mode 100644 src/Extensions/IClientFactoryResolver.cs create mode 100644 src/Tests/EndToEnd.cs create mode 100644 src/Tests/EndToEnd.json5 diff --git a/AGENTS.md b/AGENTS.md index 133249b..88dc532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,5 @@ - Client providers use `IClientProvider` for configuration-driven creation of chat, speech-to-text, and text-to-speech clients. - `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. diff --git a/readme.md b/readme.md index d629d97..d4686e6 100644 --- a/readme.md +++ b/readme.md @@ -45,15 +45,17 @@ var grok = app.Services.GetRequiredKeyedService("Grok"); Changing the `appsettings.json` file will automatically update the client configuration without restarting the application. -The same provider resolution can also create speech clients from configuration -through `IClientFactory`: +The same provider resolution can also create speech clients from configuration +through keyed `IClientFactory` registrations: ```csharp +host.AddClients(); + var section = host.Configuration.GetRequiredSection("AI:Clients:OpenAI"); -var factory = app.Services.GetRequiredService(); -var chat = factory.CreateChatClient(section); -var speechToText = factory.CreateSpeechToTextClient(section); -var textToSpeech = factory.CreateTextToSpeechClient(section); +var factory = app.Services.GetRequiredKeyedService(section.Path); +var chat = factory.CreateChatClient(); +var speechToText = factory.CreateSpeechToTextClient(); +var textToSpeech = factory.CreateTextToSpeechClient(); ``` There's also a simpler `Chat` class for streamlined creation of chat messages, which can diff --git a/src/Extensions/ClientFactoryExtensions.cs b/src/Extensions/ClientFactoryExtensions.cs index bc2b4fb..823a31d 100644 --- a/src/Extensions/ClientFactoryExtensions.cs +++ b/src/Extensions/ClientFactoryExtensions.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using Devlooped.Extensions.AI; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -11,7 +10,7 @@ namespace Microsoft.Extensions.DependencyInjection; [EditorBrowsable(EditorBrowsableState.Never)] public static class ClientFactoryExtensions { - /// Adds the default and built-in providers to the service collection. + /// Adds the default and built-in providers to the service collection. /// The service collection. /// Whether to register the default built-in providers. /// The service collection for chaining. @@ -25,13 +24,13 @@ public static IServiceCollection AddClientFactory(this IServiceCollection servic services.TryAddEnumerable(ServiceDescriptor.Singleton()); } - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); return services; } - /// Adds the default and built-in providers to the host application builder. + /// Adds the default and built-in providers to the host application builder. /// The host application builder. /// Whether to register the default built-in providers. /// The builder for chaining. @@ -41,6 +40,51 @@ public static TBuilder AddClientFactory(this TBuilder builder, bool re return builder; } + /// Adds keyed registrations for configuration sections with direct API keys. + /// The service collection. + /// The application configuration. + /// The configuration prefix for clients. Defaults to ai:clients. + /// Whether to register the default built-in providers. + /// The service collection for chaining. + public static IServiceCollection AddClients(this IServiceCollection services, IConfiguration configuration, string prefix = "ai:clients", bool useDefaultProviders = true) + { + services.AddClientFactory(useDefaultProviders); + + foreach (var section in EnumerateFactorySections(configuration, prefix)) + { + services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), section.Path, + factory: (sp, _) => sp.GetRequiredService().Resolve(section), + ServiceLifetime.Singleton)); + + services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), new ServiceKey(section.Path), + factory: (sp, _) => sp.GetRequiredKeyedService(section.Path), + ServiceLifetime.Singleton)); + + var dottedKey = section.Path.Replace(':', '.'); + + services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), dottedKey, + factory: (sp, _) => sp.GetRequiredService().Resolve(section), + ServiceLifetime.Singleton)); + + services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), new ServiceKey(dottedKey), + factory: (sp, _) => sp.GetRequiredKeyedService(section.Path), + ServiceLifetime.Singleton)); + } + + return services; + } + + /// Adds keyed registrations for configuration sections with direct API keys. + /// The host application builder. + /// The configuration prefix for clients. Defaults to ai:clients. + /// Whether to register the default built-in providers. + /// The builder for chaining. + public static TBuilder AddClients(this TBuilder builder, string prefix = "ai:clients", bool useDefaultProviders = true) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddClients(builder.Configuration, prefix, useDefaultProviders); + return builder; + } + /// Registers a typed with the service collection. /// The provider type to register. /// The service collection. @@ -48,7 +92,7 @@ public static TBuilder AddClientFactory(this TBuilder builder, bool re public static IServiceCollection AddClientProvider(this IServiceCollection services) where TProvider : class, IClientProvider { - services.AddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -62,93 +106,23 @@ public static IServiceCollection AddClientProvider( Func implementationFactory) where TProvider : class, IClientProvider { - services.AddEnumerable(ServiceDescriptor.Singleton(implementationFactory)); + services.TryAddEnumerable(ServiceDescriptor.Singleton(implementationFactory)); return services; } - /// Registers an inline with the specified name, base URI, host suffix, and factory functions. - /// The service collection. - /// The unique name for the provider. - /// The optional base URI for automatic endpoint matching. - /// The optional host suffix for automatic endpoint matching (e.g., ".openai.azure.com"). - /// The factory function to create chat clients. - /// The optional factory function to create speech-to-text clients. - /// The optional factory function to create text-to-speech clients. - /// The service collection for chaining. - public static IServiceCollection AddClientProvider( - this IServiceCollection services, - string name, - Uri? baseUri, - string? hostSuffix, - Func chatFactory, - Func? speechToTextFactory = null, - Func? textToSpeechFactory = null) - { - services.AddEnumerable(ServiceDescriptor.Singleton( - new DelegateClientProvider(name, baseUri, hostSuffix, chatFactory, speechToTextFactory, textToSpeechFactory))); - return services; - } - - /// Registers an inline with the specified name, base URI, and factory functions. - /// The service collection. - /// The unique name for the provider. - /// The optional base URI for automatic endpoint matching. - /// The factory function to create chat clients. - /// The optional factory function to create speech-to-text clients. - /// The optional factory function to create text-to-speech clients. - /// The service collection for chaining. - public static IServiceCollection AddClientProvider( - this IServiceCollection services, - string name, - Uri? baseUri, - Func chatFactory, - Func? speechToTextFactory = null, - Func? textToSpeechFactory = null) - => services.AddClientProvider(name, baseUri, null, chatFactory, speechToTextFactory, textToSpeechFactory); - - /// Registers an inline with the specified name and factory functions. - /// The service collection. - /// The unique name for the provider. - /// The factory function to create chat clients. - /// The optional factory function to create speech-to-text clients. - /// The optional factory function to create text-to-speech clients. - /// The service collection for chaining. - public static IServiceCollection AddClientProvider( - this IServiceCollection services, - string name, - Func chatFactory, - Func? speechToTextFactory = null, - Func? textToSpeechFactory = null) - => services.AddClientProvider(name, null, null, chatFactory, speechToTextFactory, textToSpeechFactory); - - static void AddEnumerable(this IServiceCollection services, ServiceDescriptor descriptor) - // Use TryAddEnumerable behavior to avoid duplicates - => services.TryAddEnumerable(descriptor); - - /// A delegate-based for inline registrations. - sealed class DelegateClientProvider( - string name, - Uri? baseUri, - string? hostSuffix, - Func chatFactory, - Func? speechToTextFactory, - Func? textToSpeechFactory) : IClientProvider + static IEnumerable EnumerateFactorySections(IConfiguration configuration, string prefix) { - public string ProviderName => name; - public Uri? BaseUri => baseUri; - public string? HostSuffix => hostSuffix; - public IClientFactory GetFactory() => new DelegateClientFactory(chatFactory, speechToTextFactory, textToSpeechFactory); - } + var normalizedPrefix = prefix.TrimEnd(':') + ":"; + HashSet sections = new(StringComparer.OrdinalIgnoreCase); - sealed class DelegateClientFactory( - Func chatFactory, - Func? speechToTextFactory, - Func? textToSpeechFactory) : IClientFactory - { - public IChatClient CreateChatClient(IConfigurationSection section) => chatFactory(section); - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) - => speechToTextFactory?.Invoke(section) ?? throw new NotSupportedException("Speech-to-text clients are not supported by this provider."); - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) - => textToSpeechFactory?.Invoke(section) ?? throw new NotSupportedException("Text-to-speech clients are not supported by this provider."); + foreach (var entry in configuration.AsEnumerable().Where(x => + x.Value is not null && + x.Key.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase) && + x.Key.EndsWith(":apikey", StringComparison.OrdinalIgnoreCase))) + { + var sectionPath = string.Join(':', entry.Key.Split(':')[..^1]); + if (sections.Add(sectionPath)) + yield return configuration.GetRequiredSection(sectionPath); + } } -} \ No newline at end of file +} diff --git a/src/Extensions/ClientFactory.cs b/src/Extensions/ClientFactoryResolver.cs similarity index 81% rename from src/Extensions/ClientFactory.cs rename to src/Extensions/ClientFactoryResolver.cs index 998d744..d8c004f 100644 --- a/src/Extensions/ClientFactory.cs +++ b/src/Extensions/ClientFactoryResolver.cs @@ -1,10 +1,9 @@ -using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; namespace Devlooped.Extensions.AI; -/// Default implementation of that resolves providers by name or by matching endpoint URIs. -public class ClientFactory : IClientFactory +/// Resolves providers by name or by matching endpoint URIs, then returns section-bound factories. +public class ClientFactoryResolver : IClientFactoryResolver { readonly IClientProvider defaultProvider = new OpenAIClientProvider(); @@ -12,9 +11,9 @@ public class ClientFactory : IClientFactory readonly List<(Uri BaseUri, IClientProvider Provider)> providersByBaseUri; readonly List<(string HostSuffix, IClientProvider Provider)> providersByHostSuffix; - /// Initializes a new instance of the class with the specified providers. + /// Initializes a new instance of the class with the specified providers. /// The collection of registered providers. - public ClientFactory(IEnumerable providers) + public ClientFactoryResolver(IEnumerable providers) { providersByName = new(StringComparer.OrdinalIgnoreCase); providersByBaseUri = []; @@ -37,9 +36,9 @@ public ClientFactory(IEnumerable providers) providersByHostSuffix.Sort((a, b) => b.HostSuffix.Length.CompareTo(a.HostSuffix.Length)); } - /// Creates a with the built-in providers registered. - /// A factory with OpenAI, Azure OpenAI, Azure AI Inference, and Grok providers. - public static ClientFactory CreateDefault() => new( + /// Creates a with the built-in providers registered. + /// A resolver with OpenAI, Azure OpenAI, Azure AI Inference, and Grok providers. + public static ClientFactoryResolver CreateDefault() => new( [ new OpenAIClientProvider(), new AzureOpenAIClientProvider(), @@ -48,16 +47,7 @@ public ClientFactory(IEnumerable providers) ]); /// - public IChatClient CreateChatClient(IConfigurationSection section) - => ResolveProvider(section).GetFactory().CreateChatClient(section); - - /// - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) - => ResolveProvider(section).GetFactory().CreateSpeechToTextClient(section); - - /// - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) - => ResolveProvider(section).GetFactory().CreateTextToSpeechClient(section); + public IClientFactory Resolve(IConfigurationSection section) => ResolveProvider(section).GetFactory(section); /// Resolves the appropriate provider for the given configuration section. /// The configuration section. diff --git a/src/Extensions/ClientProviders.cs b/src/Extensions/ClientProviders.cs index 22c9581..55adfba 100644 --- a/src/Extensions/ClientProviders.cs +++ b/src/Extensions/ClientProviders.cs @@ -15,19 +15,17 @@ namespace Devlooped.Extensions.AI; /// sealed class OpenAIClientProvider : IClientProvider { - static readonly IClientFactory factory = new OpenAIClientFactory(); - public string ProviderName => "openai"; public Uri? BaseUri => new("https://api.openai.com/"); public string? HostSuffix => null; - public IClientFactory GetFactory() => factory; + public IClientFactory GetFactory(IConfigurationSection section) => new OpenAIClientFactory(section); - class OpenAIClientFactory : IClientFactory + class OpenAIClientFactory(IConfigurationSection section) : IClientFactory { - public IChatClient CreateChatClient(IConfigurationSection section) + public IChatClient CreateChatClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -38,7 +36,7 @@ public IChatClient CreateChatClient(IConfigurationSection section) options); } - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) + public ISpeechToTextClient CreateSpeechToTextClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -49,7 +47,7 @@ public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection sectio options); } - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) + public ITextToSpeechClient CreateTextToSpeechClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -73,19 +71,17 @@ internal sealed class OpenAIProviderOptions : OpenAIClientOptions /// sealed class AzureOpenAIClientProvider : IClientProvider { - static readonly IClientFactory factory = new AzureOpenAIClientFactory(); - public string ProviderName => "azure.openai"; public Uri? BaseUri => null; public string? HostSuffix => ".openai.azure.com"; - public IClientFactory GetFactory() => factory; + public IClientFactory GetFactory(IConfigurationSection section) => new AzureOpenAIClientFactory(section); - class AzureOpenAIClientFactory : IClientFactory + class AzureOpenAIClientFactory(IConfigurationSection section) : IClientFactory { - public IChatClient CreateChatClient(IConfigurationSection section) + public IChatClient CreateChatClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -97,7 +93,7 @@ public IChatClient CreateChatClient(IConfigurationSection section) options); } - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) + public ISpeechToTextClient CreateSpeechToTextClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -111,7 +107,7 @@ public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection sectio options); } - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) + public ITextToSpeechClient CreateTextToSpeechClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -140,7 +136,6 @@ internal sealed class AzureOpenAIProviderOptions : AzureOpenAIClientOptions sealed class AzureAIInferenceClientProvider : IClientProvider { const string providerName = "azure.inference"; - static readonly IClientFactory factory = new AzureAIInferenceClientFactory(); public string ProviderName => providerName; @@ -148,11 +143,11 @@ sealed class AzureAIInferenceClientProvider : IClientProvider public string? HostSuffix => null; - public IClientFactory GetFactory() => factory; + public IClientFactory GetFactory(IConfigurationSection section) => new AzureAIInferenceClientFactory(section); - class AzureAIInferenceClientFactory : IClientFactory + class AzureAIInferenceClientFactory(IConfigurationSection section) : IClientFactory { - public IChatClient CreateChatClient(IConfigurationSection section) + public IChatClient CreateChatClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -165,10 +160,10 @@ public IChatClient CreateChatClient(IConfigurationSection section) options); } - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) + public ISpeechToTextClient CreateSpeechToTextClient() => throw ClientProviderCapabilities.Unsupported(providerName, nameof(ISpeechToTextClient)); - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) + public ITextToSpeechClient CreateTextToSpeechClient() => throw ClientProviderCapabilities.Unsupported(providerName, nameof(ITextToSpeechClient)); } @@ -186,7 +181,6 @@ internal sealed class AzureInferenceProviderOptions : AzureAIInferenceClientOpti sealed class GrokClientProvider : IClientProvider { const string providerName = "xai"; - static readonly IClientFactory factory = new GrokClientFactory(); public string ProviderName => providerName; @@ -194,11 +188,11 @@ sealed class GrokClientProvider : IClientProvider public string? HostSuffix => null; - public IClientFactory GetFactory() => factory; + public IClientFactory GetFactory(IConfigurationSection section) => new GrokClientFactory(section); - class GrokClientFactory : IClientFactory + class GrokClientFactory(IConfigurationSection section) : IClientFactory { - public IChatClient CreateChatClient(IConfigurationSection section) + public IChatClient CreateChatClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); @@ -209,22 +203,20 @@ public IChatClient CreateChatClient(IConfigurationSection section) options); } - public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section) + public ISpeechToTextClient CreateSpeechToTextClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); - Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); return new ProviderOptionsSpeechToTextClient( new GrokClient(options.ApiKey, options).AsISpeechToTextClient(), options); } - public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section) + public ITextToSpeechClient CreateTextToSpeechClient() { var options = section.Get() ?? new(); Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); - Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); return new ProviderOptionsTextToSpeechClient( new GrokClient(options.ApiKey, options).AsITextToSpeechClient(), diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index 2adca43..ba6ab89 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI; public sealed partial class ConfigurableChatClient : IChatClient, IDisposable { readonly IConfiguration configuration; - readonly IClientFactory factory; + readonly IClientFactoryResolver resolver; readonly string section; readonly string id; readonly ILogger logger; @@ -20,18 +20,18 @@ public sealed partial class ConfigurableChatClient : IChatClient, IDisposable /// Initializes a new instance of the class. /// The configuration to read settings from. - /// The factory to use for creating chat clients. + /// The resolver to use for creating provider-specific factories. /// The logger to use for logging. /// The configuration section to use. /// The unique identifier for the client. /// An optional action to configure the client after creation. - public ConfigurableChatClient(IConfiguration configuration, IClientFactory factory, ILogger logger, string section, string id, Action? configure) + public ConfigurableChatClient(IConfiguration configuration, IClientFactoryResolver resolver, ILogger logger, string section, string id, Action? configure) { if (section.Contains('.')) throw new ArgumentException("Section separator must be ':', not '.'"); this.configuration = Throw.IfNull(configuration); - this.factory = Throw.IfNull(factory); + this.resolver = Throw.IfNull(resolver); this.logger = Throw.IfNull(logger); this.section = Throw.IfNullOrEmpty(section); this.id = Throw.IfNullOrEmpty(id); @@ -41,14 +41,14 @@ public ConfigurableChatClient(IConfiguration configuration, IClientFactory facto reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); } - /// Initializes a new instance of the class using the default . + /// Initializes a new instance of the class using the default . /// The configuration to read settings from. /// The logger to use for logging. /// The configuration section to use. /// The unique identifier for the client. /// An optional action to configure the client after creation. public ConfigurableChatClient(IConfiguration configuration, ILogger logger, string section, string id, Action? configure) - : this(configuration, ClientFactory.CreateDefault(), logger, section, id, configure) + : this(configuration, ClientFactoryResolver.CreateDefault(), logger, section, id, configure) { } @@ -97,7 +97,7 @@ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerabl // Create a configuration section wrapper that includes the resolved apikey var effectiveSection = new ApiKeyResolvingConfigurationSection(configSection, apikey); - var client = factory.CreateChatClient(effectiveSection); + var client = resolver.Resolve(effectiveSection).CreateChatClient(); configure?.Invoke(id, client); LogConfigured(id); @@ -134,7 +134,54 @@ public string? this[string key] public string Key => inner.Key; public string Path => inner.Path; - public string? Value { get => inner.Value; set => inner.Value = value; } + public string? Value + { + get => inner.Value; + set => inner.Value = value; + } + public IEnumerable GetChildren() + { + var hasApiKey = false; + + foreach (var child in inner.GetChildren()) + { + if (string.Equals(child.Key, "apikey", StringComparison.OrdinalIgnoreCase)) + { + hasApiKey = true; + yield return new ApiKeyValueConfigurationSection(child, resolvedApiKey); + } + else + { + yield return child; + } + } + + if (!hasApiKey && resolvedApiKey is not null) + yield return new ApiKeyValueConfigurationSection(inner.GetSection("apikey"), resolvedApiKey); + } + public IChangeToken GetReloadToken() => inner.GetReloadToken(); + public IConfigurationSection GetSection(string key) + => string.Equals(key, "apikey", StringComparison.OrdinalIgnoreCase) + ? new ApiKeyValueConfigurationSection(inner.GetSection(key), resolvedApiKey) + : inner.GetSection(key); + } + + sealed class ApiKeyValueConfigurationSection(IConfigurationSection inner, string? resolvedApiKey) : IConfigurationSection + { + public string? this[string key] + { + get => inner[key]; + set => inner[key] = value; + } + + public string Key => inner.Key; + public string Path => inner.Path; + public string? Value + { + get => resolvedApiKey ?? inner.Value; + set => inner.Value = value; + } + public IEnumerable GetChildren() => inner.GetChildren(); public IChangeToken GetReloadToken() => inner.GetReloadToken(); public IConfigurationSection GetSection(string key) => inner.GetSection(key); diff --git a/src/Extensions/ConfigurableChatClientExtensions.cs b/src/Extensions/ConfigurableChatClientExtensions.cs index f140d87..82d5b12 100644 --- a/src/Extensions/ConfigurableChatClientExtensions.cs +++ b/src/Extensions/ConfigurableChatClientExtensions.cs @@ -62,7 +62,7 @@ public static IServiceCollection AddChatClients(this IServiceCollection services factory: (sp, _) => { var client = new ConfigurableChatClient(configuration, - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>(), section, id, configureClient); @@ -89,6 +89,14 @@ public static IServiceCollection AddChatClients(this IServiceCollection services public static IChatClient? GetChatClient(this IServiceProvider services, string id) => services.GetKeyedService(id) ?? services.GetKeyedService(new ServiceKey(id)); + /// Gets a text to speech client by id (case-insensitive) from the service provider. + public static ITextToSpeechClient? GetTextToSpeechClient(this IServiceProvider services, string id) + => services.GetKeyedService(id) ?? services.GetKeyedService(new ServiceKey(id)); + + /// Gets a speech to text client by id (case-insensitive) from the service provider. + public static ISpeechToTextClient? GetSpeechToTextClient(this IServiceProvider services, string id) + => services.GetKeyedService(id) ?? services.GetKeyedService(new ServiceKey(id)); + internal class ChatClientOptions : OpenAIClientOptions { public string? ApiKey { get; set; } diff --git a/src/Extensions/ConfigurableClientExtensions.cs b/src/Extensions/ConfigurableClientExtensions.cs new file mode 100644 index 0000000..e659dec --- /dev/null +++ b/src/Extensions/ConfigurableClientExtensions.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Devlooped.Extensions.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Adds configuration-driven AI clients to an application host or service collection. +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ConfigurableClientExtensions +{ + /// + /// Adds configuration-driven chat clients to the host application builder. + /// + /// The host application builder. + /// Optional action to configure the pipeline for each client. + /// Optional action to configure each client. + /// The configuration prefix for clients. Defaults to "ai:clients". + /// Whether to register the default built-in providers for mapping configuration sections to instances. + /// The host application builder. + public static TBuilder AddAIClients(this TBuilder builder, + Action? configureChat = default, + Action? configureTTS = default, + Action? configureSTT = default, + string prefix = "ai:clients", bool useDefaultProviders = true) + where TBuilder : IHostApplicationBuilder + { + return builder; + } +} diff --git a/src/Extensions/IClientFactory.cs b/src/Extensions/IClientFactory.cs index 5418fd4..b39512e 100644 --- a/src/Extensions/IClientFactory.cs +++ b/src/Extensions/IClientFactory.cs @@ -3,37 +3,24 @@ namespace Devlooped.Extensions.AI; -/// A factory for creating AI clients based on configuration. +/// A factory for creating AI clients based on a bound configuration section. /// -/// The factory resolves the appropriate using the following logic: -/// -/// If the configuration section contains a provider key, looks up a provider by name. -/// Otherwise, matches the endpoint URI against registered providers' base URIs or host suffix, if any. -/// If no match is found, throws an . -/// +/// Instances are created by an for a specific +/// , then reused to create clients against that section. /// public interface IClientFactory { - /// Creates an using the specified configuration section. - /// The configuration section containing client settings including - /// endpoint, apikey, modelid, and optionally provider. + /// Creates an using the bound configuration section. /// A configured instance. - /// Thrown when no matching provider is found for the given configuration. - IChatClient CreateChatClient(IConfigurationSection section); + IChatClient CreateChatClient(); - /// Creates an using the specified configuration section. - /// The configuration section containing client settings including - /// endpoint, apikey, modelid, and optionally provider. + /// Creates an using the bound configuration section. /// A configured instance. - /// Thrown when no matching provider is found for the given configuration. /// Thrown when the resolved provider does not support speech-to-text clients. - ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section); + ISpeechToTextClient CreateSpeechToTextClient(); - /// Creates an using the specified configuration section. - /// The configuration section containing client settings including - /// endpoint, apikey, modelid, and optionally provider. + /// Creates an using the bound configuration section. /// A configured instance. - /// Thrown when no matching provider is found for the given configuration. /// Thrown when the resolved provider does not support text-to-speech clients. - ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section); + ITextToSpeechClient CreateTextToSpeechClient(); } diff --git a/src/Extensions/IClientFactoryResolver.cs b/src/Extensions/IClientFactoryResolver.cs new file mode 100644 index 0000000..012d87f --- /dev/null +++ b/src/Extensions/IClientFactoryResolver.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Configuration; + +namespace Devlooped.Extensions.AI; + +/// Resolves providers by name or by matching endpoint URIs, then returns section-bound factories. +public interface IClientFactoryResolver +{ + /// Resolves the appropriate provider for the given configuration section and returns its bound factory. + /// The configuration section containing client settings. + /// A provider-specific bound to . + IClientFactory Resolve(IConfigurationSection section); +} \ No newline at end of file diff --git a/src/Extensions/IClientProvider.cs b/src/Extensions/IClientProvider.cs index 02f7c55..20a83f3 100644 --- a/src/Extensions/IClientProvider.cs +++ b/src/Extensions/IClientProvider.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; + namespace Devlooped.Extensions.AI; /// @@ -35,6 +37,6 @@ public interface IClientProvider /// string? HostSuffix { get; } - /// Gets the provider-specific factory that knows how to create individual clients. - IClientFactory GetFactory(); + /// Gets the provider-specific factory bound to the specified configuration section. + IClientFactory GetFactory(IConfigurationSection section); } diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs index 18e4045..7ca8a1f 100644 --- a/src/Tests/ConfigurableClientTests.cs +++ b/src/Tests/ConfigurableClientTests.cs @@ -411,13 +411,12 @@ public void CanInspectOpenAISpeechProviderOptions() var services = new ServiceCollection() .AddSingleton(configuration) - .AddClientFactory() + .AddClients(configuration) .BuildServiceProvider(); - var factory = services.GetRequiredService(); - var section = configuration.GetRequiredSection("ai:clients:openai"); - var speechToText = factory.CreateSpeechToTextClient(section); - var textToSpeech = factory.CreateTextToSpeechClient(section); + var factory = services.GetRequiredKeyedService("ai:clients:openai"); + var speechToText = factory.CreateSpeechToTextClient(); + var textToSpeech = factory.CreateTextToSpeechClient(); var speechOptions = Assert.IsType( speechToText.GetService(typeof(object), "options")); @@ -443,10 +442,10 @@ public void CanInspectAzureOpenAISpeechProviderOptions() }) .Build(); - var factory = ClientFactory.CreateDefault(); var section = configuration.GetRequiredSection("ai:clients:audio"); - var speechToText = factory.CreateSpeechToTextClient(section); - var textToSpeech = factory.CreateTextToSpeechClient(section); + var factory = ClientFactoryResolver.CreateDefault().Resolve(section); + var speechToText = factory.CreateSpeechToTextClient(); + var textToSpeech = factory.CreateTextToSpeechClient(); var speechOptions = Assert.IsType( speechToText.GetService(typeof(object), "options")); @@ -459,6 +458,34 @@ public void CanInspectAzureOpenAISpeechProviderOptions() Assert.Equal("myapp/1.0", textOptions.UserAgentApplicationId); } + [Fact] + public void CanInspectXAIProviderOptions() + { + 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 section = configuration.GetRequiredSection("ai:clients:grok"); + var factory = ClientFactoryResolver.CreateDefault().Resolve(section); + + var chat = factory.CreateChatClient(); + var speechToText = factory.CreateSpeechToTextClient(); + var textToSpeech = factory.CreateTextToSpeechClient(); + + var speechOptions = speechToText.GetService(); + var textOptions = textToSpeech.GetService(); + + Assert.Same(speechOptions, speechToText.GetService(typeof(object), "options")); + Assert.Same(textOptions, textToSpeech.GetService(typeof(object), "options")); + + Assert.Equal("grok-4-fast", chat.GetRequiredService().ModelId); + } + [Fact] public void ThrowsForUnsupportedSpeechProvider() { @@ -471,13 +498,85 @@ public void ThrowsForUnsupportedSpeechProvider() }) .Build(); - var factory = ClientFactory.CreateDefault(); var section = configuration.GetRequiredSection("ai:clients:azure"); + var factory = ClientFactoryResolver.CreateDefault().Resolve(section); - var speechToText = Assert.Throws(() => factory.CreateSpeechToTextClient(section)); - var textToSpeech = Assert.Throws(() => factory.CreateTextToSpeechClient(section)); + var speechToText = Assert.Throws(() => factory.CreateSpeechToTextClient()); + var textToSpeech = Assert.Throws(() => factory.CreateTextToSpeechClient()); Assert.Contains(nameof(ISpeechToTextClient), speechToText.Message); Assert.Contains(nameof(ITextToSpeechClient), textToSpeech.Message); } + + [Fact] + public void CanResolveKeyedClientFactoryBySectionPath() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:openai:modelid"] = "gpt-4.1.nano", + ["ai:clients:openai:apikey"] = "sk-asdfasdf", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddClients(configuration) + .BuildServiceProvider(); + + var factory = services.GetRequiredKeyedService("ai:clients:openai"); + var alternative = services.GetRequiredKeyedService(new ServiceKey("AI:CLIENTS:OPENAI")); + var client = factory.CreateChatClient(); + + Assert.Same(factory, alternative); + Assert.Equal("gpt-4.1.nano", client.GetRequiredService().DefaultModelId); + } + + [Fact] + public void AddClientsOnlyRegistersSectionsWithDirectApiKey() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:grok:apikey"] = "xai-asdfasdf", + ["ai:clients:grok:router:modelid"] = "grok-4-fast", + ["ai:clients:grok:router:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddClients(configuration) + .BuildServiceProvider(); + + Assert.NotNull(services.GetKeyedService("ai:clients:grok")); + Assert.Null(services.GetKeyedService("ai:clients:grok:router")); + } + + [Fact] + public void BoundFactoryReflectsConfigurationChanges() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:openai:modelid"] = "gpt-4.1", + ["ai:clients:openai:apikey"] = "sk-asdfasdf", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddClients(configuration) + .BuildServiceProvider(); + + var factory = services.GetRequiredKeyedService("ai:clients:openai"); + var original = factory.CreateChatClient(); + + configuration["ai:clients:openai:modelid"] = "gpt-5"; + + var updated = factory.CreateChatClient(); + + Assert.Equal("gpt-4.1", original.GetRequiredService().DefaultModelId); + Assert.Equal("gpt-5", updated.GetRequiredService().DefaultModelId); + } } diff --git a/src/Tests/EndToEnd.cs b/src/Tests/EndToEnd.cs new file mode 100644 index 0000000..def36b4 --- /dev/null +++ b/src/Tests/EndToEnd.cs @@ -0,0 +1,35 @@ +using Json5; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Devlooped; + +public class EndToEnd +{ + static readonly IConfigurationRoot configuration = new ConfigurationBuilder() + .AddUserSecrets() + .AddJson5File("EndToEnd.json5") + .Build(); + + [SecretsFact("AI:Clients:XAI:ApiKey")] + public async Task GetText() + { + var services = new ServiceCollection() + .AddChatClients(configuration, + configurePipeline: (id, builder) => builder.UseLogging() + //configureClient: (id, client) => client.AsBuilder().UseLogging().build + ) + .BuildServiceProvider(); + + var chat = services.GetChatClient("XAI"); + + Assert.NotNull(chat); + + var hello = await chat.GetResponseAsync("Hi there!"); + + //var tts = services.GetChatClient.GetTextToSpeechClient("XAI"); + + //Assert.NotNull(tts); + } +} diff --git a/src/Tests/EndToEnd.json5 b/src/Tests/EndToEnd.json5 new file mode 100644 index 0000000..0ed9215 --- /dev/null +++ b/src/Tests/EndToEnd.json5 @@ -0,0 +1,10 @@ +{ + AI: { + Clients: { + XAI: { + Provider: "xai", + ModelId: "grok-latest" + } + } + } +} \ No newline at end of file diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index f90a6b0..71e067b 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -10,6 +10,7 @@ + @@ -43,6 +44,7 @@ +