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 @@ +