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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- `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.
- `AddClients` registers keyed `IClientFactory` (for sections with direct `apikey`) and keyed `IChatClient` (for sections with `modelid`) by full configuration section path. An optional `id` adds a secondary lookup key without replacing the section-path key. There is no separate `AddChatClients` — all registration flows through `AddClients`.
- `AddClients` registers keyed `IClientFactory` (for sections with direct `apikey`) and keyed `IChatClient` (for sections with `modelid`) by full configuration section path. Alternate lookup keys are registered in priority order (first wins): configured `id` attribute, then generated aliases: lowercase path, dotted path (`:` → `.`), dotted lowercase path, last path segment, last path segment lowercase. There is no separate `AddChatClients` — all registration flows through `AddClients`.
- `IChatClient` registrations use `ConfigurableChatClient` for auto-reload; on every configuration reload, the inner client is recreated via the held `IClientFactory`, which applies defaults through `DefaultsApplyingClientFactory` — there is no separate defaults wrapping at the registration level.
- Keyed `IClientFactory` registrations in `AddClients` are `DefaultsApplyingClientFactory(ConfigurationBoundClientFactory(...))` singletons; the `ConfigurationBoundClientFactory` re-resolves the provider on every `Create*Client` call to reflect configuration changes including provider switches — no reload required for factory-created clients.
- `ConfigurableChatClient` holds an `IClientFactory` reference (not `IClientFactoryResolver`); the public resolver-based constructors wrap the resolver in `ConfigurationBoundClientFactory` for backwards compatibility.
5 changes: 3 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnC
builder.AddAIClients();

var app = builder.Build();
var chat = app.Services.GetChatClient("AI:Clients:Grok");
var chat = app.Services.GetChatClient("Grok");
```

The default configuration prefix is `ai:clients`. A minimal configuration section looks like this:
Expand All @@ -60,10 +60,11 @@ Useful rules:
| --- | --- |
| section has `apikey` | keyed `IClientFactory` |
| section has `modelid` | keyed `IChatClient` |
| section path ends in `Grok` | adds `Grok` as an implicit lookup key |
| section has `id` | adds an extra lookup key |
| section has `lifetime` | controls the chat client lifetime |

`IChatClient` and `IClientFactory` registrations are keyed by their full configuration section path. An optional `id` adds a secondary lookup key without replacing the section-path key. `IChatClient` registrations are reloadable. `IClientFactory` registrations stay valid across configuration changes and create fresh clients each time you call `CreateChatClient`, `CreateSpeechToTextClient`, or `CreateTextToSpeechClient`.
`IChatClient` and `IClientFactory` registrations are keyed by their full configuration section path and by the last section path segment. An optional `id` adds another lookup key without replacing the section-path key. `IChatClient` registrations are reloadable. `IClientFactory` registrations stay valid across configuration changes and create fresh clients each time you call `CreateChatClient`, `CreateSpeechToTextClient`, or `CreateTextToSpeechClient`.

Built-in provider support:

Expand Down
110 changes: 79 additions & 31 deletions src/Extensions/AIClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ public static class AIClientExtensions
/// <see cref="DefaultsApplyingClientFactory"/> that applies <see cref="ChatDefaultsEntry"/> (and speech/text-to-speech
/// equivalents) registered via <c>Configure*ClientDefaults</c>. Also registers keyed <see cref="IChatClient"/> entries
/// by full section path for sections with a <c>modelid</c>, using <see cref="ConfigurableChatClient"/> for auto-reload
/// support. A configured <c>id</c> adds a secondary lookup key without replacing the section path key. Chat defaults flow
/// through the shared factory so they are applied once, including on every configuration reload.
/// support. The following alternate lookup keys are registered for each service (first registration wins):
/// <list type="bullet">
/// <item>Configured <c>id</c> attribute from the section (highest-priority alias).</item>
/// <item>Section path, lowercase invariant (e.g., <c>ai:clients:grok</c> for <c>ai:clients:Grok</c>).</item>
/// <item>Section path with <c>:</c> replaced by <c>.</c> (e.g., <c>ai.clients.Grok</c>).</item>
/// <item>Section path with <c>:</c> replaced by <c>.</c>, lowercase (e.g., <c>ai.clients.grok</c>).</item>
/// <item>Last path segment (e.g., <c>Grok</c>).</item>
/// <item>Last path segment, lowercase (e.g., <c>grok</c>).</item>
/// </list>
/// Chat defaults flow through the shared factory so they are applied once, including on every configuration reload.
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The application configuration.</param>
Expand All @@ -31,10 +39,10 @@ public static IServiceCollection AddAIClients(this IServiceCollection services,
services.AddClientFactoryResolver(useDefaultProviders);
services.AddLogging();

foreach (var section in EnumerateFactorySections(configuration, prefix))
var factorySections = EnumerateFactorySections(configuration, prefix).ToList();
foreach (var section in factorySections)
{
var path = section.Path;
var options = section.Get<ClientOptions>();

services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), path,
factory: (sp, _) =>
Expand All @@ -46,49 +54,61 @@ public static IServiceCollection AddAIClients(this IServiceCollection services,
return new DefaultsApplyingClientFactory(configurable, path, sp);
},
ServiceLifetime.Singleton));
}

