Skip to content

Commit f6c68da

Browse files
committed
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.
1 parent 58e2821 commit f6c68da

File tree

7 files changed

+226
-72
lines changed

7 files changed

+226
-72
lines changed

src/AI.Tests/AI.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
2222
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
2323
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.9.0" />
24+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
2425
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.9" />
2526
</ItemGroup>
2627

src/AI.Tests/ConfigurableTests.cs

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
using Microsoft.Extensions.AI;
22
using Microsoft.Extensions.Configuration;
33
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Logging;
45

56
namespace Devlooped.Extensions.AI;
67

7-
public class ConfigurableTests
8+
public class ConfigurableTests(ITestOutputHelper output)
89
{
910
[Fact]
1011
public void CanConfigureClients()
@@ -20,8 +21,6 @@ public void CanConfigureClients()
2021
})
2122
.Build();
2223

23-
var calls = new List<string>();
24-
2524
var services = new ServiceCollection()
2625
.AddSingleton<IConfiguration>(configuration)
2726
.UseChatClients(configuration)
@@ -47,8 +46,6 @@ public void CanOverrideClientId()
4746
})
4847
.Build();
4948

50-
var calls = new List<string>();
51-
5249
var services = new ServiceCollection()
5350
.AddSingleton<IConfiguration>(configuration)
5451
.UseChatClients(configuration)
@@ -72,8 +69,6 @@ public void CanSetApiKeyToConfiguration()
7269
})
7370
.Build();
7471

75-
var calls = new List<string>();
76-
7772
var services = new ServiceCollection()
7873
.AddSingleton<IConfiguration>(configuration)
7974
.UseChatClients(configuration)
@@ -97,8 +92,6 @@ public void CanSetApiKeyToSection()
9792
})
9893
.Build();
9994

100-
var calls = new List<string>();
101-
10295
var services = new ServiceCollection()
10396
.AddSingleton<IConfiguration>(configuration)
10497
.UseChatClients(configuration)
@@ -108,4 +101,66 @@ public void CanSetApiKeyToSection()
108101

