Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 8 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ var grok = app.Services.GetRequiredKeyedService<IChatClient>("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<IClientFactory>();
var chat = factory.CreateChatClient(section);
var speechToText = factory.CreateSpeechToTextClient(section);
var textToSpeech = factory.CreateTextToSpeechClient(section);
var factory = app.Services.GetRequiredKeyedService<IClientFactory>(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
Expand Down
154 changes: 64 additions & 90 deletions src/Extensions/ClientFactoryExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,7 +10,7 @@ namespace Microsoft.Extensions.DependencyInjection;
[EditorBrowsable(EditorBrowsableState.Never)]
public static class ClientFactoryExtensions
{
/// <summary>Adds the default <see cref="IClientFactory"/> and built-in providers to the service collection.</summary>
/// <summary>Adds the default <see cref="ClientFactoryResolver"/> and built-in providers to the service collection.</summary>
/// <param name="services">The service collection.</param>
/// <param name="registerDefaults">Whether to register the default built-in providers.</param>
/// <returns>The service collection for chaining.</returns>
Expand All @@ -25,13 +24,13 @@ public static IServiceCollection AddClientFactory(this IServiceCollection servic
services.TryAddEnumerable(ServiceDescriptor.Singleton<IClientProvider, GrokClientProvider>());
}

services.TryAddSingleton<ClientFactory>();
services.TryAddSingleton<IClientFactory>(sp => sp.GetRequiredService<ClientFactory>());
services.TryAddSingleton<ClientFactoryResolver>();
services.TryAddSingleton<IClientFactoryResolver>(sp => sp.GetRequiredService<ClientFactoryResolver>());

return services;
}

/// <summary>Adds the default <see cref="IClientFactory"/> and built-in providers to the host application builder.</summary>
/// <summary>Adds the default <see cref="ClientFactoryResolver"/> and built-in providers to the host application builder.</summary>
/// <param name="builder">The host application builder.</param>
/// <param name="registerDefaults">Whether to register the default built-in providers.</param>
/// <returns>The builder for chaining.</returns>
Expand All @@ -41,14 +40,59 @@ public static TBuilder AddClientFactory<TBuilder>(this TBuilder builder, bool re
return builder;
}

/// <summary>Adds keyed <see cref="IClientFactory"/> registrations for configuration sections with direct API keys.</summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The application configuration.</param>
/// <param name="prefix">The configuration prefix for clients. Defaults to <c>ai:clients</c>.</param>
/// <param name="useDefaultProviders">Whether to register the default built-in providers.</param>
/// <returns>The service collection for chaining.</returns>
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<IClientFactoryResolver>().Resolve(section),
ServiceLifetime.Singleton));

services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), new ServiceKey(section.Path),
factory: (sp, _) => sp.GetRequiredKeyedService<IClientFactory>(section.Path),
ServiceLifetime.Singleton));

var dottedKey = section.Path.Replace(':', '.');

services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), dottedKey,
factory: (sp, _) => sp.GetRequiredService<IClientFactoryResolver>().Resolve(section),
ServiceLifetime.Singleton));

services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), new ServiceKey(dottedKey),
factory: (sp, _) => sp.GetRequiredKeyedService<IClientFactory>(section.Path),
ServiceLifetime.Singleton));
}

return services;
}

/// <summary>Adds keyed <see cref="IClientFactory"/> registrations for configuration sections with direct API keys.</summary>
/// <param name="builder">The host application builder.</param>
/// <param name="prefix">The configuration prefix for clients. Defaults to <c>ai:clients</c>.</param>
/// <param name="useDefaultProviders">Whether to register the default built-in providers.</param>
/// <returns>The builder for chaining.</returns>
public static TBuilder AddClients<TBuilder>(this TBuilder builder, string prefix = "ai:clients", bool useDefaultProviders = true) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddClients(builder.Configuration, prefix, useDefaultProviders);
return builder;
}

/// <summary>Registers a typed <see cref="IClientProvider"/> with the service collection.</summary>
/// <typeparam name="TProvider">The provider type to register.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddClientProvider<TProvider>(this IServiceCollection services)
where TProvider : class, IClientProvider
{
services.AddEnumerable(ServiceDescriptor.Singleton<IClientProvider, TProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IClientProvider, TProvider>());
return services;
}

Expand All @@ -62,93 +106,23 @@ public static IServiceCollection AddClientProvider<TProvider>(
Func<IServiceProvider, TProvider> implementationFactory)
where TProvider : class, IClientProvider
{
services.AddEnumerable(ServiceDescriptor.Singleton<IClientProvider>(implementationFactory));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IClientProvider>(implementationFactory));
return services;
}

/// <summary>Registers an inline <see cref="IClientProvider"/> with the specified name, base URI, host suffix, and factory functions.</summary>
/// <param name="services">The service collection.</param>
/// <param name="name">The unique name for the provider.</param>
/// <param name="baseUri">The optional base URI for automatic endpoint matching.</param>
/// <param name="hostSuffix">The optional host suffix for automatic endpoint matching (e.g., ".openai.azure.com").</param>
/// <param name="chatFactory">The factory function to create chat clients.</param>
/// <param name="speechToTextFactory">The optional factory function to create speech-to-text clients.</param>
/// <param name="textToSpeechFactory">The optional factory function to create text-to-speech clients.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddClientProvider(
this IServiceCollection services,
string name,
Uri? baseUri,
string? hostSuffix,
Func<IConfigurationSection, IChatClient> chatFactory,
Func<IConfigurationSection, ISpeechToTextClient>? speechToTextFactory = null,
Func<IConfigurationSection, ITextToSpeechClient>? textToSpeechFactory = null)
{
services.AddEnumerable(ServiceDescriptor.Singleton<IClientProvider>(
new DelegateClientProvider(name, baseUri, hostSuffix, chatFactory, speechToTextFactory, textToSpeechFactory)));
return services;
}