if (!string.IsNullOrEmpty(options?.Id) && !string.Equals(options.Id, path, StringComparison.Ordinal))
{
services.TryAdd(new ServiceDescriptor(typeof(IClientFactory), options.Id,
factory: (sp, _) => sp.GetRequiredKeyedService<IClientFactory>(path),
ServiceLifetime.Singleton));
}
foreach (var section in factorySections)
TryAddKeyedAlias<IClientFactory>(services, section.Get<ClientOptions>()?.Id, section.Path, ServiceLifetime.Singleton);

foreach (var section in factorySections)
{
foreach (var alias in GetGeneratedAliases(section.Path))
TryAddKeyedAlias<IClientFactory>(services, alias, section.Path, ServiceLifetime.Singleton);
}

var normalizedPrefix = prefix.TrimEnd(':') + ":";
foreach (var entry in configuration.AsEnumerable().Where(x =>
x.Value is not null &&
x.Key.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase) &&
x.Key.EndsWith(":modelid", StringComparison.OrdinalIgnoreCase)))
{
var path = string.Join(':', entry.Key.Split(':')[..^1]);
var options = configuration.GetRequiredSection(path).Get<ClientOptions>();
var id = string.IsNullOrEmpty(options?.Id) ? path : options.Id;
var lifetime = options?.Lifetime ?? ServiceLifetime.Singleton;
var chatSections = configuration.AsEnumerable().Where(x =>
x.Value is not null &&
x.Key.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase) &&
x.Key.EndsWith(":modelid", StringComparison.OrdinalIgnoreCase))
.Select(entry =>
{
var path = string.Join(':', entry.Key.Split(':')[..^1]);
var options = configuration.GetRequiredSection(path).Get<ClientOptions>();
var defaultId = GetDefaultId(path);
var id = string.IsNullOrEmpty(options?.Id) ? defaultId : options.Id;
var lifetime = options?.Lifetime ?? ServiceLifetime.Singleton;

return (Path: path, Id: id, DefaultId: defaultId, ConfiguredId: options?.Id, Lifetime: lifetime);
})
.ToList();

services.TryAdd(new ServiceDescriptor(typeof(IChatClient), path,
foreach (var section in chatSections)
{
services.TryAdd(new ServiceDescriptor(typeof(IChatClient), section.Path,
factory: (sp, _) =>
{
// The keyed IClientFactory exists for sections with a direct apikey.
// For sub-sections that inherit their apikey from a parent, create a
// section-specific defaults-applying factory on the fly.
var sectionFactory = sp.GetKeyedService<IClientFactory>(path)
var sectionFactory = sp.GetKeyedService<IClientFactory>(section.Path)
?? new DefaultsApplyingClientFactory(
new ConfigurableClientFactory(configuration, path, sp.GetRequiredService<IClientFactoryResolver>()),
path, sp);
new ConfigurableClientFactory(configuration, section.Path, sp.GetRequiredService<IClientFactoryResolver>()),
section.Path, sp);

return new ConfigurableChatClient(sectionFactory, configuration,
sp.GetRequiredService<ILogger<ConfigurableChatClient>>(),
path, id);
section.Path, section.Id);
},
lifetime));
section.Lifetime));
}

