Skip to content

Commit 5e48a4a

Browse files
committed
Add UseChatClients for configuration-driven clients
1 parent 0e01288 commit 5e48a4a

File tree

3 files changed

+102
-0
lines changed

3 files changed

+102
-0
lines changed

src/AI.Tests/ConfigurableTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Threading.Tasks;
2+
using Microsoft.Extensions.AI;
3+
using Microsoft.Extensions.Configuration;
4+
using Microsoft.Extensions.DependencyInjection;
5+
6+
namespace Devlooped.Extensions.AI;
7+
8+
public class ConfigurableTests
9+
{
10+
[Fact]
11+
public async Task CanConfigureClients()
12+
{
13+
var configuration = new ConfigurationBuilder()
14+
.AddInMemoryCollection(new Dictionary<string, string?>
15+
{
16+
["ai:clients:openai:modelid"] = "gpt-4.1.nano",
17+
["ai:clients:openai:ApiKey"] = "sk-asdfasdf",
18+
["ai:clients:grok:modelid"] = "grok-4-fast",
19+
["ai:clients:grok:ApiKey"] = "xai-asdfasdf",
20+
["ai:clients:grok:endpoint"] = "https://api.x.ai",
21+
})
22+
.Build();
23+
24+
var calls = new List<string>();
25+
26+
var services = new ServiceCollection()
27+
.AddSingleton<IConfiguration>(configuration)
28+
.UseChatClients(configuration)
29+
.BuildServiceProvider();
30+
31+
var openai = services.GetRequiredKeyedService<IChatClient>("openai");
32+
var grok = services.GetRequiredKeyedService<IChatClient>("grok");
33+
34+
Assert.Equal("openai", openai.GetRequiredService<ChatClientMetadata>().ProviderName);
35+
Assert.Equal("x.ai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
36+
}
37+
}

src/AI/AI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
</PropertyGroup>
1313

1414
<ItemGroup>
15+
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
1516
<PackageReference Include="NuGetizer" Version="1.3.1" PrivateAssets="all" />
1617
<PackageReference Include="Microsoft.Extensions.AI" Version="9.9.1" />
1718
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.8.0-preview.1.25412.6" />

src/AI/UseChatClientsExtensions.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Devlooped.Extensions.AI.Grok;
2+
using Devlooped.Extensions.AI.OpenAI;
3+
using Microsoft.Extensions.AI;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using OpenAI;
7+
8+
namespace Devlooped.Extensions.AI;
9+
10+
public static class UseChatClientsExtensions
11+
{
12+
public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action<string, ChatClientBuilder>? configure = default, string prefix = "ai:clients")
13+
{
14+
foreach (var entry in configuration.AsEnumerable().Where(x =>
15+
x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
16+
x.Key.EndsWith("modelid", StringComparison.OrdinalIgnoreCase)))
17+
{
18+
var section = string.Join(':', entry.Key.Split(':')[..^1]);
19+
// ID == section after clients:, with optional overridable id
20+
var id = configuration[$"{section}:id"] ?? section[(prefix.Length + 1)..];
21+
22+
var options = configuration.GetSection(section).Get<ChatClientOptions>();
23+
Throw.IfNullOrEmpty(options?.ModelId, entry.Key);
24+
25+
var apikey = options!.ApiKey;
26+
// If the key contains a section-like value, get it from config
27+
if (apikey?.Contains('.') == true || apikey?.Contains(':') == true)
28+
apikey = configuration[apikey.Replace('.', ':')] ?? configuration[apikey.Replace('.', ':') + ":apikey"];
29+
30+
var keysection = section;
31+
// ApiKey inheritance by section parents.
32+
// i.e. section ai:clients:grok:router does not need to have its own key,
33+
// it will inherit from ai:clients:grok:key, for example.
34+
while (string.IsNullOrEmpty(apikey))
35+
{
36+
keysection = string.Join(':', keysection.Split(':')[..^1]);
37+
if (string.IsNullOrEmpty(keysection))
38+
break;
39+
apikey = configuration[$"{keysection}:apikey"];
40+
}
41+
42+
Throw.IfNullOrEmpty(apikey, $"{section}:apikey");
43+
44+
var builder = services.AddKeyedChatClient(id, services =>
45+
{
46+
if (options.Endpoint?.Host == "api.x.ai")
47+
return new GrokChatClient(apikey, options.ModelId, options);
48+
49+
return new OpenAIChatClient(apikey, options.ModelId, options);
50+
}, options.Lifetime);
51+
52+
configure?.Invoke(id, builder);
53+
}
54+
55+
return services;
56+
}
57+
58+
class ChatClientOptions : OpenAIClientOptions
59+
{
60+
public string? ApiKey { get; set; }
61+
public string? ModelId { get; set; }
62+
public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Singleton;
63+
}
64+
}

0 commit comments

Comments
 (0)