diff --git a/src/Extensions/ChatClientProviders.cs b/src/Extensions/ChatClientProviders.cs index bd26b72..180605c 100644 --- a/src/Extensions/ChatClientProviders.cs +++ b/src/Extensions/ChatClientProviders.cs @@ -1,5 +1,7 @@ using System.ClientModel; using Azure; +using Azure.AI.Inference; +using Azure.AI.OpenAI; using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -29,7 +31,9 @@ public IChatClient Create(IConfigurationSection section) Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey"); Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid"); - return new OpenAIClient(new ApiKeyCredential(options.ApiKey), options).GetChatClient(options.ModelId).AsIChatClient(); + return new ProviderOptionsChatClient( + new OpenAIClient(new ApiKeyCredential(options.ApiKey), options).GetChatClient(options.ModelId).AsIChatClient(), + options); } internal sealed class OpenAIProviderOptions : OpenAIClientOptions @@ -61,7 +65,9 @@ public IChatClient Create(IConfigurationSection section) 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); + return new ProviderOptionsChatClient( + new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options.ModelId, options), + options); } internal sealed class AzureOpenAIProviderOptions : Azure.AI.OpenAI.AzureOpenAIClientOptions @@ -94,8 +100,10 @@ public IChatClient Create(IConfigurationSection section) 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); + return new ProviderOptionsChatClient( + new Azure.AI.Inference.ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options) + .AsIChatClient(options.ModelId), + options); } internal sealed class AzureInferenceProviderOptions : Azure.AI.Inference.AzureAIInferenceClientOptions @@ -127,13 +135,26 @@ public IChatClient Create(IConfigurationSection section) 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); + return new ProviderOptionsChatClient( + new GrokClient(options.ApiKey, options).AsIChatClient(options.ModelId), + options); } - internal sealed class GrokProviderOptions + internal sealed class GrokProviderOptions : GrokClientOptions { public string? ApiKey { get; set; } public string? ModelId { get; set; } } } + +sealed class ProviderOptionsChatClient(IChatClient inner, TOptions options) : DelegatingChatClient(inner) + where TOptions : notnull +{ + public override object? GetService(Type serviceType, object? serviceKey = null) + => IsOptionsRequest(serviceType, serviceKey) ? options : inner.GetService(serviceType, serviceKey); + + bool IsOptionsRequest(Type serviceType, object? serviceKey) + => serviceType == typeof(object) ? + serviceKey is string key && string.Equals(key, "options", StringComparison.OrdinalIgnoreCase) : + typeof(TOptions).IsAssignableFrom(serviceType); +} diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs index df98300..e6b3bc6 100644 --- a/src/Tests/ConfigurableClientTests.cs +++ b/src/Tests/ConfigurableClientTests.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using OpenAI; +using xAI; namespace Devlooped.Extensions.AI; @@ -32,6 +34,37 @@ public void CanConfigureClients() Assert.Equal("xai", grok.GetRequiredService().ProviderName); } + [Fact] + public void CanGetClientOptions() + { + 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 services = new ServiceCollection() + .AddSingleton(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var openai = services.GetRequiredKeyedService("openai"); + var grok = services.GetRequiredKeyedService("grok"); + + // Untyped by name+object + Assert.NotNull(openai.GetService("options")); + // Typed to concrete options, no need for key + Assert.NotNull(openai.GetService()); + + Assert.NotNull(grok.GetService("Options")); + Assert.NotNull(grok.GetService()); + } + [Fact] public void CanGetFromAlternativeKey() { @@ -259,4 +292,108 @@ public void CanConfigureAzureOpenAI() Assert.Equal("azure.ai.openai", client.GetRequiredService().ProviderName); Assert.Equal("gpt-5", client.GetRequiredService().DefaultModelId); } + + [Fact] + public void CanInspectOpenAIProviderOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:openai:modelid"] = "gpt-4.1.nano", + ["ai:clients:openai:apikey"] = "sk-asdfasdf", + ["ai:clients:openai:UserAgentApplicationId"] = "myapp/1.0", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("openai"); + var options = Assert.IsType( + client.GetService(typeof(object), "OpTiOnS")); + + Assert.Same(options, client.GetService(typeof(OpenAIChatClientProvider.OpenAIProviderOptions), "options")); + Assert.Equal("gpt-4.1.nano", options.ModelId); + Assert.Equal("myapp/1.0", options.UserAgentApplicationId); + } + + [Fact] + public void CanInspectAzureOpenAIProviderOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:chat:modelid"] = "gpt-5", + ["ai:clients:chat:apikey"] = "asdfasdf", + ["ai:clients:chat:endpoint"] = "https://chat.openai.azure.com/", + ["ai:clients:chat:UserAgentApplicationId"] = "myapp/1.0", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("chat"); + var options = Assert.IsType( + client.GetService(typeof(object), "options")); + + Assert.Same(options, client.GetService(typeof(AzureOpenAIChatClientProvider.AzureOpenAIProviderOptions), "OPTIONS")); + Assert.Equal(new Uri("https://chat.openai.azure.com/"), options.Endpoint); + Assert.Equal("myapp/1.0", options.UserAgentApplicationId); + } + + [Fact] + public void CanInspectAzureInferenceProviderOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:chat:modelid"] = "gpt-5", + ["ai:clients:chat:apikey"] = "asdfasdf", + ["ai:clients:chat:endpoint"] = "https://ai.azure.com/.default", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("chat"); + var options = Assert.IsType( + client.GetService(typeof(object), "options")); + + Assert.Same(options, client.GetService(typeof(AzureAIInferenceChatClientProvider.AzureInferenceProviderOptions), "OPTIONS")); + Assert.Equal(new Uri("https://ai.azure.com/.default"), options.Endpoint); + Assert.Equal("gpt-5", options.ModelId); + } + + [Fact] + public void CanInspectGrokProviderOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var client = services.GetRequiredKeyedService("grok"); + var options = Assert.IsType( + client.GetService(typeof(object), "options")); + + Assert.Same(options, client.GetService(typeof(GrokChatClientProvider.GrokProviderOptions), "OPTIONS")); + Assert.Equal("grok-4-fast", options.ModelId); + } }