/// <summary>Registers an inline <see cref="IClientProvider"/> with the specified name, base URI, and factory functions.</summary>
/// <param name="services">The service collection.</param>
/// <param name="name">The unique name for the provider.</param>
/// <param name="baseUri">The optional base URI for automatic endpoint matching.</param>
/// <param name="chatFactory">The factory function to create chat clients.</param>
/// <param name="speechToTextFactory">The optional factory function to create speech-to-text clients.</param>
/// <param name="textToSpeechFactory">The optional factory function to create text-to-speech clients.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddClientProvider(
this IServiceCollection services,
string name,
Uri? baseUri,
Func<IConfigurationSection, IChatClient> chatFactory,
Func<IConfigurationSection, ISpeechToTextClient>? speechToTextFactory = null,
Func<IConfigurationSection, ITextToSpeechClient>? textToSpeechFactory = null)
=> services.AddClientProvider(name, baseUri, null, chatFactory, speechToTextFactory, textToSpeechFactory);

/// <summary>Registers an inline <see cref="IClientProvider"/> with the specified name and factory functions.</summary>
/// <param name="services">The service collection.</param>
/// <param name="name">The unique name for the provider.</param>
/// <param name="chatFactory">The factory function to create chat clients.</param>
/// <param name="speechToTextFactory">The optional factory function to create speech-to-text clients.</param>
/// <param name="textToSpeechFactory">The optional factory function to create text-to-speech clients.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddClientProvider(
this IServiceCollection services,
string name,
Func<IConfigurationSection, IChatClient> chatFactory,
Func<IConfigurationSection, ISpeechToTextClient>? speechToTextFactory = null,
Func<IConfigurationSection, ITextToSpeechClient>? 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);

/// <summary>A delegate-based <see cref="IClientProvider"/> for inline registrations.</summary>
sealed class DelegateClientProvider(
string name,
Uri? baseUri,
string? hostSuffix,
Func<IConfigurationSection, IChatClient> chatFactory,
Func<IConfigurationSection, ISpeechToTextClient>? speechToTextFactory,
Func<IConfigurationSection, ITextToSpeechClient>? textToSpeechFactory) : IClientProvider
static IEnumerable<IConfigurationSection> 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<string> sections = new(StringComparer.OrdinalIgnoreCase);

sealed class DelegateClientFactory(
Func<IConfigurationSection, IChatClient> chatFactory,
Func<IConfigurationSection, ISpeechToTextClient>? speechToTextFactory,
Func<IConfigurationSection, ITextToSpeechClient>? 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;

namespace Devlooped.Extensions.AI;

/// <summary>Default implementation of <see cref="IClientFactory"/> that resolves providers by name or by matching endpoint URIs.</summary>
public class ClientFactory : IClientFactory
/// <summary>Resolves providers by name or by matching endpoint URIs, then returns section-bound factories.</summary>
public class ClientFactoryResolver : IClientFactoryResolver
{
readonly IClientProvider defaultProvider = new OpenAIClientProvider();

readonly Dictionary<string, IClientProvider> providersByName;
readonly List<(Uri BaseUri, IClientProvider Provider)> providersByBaseUri;
readonly List<(string HostSuffix, IClientProvider Provider)> providersByHostSuffix;

/// <summary>Initializes a new instance of the <see cref="ClientFactory"/> class with the specified providers.</summary>
/// <summary>Initializes a new instance of the <see cref="ClientFactoryResolver"/> class with the specified providers.</summary>
/// <param name="providers">The collection of registered providers.</param>
public ClientFactory(IEnumerable<IClientProvider> providers)
public ClientFactoryResolver(IEnumerable<IClientProvider> providers)
{
providersByName = new(StringComparer.OrdinalIgnoreCase);
providersByBaseUri = [];
Expand All @@ -37,9 +36,9 @@ public ClientFactory(IEnumerable<IClientProvider> providers)
providersByHostSuffix.Sort((a, b) => b.HostSuffix.Length.CompareTo(a.HostSuffix.Length));
}

/// <summary>Creates a <see cref="ClientFactory"/> with the built-in providers registered.</summary>
/// <returns>A factory with OpenAI, Azure OpenAI, Azure AI Inference, and Grok providers.</returns>
public static ClientFactory CreateDefault() => new(
/// <summary>Creates a <see cref="ClientFactoryResolver"/> with the built-in providers registered.</summary>
/// <returns>A resolver with OpenAI, Azure OpenAI, Azure AI Inference, and Grok providers.</returns>
public static ClientFactoryResolver CreateDefault() => new(
[
new OpenAIClientProvider(),
new AzureOpenAIClientProvider(),
Expand All @@ -48,16 +47,7 @@ public ClientFactory(IEnumerable<IClientProvider> providers)
]);

/// <inheritdoc/>
public IChatClient CreateChatClient(IConfigurationSection section)
=> ResolveProvider(section).GetFactory().CreateChatClient(section);

/// <inheritdoc/>
public ISpeechToTextClient CreateSpeechToTextClient(IConfigurationSection section)
=> ResolveProvider(section).GetFactory().CreateSpeechToTextClient(section);

/// <inheritdoc/>
public ITextToSpeechClient CreateTextToSpeechClient(IConfigurationSection section)
=> ResolveProvider(section).GetFactory().CreateTextToSpeechClient(section);
public IClientFactory Resolve(IConfigurationSection section) => ResolveProvider(section).GetFactory(section);

/// <summary>Resolves the appropriate provider for the given configuration section.</summary>
/// <param name="section">The configuration section.</param>
Expand Down
Loading
Loading