if (!string.Equals(id, path, StringComparison.Ordinal))
{
services.TryAdd(new ServiceDescriptor(typeof(IChatClient), id,
factory: (sp, _) => sp.GetRequiredKeyedService<IChatClient>(path),
lifetime));
}
foreach (var section in chatSections)
TryAddKeyedAlias<IChatClient>(services, section.ConfiguredId, section.Path, section.Lifetime);

foreach (var section in chatSections)
{
foreach (var alias in GetGeneratedAliases(section.Path))
TryAddKeyedAlias<IChatClient>(services, alias, section.Path, section.Lifetime);
}

return services;
Expand Down Expand Up @@ -178,6 +198,34 @@ x.Value is not null &&
}
}

static string GetDefaultId(string path)
{
var separator = path.LastIndexOf(':');
return separator < 0 ? path : path[(separator + 1)..];
}

static IEnumerable<string> GetGeneratedAliases(string path)
{
yield return path.ToLowerInvariant();
var dotted = path.Replace(':', '.');
yield return dotted;
yield return dotted.ToLowerInvariant();
var lastSegment = GetDefaultId(path);
yield return lastSegment;
yield return lastSegment.ToLowerInvariant();
}

static void TryAddKeyedAlias<TService>(IServiceCollection services, string? id, string path, ServiceLifetime lifetime)
where TService : class
{
if (string.IsNullOrEmpty(id) || string.Equals(id, path, StringComparison.Ordinal))
return;

services.TryAdd(new ServiceDescriptor(typeof(TService), id,
factory: (sp, _) => sp.GetRequiredKeyedService<TService>(path),
lifetime));
}

internal class ClientOptions
{
public string? ApiKey { get; set; }
Expand Down
110 changes: 104 additions & 6 deletions src/Tests/ConfigurableClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public void CanGetClientOptions()
}

[Fact]
public void DoesNotRegisterShortNameWithoutConfiguredId()
public void CanResolveClientByImplicitSectionId()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
Expand All @@ -82,9 +82,11 @@ public void DoesNotRegisterShortNameWithoutConfiguredId()
.AddAIClients(configuration)
.BuildServiceProvider();

Assert.Null(services.GetKeyedService<IChatClient>("grok"));

var grok = services.GetRequiredKeyedService<IChatClient>("ai:clients:Grok");
var alias = services.GetRequiredKeyedService<IChatClient>("Grok");

Assert.Same(grok, alias);
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("grok"));
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
}

Expand All @@ -108,9 +110,11 @@ public void CanGetSectionAndIdFromMetadata()

var grok = services.GetRequiredKeyedService<IChatClient>("ai:clients:Grok");
var alias = services.GetRequiredKeyedService<IChatClient>("groked");
var defaultAlias = services.GetRequiredKeyedService<IChatClient>("Grok");
var metadata = grok.GetRequiredService<ConfigurableChatClientMetadata>();

Assert.Same(grok, alias);
Assert.Same(grok, defaultAlias);
Assert.Equal("groked", metadata.Id);
Assert.Equal("ai:clients:Grok", metadata.ConfigurationSection);
}
Expand All @@ -135,11 +139,99 @@ public void CanResolveClientByConfiguredId()

var grok = services.GetRequiredKeyedService<IChatClient>("ai:clients:grok");
var alias = services.GetRequiredKeyedService<IChatClient>("xai");
var defaultAlias = services.GetRequiredKeyedService<IChatClient>("grok");

Assert.Same(grok, alias);
Assert.Same(grok, defaultAlias);
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
}

[Fact]
public void CanResolveClientByLowercasePath()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ai:clients:Grok:modelid"] = "grok-4-fast",
["ai:clients:Grok:ApiKey"] = "xai-asdfasdf",
["ai:clients:Grok:endpoint"] = "https://api.x.ai",
})
.Build();

var services = new ServiceCollection()
.AddSingleton<IConfiguration>(configuration)
.AddAIClients(configuration)
.BuildServiceProvider();

var grok = services.GetRequiredKeyedService<IChatClient>("ai:clients:Grok");

// lowercase path
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("ai:clients:grok"));
// dotted path (original casing)
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("ai.clients.Grok"));
// dotted path (lowercase)
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("ai.clients.grok"));
// last segment (already tested elsewhere, but confirms it too)
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("Grok"));
// last segment lowercase
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("grok"));
}

