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