109102
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
110103
}
104+
105+
[Fact]
106+
public void CanChangeAndReloadModelId()
107+
{
108+
var configuration = new ConfigurationBuilder()
109+
.AddInMemoryCollection(new Dictionary<string, string?>
110+
{
111+
["ai:clients:openai:modelid"] = "gpt-4.1",
112+
["ai:clients:openai:apikey"] = "sk-asdfasdf",
113+
})
114+
.Build();
115+
116+
var services = new ServiceCollection()
117+
.AddSingleton<IConfiguration>(configuration)
118+
.AddLogging(builder => builder.AddTestOutput(output))
119+
.UseChatClients(configuration)
120+
.BuildServiceProvider();
121+
122+
var client = services.GetRequiredKeyedService<IChatClient>("openai");
123+
124+
Assert.Equal("openai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
125+
Assert.Equal("gpt-4.1", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
126+
127+
configuration["ai:clients:openai:modelid"] = "gpt-5";
128+
// NOTE: the in-memory provider does not support reload on change, so we must trigger it manually.
129+
configuration.Reload();
130+
131+
Assert.Equal("gpt-5", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
132+
}
133+
134+
[Fact]
135+
public void CanChangeAndSwapProvider()
136+
{
137+
var configuration = new ConfigurationBuilder()
138+
.AddInMemoryCollection(new Dictionary<string, string?>
139+
{
140+
["ai:clients:chat:modelid"] = "gpt-4.1",
141+
["ai:clients:chat:apikey"] = "sk-asdfasdf",
142+
})
143+
.Build();
144+
145+
var services = new ServiceCollection()
146+
.AddSingleton<IConfiguration>(configuration)
147+
.AddLogging(builder => builder.AddTestOutput(output))
148+
.UseChatClients(configuration)
149+
.BuildServiceProvider();
150+
151+
var client = services.GetRequiredKeyedService<IChatClient>("chat");
152+
153+
Assert.Equal("openai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
154+
Assert.Equal("gpt-4.1", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
155+
156+
configuration["ai:clients:chat:modelid"] = "grok-4";
157+
configuration["ai:clients:chat:apikey"] = "xai-asdfasdf";
158+
configuration["ai:clients:chat:endpoint"] = "https://api.x.ai";
159+
160+
// NOTE: the in-memory provider does not support reload on change, so we must trigger it manually.
161+
configuration.Reload();
162+
163+
Assert.Equal("xai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
164+
Assert.Equal("grok-4", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
165+
}
111166
}

src/AI.Tests/Extensions/LoggerFactory.cs

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/AI.Tests/Extensions/Logging.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Microsoft.Extensions.Logging;
7+
8+
public static class LoggerFactoryExtensions
9+
{
10+
public static ILoggerFactory AsLoggerFactory(this ITestOutputHelper output) => new TestLoggerFactory(output);
11+
12+
public static ILoggingBuilder AddTestOutput(this ILoggingBuilder builder, ITestOutputHelper output)
13+
=> builder.AddProvider(new TestLoggerProider(output));
14+
15+
class TestLoggerProider(ITestOutputHelper output) : ILoggerProvider
16+
{
17+
readonly ILoggerFactory factory = new TestLoggerFactory(output);
18+
19+
public ILogger CreateLogger(string categoryName) => factory.CreateLogger(categoryName);
20+
21+
public void Dispose() { }
22+
}
23+
24+
class TestLoggerFactory(ITestOutputHelper output) : ILoggerFactory
25+
{
26+
public ILogger CreateLogger(string categoryName) => new TestOutputLogger(output, categoryName);
27+
public void AddProvider(ILoggerProvider provider) { }
28+
public void Dispose() { }
29+
30+
// create ilogger implementation over testoutputhelper
31+
public class TestOutputLogger(ITestOutputHelper output, string categoryName) : ILogger
32+
{
33+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null!;
34+
35+
public bool IsEnabled(LogLevel logLevel) => true;
36+
37+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
38+
{
39+
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
40+
if (state == null) throw new ArgumentNullException(nameof(state));
41+
output.WriteLine($"{logLevel}: {categoryName}: {formatter(state, exception)}");
42+
}
43+
}
44+
}
45+
}

src/AI/AI.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<PackageLicenseExpression></PackageLicenseExpression>
1010
<PackageLicenseFile>OSMFEULA.txt</PackageLicenseFile>
1111
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
12+
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
1213
</PropertyGroup>
1314

1415
<ItemGroup>
@@ -17,6 +18,7 @@
1718
<PackageReference Include="Microsoft.Extensions.AI" Version="9.9.1" />
1819
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.8.0-preview.1.25412.6" />
1920
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
21+
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.9" />
2022
<PackageReference Include="OpenAI" Version="2.3.0" />
2123
<PackageReference Include="Spectre.Console" Version="0.51.1" />
2224
<PackageReference Include="Spectre.Console.Json" Version="0.51.1" />

src/AI/ConfigurableChatClient.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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.Logging;
6+
using OpenAI;
7+
8+
namespace Devlooped.Extensions.AI;
9+
10+
public sealed partial class ConfigurableChatClient : IDisposable, IChatClient
11+
{
12+
readonly IConfiguration configuration;
13+
readonly string section;
14+
readonly string id;
15+
readonly ILogger logger;
16+
readonly Action<string, IChatClient>? configure;
17+
IDisposable reloadToken;
18+
IChatClient innerClient;
19+
20+
public ConfigurableChatClient(IConfiguration configuration, ILogger logger, string section, string id, Action<string, IChatClient>? configure)
21+
{
22+
if (section.Contains('.'))
23+
throw new ArgumentException("Section separator must be ':', not '.'");
24+
25+
this.configuration = Throw.IfNull(configuration);
26+
this.logger = Throw.IfNull(logger);
27+
this.section = Throw.IfNullOrEmpty(section);
28+
this.id = Throw.IfNullOrEmpty(id);
29+
this.configure = configure;
30+
31+
innerClient = Configure(configuration.GetRequiredSection(section));
32+
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
33+
}
34+
35+
public void Dispose() => reloadToken?.Dispose();
36+
37+
/// <inheritdoc/>
38+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
39+
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
40+
/// <inheritdoc/>
41+
public object? GetService(Type serviceType, object? serviceKey = null)
42+
=> innerClient.GetService(serviceType, serviceKey);
43+
/// <inheritdoc/>
44+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
45+
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
46+
47+
IChatClient Configure(IConfigurationSection configSection)
48+
{
49+
var options = configSection.Get<ConfigurableChatClientOptions>();
50+
Throw.IfNullOrEmpty(options?.ModelId, $"{configSection}:modelid");
51+
52+
// If there was a custom id, we must validate it didn't change since that's not supported.
53+
if (configuration[$"{section}:id"] is { } newid && newid != id)
54+
throw new InvalidOperationException($"The ID of a configured client cannot be changed at runtime. Expected '{id}' but was '{newid}'.");
55+
56+
var apikey = options!.ApiKey;
57+
// If the key contains a section-like value, get it from config
58+
if (apikey?.Contains('.') == true || apikey?.Contains(':') == true)
59+
apikey = configuration[apikey.Replace('.', ':')] ?? configuration[apikey.Replace('.', ':') + ":apikey"];
60+
61+
var keysection = section;
62+
// ApiKey inheritance by section parents.
63+
// i.e. section ai:clients:grok:router does not need to have its own key,
64+
// it will inherit from ai:clients:grok:apikey, for example.
65+
while (string.IsNullOrEmpty(apikey))
66+
{
67+
keysection = string.Join(':', keysection.Split(':')[..^1]);
68+
if (string.IsNullOrEmpty(keysection))
69+
break;
70+
apikey = configuration[$"{keysection}:apikey"];
71+
}
72+
73+
Throw.IfNullOrEmpty(apikey, $"{section}:apikey");
74+
75+
IChatClient client = options.Endpoint?.Host == "api.x.ai"
76+
? new GrokChatClient(apikey, options.ModelId, options)
77+
: new OpenAIChatClient(apikey, options.ModelId, options);
78+
79+
configure?.Invoke(id, client);
80+
81+
LogConfigured(id);
82+
83+
return client;
84+
}
85+
86+
void OnReload(object? state)
87+
{
88+
var configSection = configuration.GetRequiredSection(section);
89+
90+
(innerClient as IDisposable)?.Dispose();
91+
reloadToken?.Dispose();
92+
93+
innerClient = Configure(configSection);
94+
95+
reloadToken = configuration.GetReloadToken().RegisterChangeCallback(OnReload, state: null);
96+
}
97+
98+
[LoggerMessage(LogLevel.Information, "ChatClient {Id} configured.")]
99+
private partial void LogConfigured(string id);
100+
101+
class ConfigurableChatClientOptions : OpenAIClientOptions
102+
{
103+
public string? ApiKey { get; set; }
104+
public string? ModelId { get; set; }
105+
}
106+
}

src/AI/UseChatClientsExtensions.cs

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
using Microsoft.Extensions.AI;
44
using Microsoft.Extensions.Configuration;
55
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
67
using OpenAI;
78

89
namespace Devlooped.Extensions.AI;
910

1011
public static class UseChatClientsExtensions
1112
{
12-
public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action<string, ChatClientBuilder>? configure = default, string prefix = "ai:clients")
13+
public static IServiceCollection UseChatClients(this IServiceCollection services, IConfiguration configuration, Action<string, ChatClientBuilder>? configurePipeline = default, Action<string, IChatClient>? configureClient = default, string prefix = "ai:clients")
1314
{
1415
foreach (var entry in configuration.AsEnumerable().Where(x =>
1516
x.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
@@ -18,38 +19,15 @@ public static IServiceCollection UseChatClients(this IServiceCollection services
1819
var section = string.Join(':', entry.Key.Split(':')[..^1]);
1920
// ID == section after clients:, with optional overridable id
2021
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);
2422

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"];
23+
var options = configuration.GetRequiredSection(section).Get<ChatClientOptions>();
24+
services.AddLogging();
2925

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-
}
26+
var builder = services.AddKeyedChatClient(id,
27+
services => new ConfigurableChatClient(configuration, services.GetRequiredService<ILogger<ConfigurableChatClient>>(), section, id, configureClient),
28+
options?.Lifetime ?? ServiceLifetime.Singleton);
4129

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);
30+
configurePipeline?.Invoke(id, builder);
5331
}
5432

5533
return services;

0 commit comments

Comments
 (0)