From f0fa2121ca976d98e852b3d1289d53612949a9d5 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 11 Oct 2025 20:48:28 -0300 Subject: [PATCH 1/4] Add UseChatClients for configuration-driven clients --- src/AI.Tests/ConfigurableTests.cs | 37 +++++++++++++++++ src/AI/AI.csproj | 1 + src/AI/UseChatClientsExtensions.cs | 64 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 src/AI.Tests/ConfigurableTests.cs create mode 100644 src/AI/UseChatClientsExtensions.cs diff --git a/src/AI.Tests/ConfigurableTests.cs b/src/AI.Tests/ConfigurableTests.cs new file mode 100644 index 0000000..d9698e9 --- /dev/null +++ b/src/AI.Tests/ConfigurableTests.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Devlooped.Extensions.AI; + +public class ConfigurableTests +{ + [Fact] + public async Task CanConfigureClients() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:openai:modelid"] = "gpt-4.1.nano", + ["ai:clients:openai:ApiKey"] = "sk-asdfasdf", + ["ai:clients:grok:modelid"] = "grok-4-fast", + ["ai:clients:grok:ApiKey"] = "xai-asdfasdf", + ["ai:clients:grok:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var calls = new List(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var openai = services.GetRequiredKeyedService("openai"); + var grok = services.GetRequiredKeyedService("grok"); + + Assert.Equal("openai", openai.GetRequiredService().ProviderName); + Assert.Equal("x.ai", grok.GetRequiredService().ProviderName); + } +} diff --git a/src/AI/AI.csproj b/src/AI/AI.csproj index 6fe001a..b280975 100644 --- a/src/AI/AI.csproj +++ b/src/AI/AI.csproj @@ -12,6 +12,7 @@ + diff --git a/src/AI/UseChatClientsExtensions.cs b/src/AI/UseChatClientsExtensions.cs new file mode 100644 index 0000000..1a1ce5d --- /dev/null +++ b/src/AI/UseChatClientsExtensions.cs @@ -0,0 +1,64 @@ +using Devlooped.Extensions.AI.Grok; +using Devlooped.Extensions.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenAI; + +namespace Devlooped.Extensions.AI; + +public static class UseChatClientsExtensions +{ + public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action? configure = default, string prefix = "ai:clients") + { + foreach (var entry in configuration.AsEnumerable().Where(x => + x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + x.Key.EndsWith("modelid", StringComparison.OrdinalIgnoreCase))) + { + var section = string.Join(':', entry.Key.Split(':')[..^1]); + // ID == section after clients:, with optional overridable id + var id = configuration[$"{section}:id"] ?? section[(prefix.Length + 1)..]; + + var options = configuration.GetSection(section).Get(); + Throw.IfNullOrEmpty(options?.ModelId, entry.Key); + + var apikey = options!.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"]; + + var keysection = section; + // ApiKey inheritance by section parents. + // i.e. section ai:clients:grok:router does not need to have its own key, + // it will inherit from ai:clients:grok:key, for example. + while (string.IsNullOrEmpty(apikey)) + { + keysection = string.Join(':', keysection.Split(':')[..^1]); + if (string.IsNullOrEmpty(keysection)) + break; + apikey = configuration[$"{keysection}:apikey"]; + } + + Throw.IfNullOrEmpty(apikey, $"{section}:apikey"); + + var builder = services.AddKeyedChatClient(id, services => + { + if (options.Endpoint?.Host == "api.x.ai") + return new GrokChatClient(apikey, options.ModelId, options); + + return new OpenAIChatClient(apikey, options.ModelId, options); + }, options.Lifetime); + + configure?.Invoke(id, builder); + } + + return services; + } + + class ChatClientOptions : OpenAIClientOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Singleton; + } +} From 8dba1b649d02758d0f8846301757cb847d0908a3 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 11 Oct 2025 21:01:03 -0300 Subject: [PATCH 2/4] Initial configuration-driven chat clients --- src/AI.Tests/ConfigurableTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/AI.Tests/ConfigurableTests.cs b/src/AI.Tests/ConfigurableTests.cs index d9698e9..6233df9 100644 --- a/src/AI.Tests/ConfigurableTests.cs +++ b/src/AI.Tests/ConfigurableTests.cs @@ -1,5 +1,4 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +7,7 @@ namespace Devlooped.Extensions.AI; public class ConfigurableTests { [Fact] - public async Task CanConfigureClients() + public void CanConfigureClients() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -32,6 +31,6 @@ public async Task CanConfigureClients() var grok = services.GetRequiredKeyedService("grok"); Assert.Equal("openai", openai.GetRequiredService().ProviderName); - Assert.Equal("x.ai", grok.GetRequiredService().ProviderName); + Assert.Equal("xai", grok.GetRequiredService().ProviderName); } } From 79cea2866dc267d1991303864b5828a985b894c6 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 11 Oct 2025 21:08:26 -0300 Subject: [PATCH 3/4] Add tests for apikey config forwarding --- src/AI.Tests/ConfigurableTests.cs | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/AI.Tests/ConfigurableTests.cs b/src/AI.Tests/ConfigurableTests.cs index 6233df9..30bd6a3 100644 --- a/src/AI.Tests/ConfigurableTests.cs +++ b/src/AI.Tests/ConfigurableTests.cs @@ -33,4 +33,79 @@ public void CanConfigureClients() Assert.Equal("openai", openai.GetRequiredService().ProviderName); Assert.Equal("xai", grok.GetRequiredService().ProviderName); } + + [Fact] + public void CanOverrideClientId() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:grok:id"] = "xai", + ["ai:clients:grok:modelid"] = "grok-4-fast", + ["ai:clients:grok:apikey"] = "xai-asdfasdf", + ["ai:clients:grok:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var calls = new List(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var grok = services.GetRequiredKeyedService("xai"); + + Assert.Equal("xai", grok.GetRequiredService().ProviderName); + } + + [Fact] + public void CanSetApiKeyToConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["keys:grok"] = "xai-asdfasdf", + ["ai:clients:grok:modelid"] = "grok-4-fast", + ["ai:clients:grok:apikey"] = "keys:grok", + ["ai:clients:grok:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var calls = new List(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var grok = services.GetRequiredKeyedService("grok"); + + Assert.Equal("xai", grok.GetRequiredService().ProviderName); + } + + [Fact] + public void CanSetApiKeyToSection() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["keys:grok:apikey"] = "xai-asdfasdf", + ["ai:clients:grok:modelid"] = "grok-4-fast", + ["ai:clients:grok:apikey"] = "keys:grok", + ["ai:clients:grok:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var calls = new List(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var grok = services.GetRequiredKeyedService("grok"); + + Assert.Equal("xai", grok.GetRequiredService().ProviderName); + } } From 436b4dc655c644291def948fb8fe224a429dc921 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Mon, 13 Oct 2025 16:29:17 -0300 Subject: [PATCH 4/4] Add support for fully replacing everything in the client via config Except for the ID since that's what the instance is exported as, everything else can be changed, including the model provider (i.e. openai > xai), default model id, apikey and whatever options are supported by the underlying provider. --- src/AI.Tests/AI.Tests.csproj | 1 + src/AI.Tests/ConfigurableTests.cs | 73 ++++++++++++++-- src/AI.Tests/Extensions/LoggerFactory.cs | 33 ------- src/AI.Tests/Extensions/Logging.cs | 45 ++++++++++ src/AI/AI.csproj | 2 + src/AI/ConfigurableChatClient.cs | 106 +++++++++++++++++++++++ src/AI/UseChatClientsExtensions.cs | 38 ++------ 7 files changed, 226 insertions(+), 72 deletions(-) delete mode 100644 src/AI.Tests/Extensions/LoggerFactory.cs create mode 100644 src/AI.Tests/Extensions/Logging.cs create mode 100644 src/AI/ConfigurableChatClient.cs diff --git a/src/AI.Tests/AI.Tests.csproj b/src/AI.Tests/AI.Tests.csproj index 980a5de..4ea0ffb 100644 --- a/src/AI.Tests/AI.Tests.csproj +++ b/src/AI.Tests/AI.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/AI.Tests/ConfigurableTests.cs b/src/AI.Tests/ConfigurableTests.cs index 30bd6a3..47f0d7f 100644 --- a/src/AI.Tests/ConfigurableTests.cs +++ b/src/AI.Tests/ConfigurableTests.cs @@ -1,10 +1,11 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Devlooped.Extensions.AI; -public class ConfigurableTests +public class ConfigurableTests(ITestOutputHelper output) { [Fact] public void CanConfigureClients() @@ -20,8 +21,6 @@ public void CanConfigureClients() }) .Build(); - var calls = new List(); - var services = new ServiceCollection() .AddSingleton(configuration) .UseChatClients(configuration) @@ -47,8 +46,6 @@ public void CanOverrideClientId() }) .Build(); - var calls = new List(); - var services = new ServiceCollection() .AddSingleton(configuration) .UseChatClients(configuration) @@ -72,8 +69,6 @@ public void CanSetApiKeyToConfiguration() }) .Build(); - var calls = new List(); - var services = new ServiceCollection() .AddSingleton(configuration) .UseChatClients(configuration) @@ -97,8 +92,6 @@ public void CanSetApiKeyToSection() }) .Build(); - var calls = new List(); - var services = new ServiceCollection() .AddSingleton(configuration) .UseChatClients(configuration) @@ -108,4 +101,66 @@ public void CanSetApiKeyToSection() Assert.Equal("xai", grok.GetRequiredService().ProviderName); } + + [Fact] + public void CanChangeAndReloadModelId() + { + 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) + .AddLogging(builder => builder.AddTestOutput(output)) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("openai"); + + Assert.Equal("openai", client.GetRequiredService().ProviderName); + Assert.Equal("gpt-4.1", client.GetRequiredService().DefaultModelId); + + configuration["ai:clients:openai:modelid"] = "gpt-5"; + // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. + configuration.Reload(); + + Assert.Equal("gpt-5", client.GetRequiredService().DefaultModelId); + } + + [Fact] + public void CanChangeAndSwapProvider() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:chat:modelid"] = "gpt-4.1", + ["ai:clients:chat:apikey"] = "sk-asdfasdf", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddLogging(builder => builder.AddTestOutput(output)) + .UseChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("chat"); + + Assert.Equal("openai", client.GetRequiredService().ProviderName); + Assert.Equal("gpt-4.1", client.GetRequiredService().DefaultModelId); + + configuration["ai:clients:chat:modelid"] = "grok-4"; + configuration["ai:clients:chat:apikey"] = "xai-asdfasdf"; + configuration["ai:clients:chat:endpoint"] = "https://api.x.ai"; + + // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. + configuration.Reload(); + + Assert.Equal("xai", client.GetRequiredService().ProviderName); + Assert.Equal("grok-4", client.GetRequiredService().DefaultModelId); + } } diff --git a/src/AI.Tests/Extensions/LoggerFactory.cs b/src/AI.Tests/Extensions/LoggerFactory.cs deleted file mode 100644 index 139b507..0000000 --- a/src/AI.Tests/Extensions/LoggerFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -public static class LoggerFactoryExtensions -{ - public static ILoggerFactory AsLoggerFactory(this ITestOutputHelper output) => new LoggerFactory(output); -} - -public class LoggerFactory(ITestOutputHelper output) : ILoggerFactory -{ - public ILogger CreateLogger(string categoryName) => new TestOutputLogger(output, categoryName); - public void AddProvider(ILoggerProvider provider) { } - public void Dispose() { } - - // create ilogger implementation over testoutputhelper - public class TestOutputLogger(ITestOutputHelper output, string categoryName) : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull => null!; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (formatter == null) throw new ArgumentNullException(nameof(formatter)); - if (state == null) throw new ArgumentNullException(nameof(state)); - output.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); - } - } -} diff --git a/src/AI.Tests/Extensions/Logging.cs b/src/AI.Tests/Extensions/Logging.cs new file mode 100644 index 0000000..f71fa67 --- /dev/null +++ b/src/AI.Tests/Extensions/Logging.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +public static class LoggerFactoryExtensions +{ + public static ILoggerFactory AsLoggerFactory(this ITestOutputHelper output) => new TestLoggerFactory(output); + + public static ILoggingBuilder AddTestOutput(this ILoggingBuilder builder, ITestOutputHelper output) + => builder.AddProvider(new TestLoggerProider(output)); + + class TestLoggerProider(ITestOutputHelper output) : ILoggerProvider + { + readonly ILoggerFactory factory = new TestLoggerFactory(output); + + public ILogger CreateLogger(string categoryName) => factory.CreateLogger(categoryName); + + public void Dispose() { } + } + + class TestLoggerFactory(ITestOutputHelper output) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new TestOutputLogger(output, categoryName); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + + // create ilogger implementation over testoutputhelper + public class TestOutputLogger(ITestOutputHelper output, string categoryName) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null!; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (formatter == null) throw new ArgumentNullException(nameof(formatter)); + if (state == null) throw new ArgumentNullException(nameof(state)); + output.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}"); + } + } + } +} \ No newline at end of file diff --git a/src/AI/AI.csproj b/src/AI/AI.csproj index b280975..737c6d4 100644 --- a/src/AI/AI.csproj +++ b/src/AI/AI.csproj @@ -9,6 +9,7 @@ OSMFEULA.txt true + true @@ -17,6 +18,7 @@ + diff --git a/src/AI/ConfigurableChatClient.cs b/src/AI/ConfigurableChatClient.cs new file mode 100644 index 0000000..7a2c7a2 --- /dev/null +++ b/src/AI/ConfigurableChatClient.cs @@ -0,0 +1,106 @@ +using Devlooped.Extensions.AI.Grok; +using Devlooped.Extensions.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using OpenAI; + +namespace Devlooped.Extensions.AI; + +public sealed partial class ConfigurableChatClient : IDisposable, IChatClient +{ + readonly IConfiguration configuration; + readonly string section; + readonly string id; + readonly ILogger logger; + readonly Action? configure; + IDisposable reloadToken; + IChatClient innerClient; + + public ConfigurableChatClient(IConfiguration configuration, 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.logger = Throw.IfNull(logger); + this.section = Throw.IfNullOrEmpty(section); + this.id = Throw.IfNullOrEmpty(id); + this.configure = configure; + + innerClient = Configure(configuration.GetRequiredSection(section)); + reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); + } + + public void Dispose() => reloadToken?.Dispose(); + + /// + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => innerClient.GetResponseAsync(messages, options, cancellationToken); + /// + public object? GetService(Type serviceType, object? serviceKey = null) + => innerClient.GetService(serviceType, serviceKey); + /// + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => innerClient.GetStreamingResponseAsync(messages, options, cancellationToken); + + IChatClient Configure(IConfigurationSection configSection) + { + var options = configSection.Get(); + 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; + // 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"]; + + var keysection = section; + // ApiKey inheritance by section parents. + // i.e. section ai:clients:grok:router does not need to have its own key, + // it will inherit from ai:clients:grok:apikey, for example. + while (string.IsNullOrEmpty(apikey)) + { + keysection = string.Join(':', keysection.Split(':')[..^1]); + if (string.IsNullOrEmpty(keysection)) + break; + apikey = configuration[$"{keysection}:apikey"]; + } + + Throw.IfNullOrEmpty(apikey, $"{section}:apikey"); + + IChatClient client = options.Endpoint?.Host == "api.x.ai" + ? new GrokChatClient(apikey, options.ModelId, options) + : new OpenAIChatClient(apikey, options.ModelId, options); + + configure?.Invoke(id, client); + + LogConfigured(id); + + return client; + } + + void OnReload(object? state) + { + var configSection = configuration.GetRequiredSection(section); + + (innerClient as IDisposable)?.Dispose(); + reloadToken?.Dispose(); + + innerClient = Configure(configSection); + + reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null); + } + + [LoggerMessage(LogLevel.Information, "ChatClient {Id} configured.")] + private partial void LogConfigured(string id); + + class ConfigurableChatClientOptions : OpenAIClientOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } + } +} diff --git a/src/AI/UseChatClientsExtensions.cs b/src/AI/UseChatClientsExtensions.cs index 1a1ce5d..2f299e4 100644 --- a/src/AI/UseChatClientsExtensions.cs +++ b/src/AI/UseChatClientsExtensions.cs @@ -3,13 +3,14 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OpenAI; namespace Devlooped.Extensions.AI; public static class UseChatClientsExtensions { - public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action? configure = default, string prefix = "ai:clients") + public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action? configurePipeline = default, Action? configureClient = default, string prefix = "ai:clients") { foreach (var entry in configuration.AsEnumerable().Where(x => x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && @@ -18,38 +19,15 @@ public static IServiceCollection UseChatClients(this IServiceCollection services var section = string.Join(':', entry.Key.Split(':')[..^1]); // ID == section after clients:, with optional overridable id var id = configuration[$"{section}:id"] ?? section[(prefix.Length + 1)..]; - - var options = configuration.GetSection(section).Get(); - Throw.IfNullOrEmpty(options?.ModelId, entry.Key); - var apikey = options!.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"]; + var options = configuration.GetRequiredSection(section).Get(); + services.AddLogging(); - var keysection = section; - // ApiKey inheritance by section parents. - // i.e. section ai:clients:grok:router does not need to have its own key, - // it will inherit from ai:clients:grok:key, for example. - while (string.IsNullOrEmpty(apikey)) - { - keysection = string.Join(':', keysection.Split(':')[..^1]); - if (string.IsNullOrEmpty(keysection)) - break; - apikey = configuration[$"{keysection}:apikey"]; - } + var builder = services.AddKeyedChatClient(id, + services => new ConfigurableChatClient(configuration, services.GetRequiredService>(), section, id, configureClient), + options?.Lifetime ?? ServiceLifetime.Singleton); - Throw.IfNullOrEmpty(apikey, $"{section}:apikey"); - - var builder = services.AddKeyedChatClient(id, services => - { - if (options.Endpoint?.Host == "api.x.ai") - return new GrokChatClient(apikey, options.ModelId, options); - - return new OpenAIChatClient(apikey, options.ModelId, options); - }, options.Lifetime); - - configure?.Invoke(id, builder); + configurePipeline?.Invoke(id, builder); } return services;