[Fact]
public void ConfiguredIdWinsOverGeneratedAliasConflict()
{
// Section B's path segment is "fast", and section A has id="fast".
// Configured id should win (registered before generated aliases).
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ai:clients:grok:id"] = "fast",
["ai:clients:grok:modelid"] = "grok-4-fast",
["ai:clients:grok:apikey"] = "xai-asdfasdf",
["ai:clients:grok:endpoint"] = "https://api.x.ai",
["ai:clients:fast:modelid"] = "gpt-4.1.nano",
["ai:clients:fast:apikey"] = "sk-asdfasdf",
})
.Build();

var services = new ServiceCollection()
.AddSingleton<IConfiguration>(configuration)
.AddAIClients(configuration)
.BuildServiceProvider();

var grok = services.GetRequiredKeyedService<IChatClient>("ai:clients:grok");
var fast = services.GetRequiredKeyedService<IChatClient>("ai:clients:fast");

// "fast" as an alias key was claimed first by grok's configured id
Assert.Same(grok, services.GetRequiredKeyedService<IChatClient>("fast"));
Assert.NotSame(fast, services.GetRequiredKeyedService<IChatClient>("fast"));
}

[Fact]
public void CanResolveFactoryByAlternateKeys()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ai:clients:Grok:apikey"] = "xai-asdfasdf",
["ai:clients:Grok:endpoint"] = "https://api.x.ai",
})
.Build();

var services = new ServiceCollection()
.AddSingleton<IConfiguration>(configuration)
.AddAIClients(configuration)
.BuildServiceProvider();

var factory = services.GetRequiredKeyedService<IClientFactory>("ai:clients:Grok");

Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("ai:clients:grok"));
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("ai.clients.Grok"));
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("ai.clients.grok"));
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("Grok"));
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("grok"));
}

[Fact]
public void CanSetApiKeyToConfiguration()
{
Expand Down Expand Up @@ -531,11 +623,12 @@ public void CanResolveKeyedClientFactoryBySectionPath()

var factory = services.GetRequiredKeyedService<IClientFactory>("ai:clients:openai");
var alias = services.GetRequiredKeyedService<IClientFactory>("default-openai");
var defaultAlias = services.GetRequiredKeyedService<IClientFactory>("openai");
var client = factory.CreateChatClient();

Assert.Same(factory, alias);
Assert.Null(services.GetKeyedService<IClientFactory>("ai.clients.openai"));
Assert.Null(services.GetKeyedService<IClientFactory>("openai"));
Assert.Same(factory, defaultAlias);
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("ai.clients.openai"));
Assert.Equal("gpt-4.1.nano", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
}

Expand All @@ -557,6 +650,7 @@ public void AddClientsOnlyRegistersSectionsWithDirectApiKey()
.BuildServiceProvider();

Assert.NotNull(services.GetKeyedService<IClientFactory>("ai:clients:grok"));
Assert.NotNull(services.GetKeyedService<IClientFactory>("grok"));
Assert.Null(services.GetKeyedService<IClientFactory>("ai:clients:grok:router"));
}

Expand Down Expand Up @@ -741,9 +835,11 @@ public void AddClientsAppliesDefaultsToChatFactory()
.BuildServiceProvider();

var factory = services.GetRequiredKeyedService<IClientFactory>("ai:clients:openai");
var alias = services.GetRequiredKeyedService<IClientFactory>("openai");
var client = factory.CreateChatClient();

Assert.Null(services.GetKeyedService<IClientFactory>("ai.clients.openai"));
Assert.Same(factory, alias);
Assert.Same(factory, services.GetRequiredKeyedService<IClientFactory>("ai.clients.openai"));
Assert.NotNull(client.GetService<MarkerChatClient>());
}

Expand Down Expand Up @@ -886,6 +982,8 @@ public void SubSectionChatClientInheritsParentApiKey()

// But IChatClient resolves via apikey inheritance from the parent section
var client = services.GetRequiredKeyedService<IChatClient>("ai:clients:grok:chat");
var alias = services.GetRequiredKeyedService<IChatClient>("chat");
Assert.Same(client, alias);
Assert.Equal("xai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
}

Expand Down
Loading