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
35 changes: 28 additions & 7 deletions src/Extensions/ChatClientProviders.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -29,7 +31,9 @@
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<OpenAIClientOptions>(
new OpenAIClient(new ApiKeyCredential(options.ApiKey), options).GetChatClient(options.ModelId).AsIChatClient(),
options);
}

internal sealed class OpenAIProviderOptions : OpenAIClientOptions
Expand Down Expand Up @@ -61,7 +65,9 @@
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<AzureOpenAIClientOptions>(
new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options.ModelId, options),
options);
}

internal sealed class AzureOpenAIProviderOptions : Azure.AI.OpenAI.AzureOpenAIClientOptions
Expand Down Expand Up @@ -94,8 +100,10 @@
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<AzureAIInferenceClientOptions>(
new Azure.AI.Inference.ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options)
.AsIChatClient(options.ModelId),
options);
}

internal sealed class AzureInferenceProviderOptions : Azure.AI.Inference.AzureAIInferenceClientOptions
Expand Down Expand Up @@ -127,13 +135,26 @@
Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey");
Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid");

return new GrokClient(options.ApiKey, section.Get<GrokClientOptions>() ?? new())
.AsIChatClient(options.ModelId);
return new ProviderOptionsChatClient<GrokClientOptions>(
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<TOptions>(IChatClient inner, TOptions options) : DelegatingChatClient(inner)

Check warning on line 150 in src/Extensions/ChatClientProviders.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Parameter 'IChatClient inner' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 150 in src/Extensions/ChatClientProviders.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Parameter 'IChatClient inner' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 150 in src/Extensions/ChatClientProviders.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Parameter 'IChatClient inner' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 150 in src/Extensions/ChatClientProviders.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Parameter 'IChatClient inner' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
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);
}
137 changes: 137 additions & 0 deletions src/Tests/ConfigurableClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenAI;
using xAI;

namespace Devlooped.Extensions.AI;

Expand Down Expand Up @@ -32,6 +34,37 @@ public void CanConfigureClients()
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
}

[Fact]
public void CanGetClientOptions()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var openai = services.GetRequiredKeyedService<IChatClient>("openai");
var grok = services.GetRequiredKeyedService<IChatClient>("grok");

// Untyped by name+object
Assert.NotNull(openai.GetService<object>("options"));
// Typed to concrete options, no need for key
Assert.NotNull(openai.GetService<OpenAIClientOptions>());

Assert.NotNull(grok.GetService<object>("Options"));
Assert.NotNull(grok.GetService<GrokClientOptions>());
}

[Fact]
public void CanGetFromAlternativeKey()
{
Expand Down Expand Up @@ -259,4 +292,108 @@ public void CanConfigureAzureOpenAI()
Assert.Equal("azure.ai.openai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
Assert.Equal("gpt-5", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
}

[Fact]
public void CanInspectOpenAIProviderOptions()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var client = services.GetRequiredKeyedService<IChatClient>("openai");
var options = Assert.IsType<OpenAIChatClientProvider.OpenAIProviderOptions>(
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<string, string?>
{
["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<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var client = services.GetRequiredKeyedService<IChatClient>("chat");
var options = Assert.IsType<AzureOpenAIChatClientProvider.AzureOpenAIProviderOptions>(
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<string, string?>
{
["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<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var client = services.GetRequiredKeyedService<IChatClient>("chat");
var options = Assert.IsType<AzureAIInferenceChatClientProvider.AzureInferenceProviderOptions>(
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<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)
.AddChatClients(configuration)
.BuildServiceProvider();

var client = services.GetRequiredKeyedService<IChatClient>("grok");
var options = Assert.IsType<GrokChatClientProvider.GrokProviderOptions>(
client.GetService(typeof(object), "options"));

Assert.Same(options, client.GetService(typeof(GrokChatClientProvider.GrokProviderOptions), "OPTIONS"));
Assert.Equal("grok-4-fast", options.ModelId);
}
}
Loading