From c317812afb13f79eaf8c5fccd64c293dee93357c Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 15 Jan 2026 19:58:50 -0300 Subject: [PATCH] Refactor: introduce provider/factory model for chat clients Adds extensible IChatClientProvider and IChatClientFactory abstractions to support multiple AI chat providers and custom registration. Refactors ConfigurableChatClient to use the new factory, decoupling provider logic and enabling DI-based extensibility. Includes built-in providers for OpenAI, Azure OpenAI, Azure AI Inference, and Grok, with extension methods for easy registration and configuration-driven resolution. Improves documentation and maintainability. --- src/Extensions/ChatClientFactory.cs | 139 +++++++++++++++++ src/Extensions/ChatClientFactoryExtensions.cs | 147 ++++++++++++++++++ src/Extensions/ChatClientProviders.cs | 138 ++++++++++++++++ src/Extensions/ConfigurableChatClient.cs | 99 +++++------- .../ConfigurableChatClientExtensions.cs | 12 +- src/Extensions/IChatClientFactory.cs | 29 ++++ src/Extensions/IChatClientProvider.cs | 47 ++++++ src/Tests/Tests.csproj | 8 + 8 files changed, 559 insertions(+), 60 deletions(-) create mode 100644 src/Extensions/ChatClientFactory.cs create mode 100644 src/Extensions/ChatClientFactoryExtensions.cs create mode 100644 src/Extensions/ChatClientProviders.cs create mode 100644 src/Extensions/IChatClientFactory.cs create mode 100644 src/Extensions/IChatClientProvider.cs diff --git a/src/Extensions/ChatClientFactory.cs b/src/Extensions/ChatClientFactory.cs new file mode 100644 index 0000000..0c54595 --- /dev/null +++ b/src/Extensions/ChatClientFactory.cs @@ -0,0 +1,139 @@ +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 ChatClientFactory : IChatClientFactory +{ + readonly IChatClientProvider defaultProvider = new OpenAIChatClientProvider(); + + readonly Dictionary providersByName; + readonly List<(Uri BaseUri, IChatClientProvider Provider)> providersByBaseUri; + readonly List<(string HostSuffix, IChatClientProvider Provider)> providersByHostSuffix; + + /// + /// Initializes a new instance of the class + /// with the specified providers. + /// + /// The collection of registered providers. + public ChatClientFactory(IEnumerable providers) + { + providersByName = new(StringComparer.OrdinalIgnoreCase); + providersByBaseUri = []; + providersByHostSuffix = []; + + foreach (var provider in providers) + { + providersByName[provider.ProviderName] = provider; + if (provider.BaseUri is { } baseUri) + providersByBaseUri.Add((baseUri, provider)); + + // Register host suffix for providers that use suffix matching + if (provider.HostSuffix is { } hostSuffix) + providersByHostSuffix.Add((hostSuffix, provider)); + } + + // Sort by URI length descending for longest-prefix matching + providersByBaseUri.Sort((a, b) => b.BaseUri.ToString().Length.CompareTo(a.BaseUri.ToString().Length)); + // Sort by suffix length descending for longest-suffix matching + 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 ChatClientFactory CreateDefault() => new( + [ + new OpenAIChatClientProvider(), + new AzureOpenAIChatClientProvider(), + new AzureAIInferenceChatClientProvider(), + new GrokChatClientProvider(), + ]); + + /// + public IChatClient CreateClient(IConfigurationSection section) + => ResolveProvider(section).Create(section); + + /// + /// Resolves the appropriate provider for the given configuration section. + /// + /// The configuration section. + /// The resolved provider. + /// + /// Thrown when no matching provider is found. + /// + protected virtual IChatClientProvider ResolveProvider(IConfigurationSection section) + { + // First, try explicit provider name + var providerName = section["provider"]; + if (!string.IsNullOrEmpty(providerName)) + { + if (providersByName.TryGetValue(providerName, out var namedProvider)) + return namedProvider; + + throw new InvalidOperationException( + $"No chat client provider registered with name '{providerName}'. " + + $"Available providers: {string.Join(", ", providersByName.Keys)}."); + } + + // Second, try endpoint URI matching + var endpointValue = section["endpoint"]; + if (!string.IsNullOrEmpty(endpointValue) && Uri.TryCreate(endpointValue, UriKind.Absolute, out var endpoint)) + { + // Try base URI prefix matching first + foreach (var (baseUri, provider) in providersByBaseUri) + { + if (MatchesBaseUri(endpoint, baseUri)) + return provider; + } + + // Then try host suffix matching (e.g., .openai.azure.com) + foreach (var (hostSuffix, provider) in providersByHostSuffix) + { + if (endpoint.Host.EndsWith(hostSuffix, StringComparison.OrdinalIgnoreCase)) + return provider; + } + } + + if (string.IsNullOrEmpty(endpointValue)) + return defaultProvider; + + throw new InvalidOperationException( + $"No chat client provider found for configuration section '{section.Path}'. " + + $"Specify a 'provider' key or use an 'endpoint' that matches a registered provider. " + + $"Available providers: {string.Join(", ", providersByName.Keys)}."); + } + + /// + /// Determines if the endpoint URI matches the provider's base URI pattern. + /// + /// The endpoint URI from configuration. + /// The provider's base URI pattern. + /// true if the endpoint matches the pattern; otherwise, false. + protected virtual bool MatchesBaseUri(Uri endpoint, Uri baseUri) + { + // Check host match + if (!string.Equals(endpoint.Host, baseUri.Host, StringComparison.OrdinalIgnoreCase)) + return false; + + // Check scheme + if (!string.Equals(endpoint.Scheme, baseUri.Scheme, StringComparison.OrdinalIgnoreCase)) + return false; + + // Check path prefix (for patterns like api.x.ai/v1) + var basePath = baseUri.AbsolutePath.TrimEnd('/'); + if (!string.IsNullOrEmpty(basePath) && basePath != "/") + { + var endpointPath = endpoint.AbsolutePath; + if (!endpointPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } +} diff --git a/src/Extensions/ChatClientFactoryExtensions.cs b/src/Extensions/ChatClientFactoryExtensions.cs new file mode 100644 index 0000000..5263ef5 --- /dev/null +++ b/src/Extensions/ChatClientFactoryExtensions.cs @@ -0,0 +1,147 @@ +using System.ComponentModel; +using Devlooped.Extensions.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for registering chat client providers and factories. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ChatClientFactoryExtensions +{ + /// + /// 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. + public static IServiceCollection AddChatClientFactory(this IServiceCollection services, bool registerDefaults = true) + { + if (registerDefaults) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + + // Register the factory + services.TryAddSingleton(); + + return services; + } + + /// + /// 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. + public static TBuilder AddChatClientFactory(this TBuilder builder, bool registerDefaults = true) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddChatClientFactory(registerDefaults); + return builder; + } + + /// + /// Registers a typed with the service collection. + /// + /// The provider type to register. + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddChatClientProvider(this IServiceCollection services) + where TProvider : class, IChatClientProvider + { + services.AddEnumerable(ServiceDescriptor.Singleton()); + return services; + } + + /// + /// Registers a typed with the service collection, + /// using a factory function. + /// + /// The provider type to register. + /// The service collection. + /// The factory function to create the provider. + /// The service collection for chaining. + public static IServiceCollection AddChatClientProvider( + this IServiceCollection services, + Func implementationFactory) + where TProvider : class, IChatClientProvider + { + services.AddEnumerable(ServiceDescriptor.Singleton(implementationFactory)); + return services; + } + + /// + /// Registers an inline with the specified name, + /// base URI, host suffix, and factory function. + /// + /// 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 service collection for chaining. + public static IServiceCollection AddChatClientProvider( + this IServiceCollection services, + string name, + Uri? baseUri, + string? hostSuffix, + Func factory) + { + services.AddEnumerable(ServiceDescriptor.Singleton( + new DelegateChatClientProvider(name, baseUri, hostSuffix, factory))); + return services; + } + + /// + /// Registers an inline with the specified name, + /// base URI, and factory function. + /// + /// 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 service collection for chaining. + public static IServiceCollection AddChatClientProvider( + this IServiceCollection services, + string name, + Uri? baseUri, + Func factory) + => services.AddChatClientProvider(name, baseUri, null, factory); + + /// + /// Registers an inline with the specified name and factory function. + /// + /// The service collection. + /// The unique name for the provider. + /// The factory function to create chat clients. + /// The service collection for chaining. + public static IServiceCollection AddChatClientProvider( + this IServiceCollection services, + string name, + Func factory) + => services.AddChatClientProvider(name, null, null, factory); + + 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 DelegateChatClientProvider(string name, Uri? baseUri, string? hostSuffix, Func factory) : IChatClientProvider + { + public string ProviderName => name; + public Uri? BaseUri => baseUri; + public string? HostSuffix => hostSuffix; + public IChatClient Create(IConfigurationSection section) => factory(section); + } +} diff --git a/src/Extensions/ChatClientProviders.cs b/src/Extensions/ChatClientProviders.cs new file mode 100644 index 0000000..4ebb92a --- /dev/null +++ b/src/Extensions/ChatClientProviders.cs @@ -0,0 +1,138 @@ +using Azure; +using Devlooped.Extensions.AI.Grok; +using Devlooped.Extensions.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; + +namespace Devlooped.Extensions.AI; + +/// +/// Provides instances for the OpenAI API. +/// +sealed class OpenAIChatClientProvider : IChatClientProvider +{ + /// + public string ProviderName => "openai"; + + /// + public Uri? BaseUri => new("https://api.openai.com/"); + + /// + public string? HostSuffix => null; + + /// + public IChatClient Create(IConfigurationSection section) + { + var options = section.Get() ?? new(); + Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); + Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); + + return new OpenAIChatClient(options.ApiKey, options.ModelId, options); + } + + internal sealed class OpenAIProviderOptions : OpenAIClientOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + } +} + +/// +/// Provides instances for the Azure OpenAI API. +/// +sealed class AzureOpenAIChatClientProvider : IChatClientProvider +{ + /// + public string ProviderName => "azure.openai"; + + /// + public Uri? BaseUri => null; + + /// + public string? HostSuffix => ".openai.azure.com"; + + /// + public IChatClient Create(IConfigurationSection section) + { + var options = section.Get() ?? new(); + Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); + Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); + Throw.IfNull(options.Endpoint, $"{section.Path}:endpoint"); + + return new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options.ModelId, options); + } + + internal sealed class AzureOpenAIProviderOptions : Azure.AI.OpenAI.AzureOpenAIClientOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + public Uri? Endpoint { get; set; } + } +} + +/// +/// Provides instances for the Azure AI Inference API. +/// +sealed class AzureAIInferenceChatClientProvider : IChatClientProvider +{ + /// + public string ProviderName => "azure.inference"; + + /// + public Uri? BaseUri => new("https://ai.azure.com/"); + + /// + public string? HostSuffix => null; + + /// + public IChatClient Create(IConfigurationSection section) + { + var options = section.Get() ?? new(); + Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); + Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); + Throw.IfNull(options.Endpoint, $"{section.Path}:endpoint"); + + return new Azure.AI.Inference.ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options) + .AsIChatClient(options.ModelId); + } + + internal sealed class AzureInferenceProviderOptions : Azure.AI.Inference.AzureAIInferenceClientOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + public Uri? Endpoint { get; set; } + } +} + +/// +/// Provides instances for the Grok (xAI) API. +/// +sealed class GrokChatClientProvider : IChatClientProvider +{ + /// + public string ProviderName => "xai"; + + /// + public Uri? BaseUri => new("https://api.x.ai/"); + + /// + public string? HostSuffix => null; + + /// + public IChatClient Create(IConfigurationSection section) + { + var options = section.Get() ?? new(); + Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); + Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); + + return new GrokClient(options.ApiKey, section.Get() ?? new()) + .AsIChatClient(options.ModelId); + } + + internal sealed class GrokProviderOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + } +} diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index 491fcb9..3a80b93 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -1,14 +1,7 @@ -using System.ClientModel.Primitives; -using System.ComponentModel; -using Azure; -using Azure.AI.Inference; -using Azure.AI.OpenAI; -using Devlooped.Extensions.AI.Grok; -using Devlooped.Extensions.AI.OpenAI; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using OpenAI; +using Microsoft.Extensions.Primitives; namespace Devlooped.Extensions.AI; @@ -19,6 +12,7 @@ namespace Devlooped.Extensions.AI; public sealed partial class ConfigurableChatClient : IChatClient, IDisposable { readonly IConfiguration configuration; + readonly IChatClientFactory factory; readonly string section; readonly string id; readonly ILogger logger; @@ -26,22 +20,23 @@ public sealed partial class ConfigurableChatClient : IChatClient, IDisposable IDisposable reloadToken; IChatClient innerClient; ChatClientMetadata metadata; - object? options; /// /// Initializes a new instance of the class. /// /// The configuration to read settings from. + /// The factory to use for creating chat clients. /// 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) + public ConfigurableChatClient(IConfiguration configuration, IChatClientFactory factory, 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.logger = Throw.IfNull(logger); this.section = Throw.IfNullOrEmpty(section); this.id = Throw.IfNullOrEmpty(id); @@ -51,6 +46,20 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); } + /// + /// 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, ChatClientFactory.CreateDefault(), logger, section, id, configure) + { + } + /// Disposes the client and stops monitoring configuration changes. public void Dispose() => reloadToken?.Dispose(); @@ -69,20 +78,14 @@ public Task GetResponseAsync(IEnumerable messages, Ch public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => innerClient.GetStreamingResponseAsync(messages, options, cancellationToken); - /// Exposes the optional configured for the client. - [EditorBrowsable(EditorBrowsableState.Never)] - public object? Options => options; - (IChatClient, ChatClientMetadata) Configure(IConfigurationSection configSection) { - var options = SetOptions(configSection); - Throw.IfNullOrEmpty(options?.ModelId, $"{configSection}:modelid"); - // If there was a custom id, we must validate it didn't change since that's not supported. if (configuration[$"{section}:id"] is { } newid && newid != id) throw new InvalidOperationException($"The ID of a configured client cannot be changed at runtime. Expected '{id}' but was '{newid}'."); - var apikey = options!.ApiKey; + // Resolve apikey from configuration with inheritance support + var apikey = configSection["apikey"]; // If the key contains a section-like value, get it from config if (apikey?.Contains('.') == true || apikey?.Contains(':') == true) apikey = configuration[apikey.Replace('.', ':')] ?? configuration[apikey.Replace('.', ':') + ":apikey"]; @@ -99,15 +102,10 @@ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerabl apikey = configuration[$"{keysection}:apikey"]; } - Throw.IfNullOrEmpty(apikey, $"{section}:apikey"); + // Create a configuration section wrapper that includes the resolved apikey + var effectiveSection = new ApiKeyResolvingConfigurationSection(configSection, apikey); - IChatClient client = options.Endpoint?.Host == "api.x.ai" - ? new GrokClient(apikey, configSection.Get() ?? new()).AsIChatClient(options.ModelId) - : options.Endpoint?.Host == "ai.azure.com" - ? new ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(apikey), SetOptions(configSection)).AsIChatClient(options.ModelId) - : options.Endpoint?.Host.EndsWith("openai.azure.com") == true - ? new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(apikey), options.ModelId, SetOptions(configSection)) - : new OpenAIChatClient(apikey, options.ModelId, options); + IChatClient client = factory.CreateClient(effectiveSection); configure?.Invoke(id, client); @@ -118,22 +116,6 @@ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerabl return (client, new ConfigurableChatClientMetadata(id, section, metadata.ProviderName, metadata.ProviderUri, metadata.DefaultModelId)); } - TOptions? SetOptions(IConfigurationSection section) where TOptions : class - { - var options = typeof(TOptions) switch - { - var t when t == typeof(ConfigurableClientOptions) => section.Get() as TOptions, - var t when t == typeof(ConfigurableInferenceOptions) => section.Get() as TOptions, - var t when t == typeof(ConfigurableAzureOptions) => section.Get() as TOptions, -#pragma warning disable SYSLIB1104 // The target type for a binder call could not be determined - _ => section.Get() -#pragma warning restore SYSLIB1104 // The target type for a binder call could not be determined - }; - - this.options = options; - return options; - } - void OnReload(object? state) { var configSection = configuration.GetRequiredSection(section); @@ -149,22 +131,25 @@ void OnReload(object? state) [LoggerMessage(LogLevel.Information, "ChatClient '{Id}' configured.")] private partial void LogConfigured(string id); - internal class ConfigurableClientOptions : OpenAIClientOptions - { - public string? ApiKey { get; set; } - public string? ModelId { get; set; } - } - - internal class ConfigurableInferenceOptions : AzureAIInferenceClientOptions + /// + /// A configuration section wrapper that overrides the apikey value with a resolved value. + /// + sealed class ApiKeyResolvingConfigurationSection(IConfigurationSection inner, string? resolvedApiKey) : IConfigurationSection { - public string? ApiKey { get; set; } - public string? ModelId { get; set; } - } + public string? this[string key] + { + get => string.Equals(key, "apikey", StringComparison.OrdinalIgnoreCase) && resolvedApiKey != null + ? resolvedApiKey + : inner[key]; + set => inner[key] = value; + } - internal class ConfigurableAzureOptions : AzureOpenAIClientOptions - { - public string? ApiKey { get; set; } - public string? ModelId { get; set; } + public string Key => inner.Key; + public string Path => inner.Path; + public string? Value { get => 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 fe8216d..c77c894 100644 --- a/src/Extensions/ConfigurableChatClientExtensions.cs +++ b/src/Extensions/ConfigurableChatClientExtensions.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using Devlooped.Extensions.AI; -using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -39,9 +38,13 @@ public static TBuilder AddChatClients(this TBuilder builder, ActionOptional 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 service collection. - public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients") + public static IServiceCollection AddChatClients(this IServiceCollection services, IConfiguration configuration, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients", bool useDefaultProviders = true) { + // Ensure the factory and providers are registered + services.AddChatClientFactory(useDefaultProviders); + foreach (var entry in configuration.AsEnumerable().Where(x => x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && x.Key.EndsWith("modelid", StringComparison.OrdinalIgnoreCase))) @@ -57,7 +60,10 @@ public static IServiceCollection AddChatClients(this IServiceCollection services services.TryAdd(new ServiceDescriptor(typeof(IChatClient), id, factory: (sp, _) => { - var client = new ConfigurableChatClient(configuration, sp.GetRequiredService>(), section, id, configureClient); + var client = new ConfigurableChatClient(configuration, + sp.GetRequiredService(), + sp.GetRequiredService>(), + section, id, configureClient); if (configurePipeline != null) { diff --git a/src/Extensions/IChatClientFactory.cs b/src/Extensions/IChatClientFactory.cs new file mode 100644 index 0000000..34fd732 --- /dev/null +++ b/src/Extensions/IChatClientFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; + +namespace Devlooped.Extensions.AI; + +/// +/// A factory for creating instances based on configuration. +/// +/// +/// 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 . +/// +/// +public interface IChatClientFactory +{ + /// + /// Creates an using the specified configuration section. + /// + /// The configuration section containing client settings including + /// endpoint, apikey, modelid, and optionally provider. + /// A configured instance. + /// + /// Thrown when no matching provider is found for the given configuration. + /// + IChatClient CreateClient(IConfigurationSection section); +} diff --git a/src/Extensions/IChatClientProvider.cs b/src/Extensions/IChatClientProvider.cs new file mode 100644 index 0000000..b165db7 --- /dev/null +++ b/src/Extensions/IChatClientProvider.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; + +namespace Devlooped.Extensions.AI; + +/// +/// Represents a provider that can create instances for a specific AI service. +/// +public interface IChatClientProvider +{ + /// + /// Gets the unique name of this provider (e.g., "openai", "azure.openai", "azure.inference", "xai"). + /// + /// + /// This name is used for explicit provider selection via the provider configuration key. + /// + string ProviderName { get; } + + /// + /// Gets the base URI prefix used for automatic provider detection, if any. + /// + /// + /// When a configuration section does not specify an explicit provider key, + /// the factory will match the endpoint against registered providers' base URIs. + /// The URI can include path segments for more specific matching (e.g., https://api.x.ai/v1). + /// + Uri? BaseUri { get; } + + /// + /// Gets the host suffix pattern used for automatic provider detection, if any. + /// + /// + /// When a configuration section does not specify an explicit provider key, + /// the factory will match the endpoint host against registered providers' host suffixes. + /// For example, .openai.azure.com matches any Azure OpenAI endpoint. + /// If both and are provided, + /// is tested first. + /// + string? HostSuffix { get; } + + /// + /// Creates an using the specified configuration section. + /// + /// The configuration section containing provider-specific settings. + /// A configured instance. + IChatClient Create(IConfigurationSection section); +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index c8d8f5c..0d3d28f 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -48,4 +48,12 @@ + + + + + + + +