Skip to content

Commit bced5ec

Browse files
committed
Allow retrieving native client options from chat client
If the caller knows what type of options it's dealing with (OpenAIClientOptions, AzureOpenAIClientOptions, AzureAIInferenceClientOptions or GrokClientOptions), it can request the specific type. As a fallback, an untyped service with the key `options` is provided.
1 parent 1543c69 commit bced5ec

2 files changed

Lines changed: 165 additions & 7 deletions

File tree

src/Extensions/ChatClientProviders.cs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.ClientModel;
22
using Azure;
3+
using Azure.AI.Inference;
4+
using Azure.AI.OpenAI;
35
using Devlooped.Extensions.AI.OpenAI;
46
using Microsoft.Extensions.AI;
57
using Microsoft.Extensions.Configuration;
@@ -29,7 +31,9 @@ public IChatClient Create(IConfigurationSection section)
2931
Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey");
3032
Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid");
3133

32-
return new OpenAIClient(new ApiKeyCredential(options.ApiKey), options).GetChatClient(options.ModelId).AsIChatClient();
34+
return new ProviderOptionsChatClient<OpenAIClientOptions>(
35+
new OpenAIClient(new ApiKeyCredential(options.ApiKey), options).GetChatClient(options.ModelId).AsIChatClient(),
36+
options);
3337
}
3438

3539
internal sealed class OpenAIProviderOptions : OpenAIClientOptions
@@ -61,7 +65,9 @@ public IChatClient Create(IConfigurationSection section)
6165
Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid");
6266
Throw.IfNull(options.Endpoint, $"{section.Path}:endpoint");
6367

64-
return new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options.ModelId, options);
68+
return new ProviderOptionsChatClient<AzureOpenAIClientOptions>(
69+
new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options.ModelId, options),
70+
options);
6571
}
6672

6773
internal sealed class AzureOpenAIProviderOptions : Azure.AI.OpenAI.AzureOpenAIClientOptions
@@ -94,8 +100,10 @@ public IChatClient Create(IConfigurationSection section)
94100
Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid");
95101
Throw.IfNull(options.Endpoint, $"{section.Path}:endpoint");
96102

97-
return new Azure.AI.Inference.ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options)
98-
.AsIChatClient(options.ModelId);
103+
return new ProviderOptionsChatClient<AzureAIInferenceClientOptions>(
104+
new Azure.AI.Inference.ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(options.ApiKey), options)
105+
.AsIChatClient(options.ModelId),
106+
options);
99107
}
100108

101109
internal sealed class AzureInferenceProviderOptions : Azure.AI.Inference.AzureAIInferenceClientOptions
@@ -127,13 +135,26 @@ public IChatClient Create(IConfigurationSection section)
127135
Throw.IfNullOrEmpty(options.ApiKey, $"{section.Path}:apikey");
128136
Throw.IfNullOrEmpty(options.ModelId, $"{section.Path}:modelid");
129137

130-
return new GrokClient(options.ApiKey, section.Get<GrokClientOptions>() ?? new())
131-
.AsIChatClient(options.ModelId);
138+
return new ProviderOptionsChatClient<GrokClientOptions>(
139+
new GrokClient(options.ApiKey, options).AsIChatClient(options.ModelId),
140+
options);
132141
}
133142

134-
internal sealed class GrokProviderOptions
143+
internal sealed class GrokProviderOptions : GrokClientOptions
135144
{
136145
public string? ApiKey { get; set; }
137146
public string? ModelId { get; set; }
138147
}
139148
}
149+
150+
sealed class ProviderOptionsChatClient<TOptions>(IChatClient inner, TOptions options) : DelegatingChatClient(inner)
151+
where TOptions : notnull
152+
{
153+
public override object? GetService(Type serviceType, object? serviceKey = null)
154+
=> IsOptionsRequest(serviceType, serviceKey) ? options : inner.GetService(serviceType, serviceKey);
155+
156+
bool IsOptionsRequest(Type serviceType, object? serviceKey)
157+
=> serviceType == typeof(object) ?
158+
serviceKey is string key && string.Equals(key, "options", StringComparison.OrdinalIgnoreCase) :
159+
typeof(TOptions).IsAssignableFrom(serviceType);
160+
}

src/Tests/ConfigurableClientTests.cs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Microsoft.Extensions.AI;
22
using Microsoft.Extensions.Configuration;
33
using Microsoft.Extensions.DependencyInjection;
4+
using OpenAI;
5+
using xAI;
46

57
namespace Devlooped.Extensions.AI;
68

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

37+
[Fact]
38+
public void CanGetClientOptions()
39+
{
40+
var configuration = new ConfigurationBuilder()
41+
.AddInMemoryCollection(new Dictionary<string, string?>
42+
{
43+
["ai:clients:openai:modelid"] = "gpt-4.1.nano",
44+
["ai:clients:openai:ApiKey"] = "sk-asdfasdf",
45+
["ai:clients:grok:modelid"] = "grok-4-fast",
46+
["ai:clients:grok:ApiKey"] = "xai-asdfasdf",
47+
["ai:clients:grok:endpoint"] = "https://api.x.ai",
48+
})
49+
.Build();
50+
51+
var services = new ServiceCollection()
52+
.AddSingleton<IConfiguration>(configuration)
53+
.AddChatClients(configuration)
54+
.BuildServiceProvider();
55+
56+
var openai = services.GetRequiredKeyedService<IChatClient>("openai");
57+
var grok = services.GetRequiredKeyedService<IChatClient>("grok");
58+
59+
// Untyped by name+object
60+
Assert.NotNull(openai.GetService<object>("options"));
61+
// Typed to concrete options, no need for key
62+
Assert.NotNull(openai.GetService<OpenAIClientOptions>());
63+
64+
Assert.NotNull(grok.GetService<object>("Options"));
65+
Assert.NotNull(grok.GetService<GrokClientOptions>());
66+
}
67+
3568
[Fact]
3669
public void CanGetFromAlternativeKey()
3770
{
@@ -259,4 +292,108 @@ public void CanConfigureAzureOpenAI()
259292
Assert.Equal("azure.ai.openai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
260293
Assert.Equal("gpt-5", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
261294
}
295+
296+
[Fact]
297+
public void CanInspectOpenAIProviderOptions()
298+
{
299+
var configuration = new ConfigurationBuilder()
300+
.AddInMemoryCollection(new Dictionary<string, string?>
301+
{
302+
["ai:clients:openai:modelid"] = "gpt-4.1.nano",
303+
["ai:clients:openai:apikey"] = "sk-asdfasdf",
304+
["ai:clients:openai:UserAgentApplicationId"] = "myapp/1.0",
305+
})
306+
.Build();
307+
308+
var services = new ServiceCollection()
309+
.AddSingleton<IConfiguration>(configuration)
310+
.AddChatClients(configuration)
311+
.BuildServiceProvider();
312+
313+
var client = services.GetRequiredKeyedService<IChatClient>("openai");
314+
var options = Assert.IsType<OpenAIChatClientProvider.OpenAIProviderOptions>(
315+
client.GetService(typeof(object), "OpTiOnS"));
316+
317+
Assert.Same(options, client.GetService(typeof(OpenAIChatClientProvider.OpenAIProviderOptions), "options"));
318+
Assert.Equal("gpt-4.1.nano", options.ModelId);
319+
Assert.Equal("myapp/1.0", options.UserAgentApplicationId);
320+
}
321+
322+
[Fact]
323+
public void CanInspectAzureOpenAIProviderOptions()
324+
{
325+
var configuration = new ConfigurationBuilder()
326+
.AddInMemoryCollection(new Dictionary<string, string?>
327+
{
328+
["ai:clients:chat:modelid"] = "gpt-5",
329+
["ai:clients:chat:apikey"] = "asdfasdf",
330+
["ai:clients:chat:endpoint"] = "https://chat.openai.azure.com/",
331+
["ai:clients:chat:UserAgentApplicationId"] = "myapp/1.0",
332+
})
333+
.Build();
334+
335+
var services = new ServiceCollection()
336+
.AddSingleton<IConfiguration>(configuration)
337+
.AddChatClients(configuration)
338+
.BuildServiceProvider();
339+
340+
var client = services.GetRequiredKeyedService<IChatClient>("chat");
341+
var options = Assert.IsType<AzureOpenAIChatClientProvider.AzureOpenAIProviderOptions>(
342+
client.GetService(typeof(object), "options"));
343+
344+
Assert.Same(options, client.GetService(typeof(AzureOpenAIChatClientProvider.AzureOpenAIProviderOptions), "OPTIONS"));
345+
Assert.Equal(new Uri("https://chat.openai.azure.com/"), options.Endpoint);
346+
Assert.Equal("myapp/1.0", options.UserAgentApplicationId);
347+
}
348+
349+
[Fact]
350+
public void CanInspectAzureInferenceProviderOptions()
351+
{
352+
var configuration = new ConfigurationBuilder()
353+
.AddInMemoryCollection(new Dictionary<string, string?>
354+
{
355+
["ai:clients:chat:modelid"] = "gpt-5",
356+
["ai:clients:chat:apikey"] = "asdfasdf",
357+
["ai:clients:chat:endpoint"] = "https://ai.azure.com/.default",
358+
})
359+
.Build();
360+
361+
var services = new ServiceCollection()
362+
.AddSingleton<IConfiguration>(configuration)
363+
.AddChatClients(configuration)
364+
.BuildServiceProvider();
365+
366+
var client = services.GetRequiredKeyedService<IChatClient>("chat");
367+
var options = Assert.IsType<AzureAIInferenceChatClientProvider.AzureInferenceProviderOptions>(
368+
client.GetService(typeof(object), "options"));
369+
370+
Assert.Same(options, client.GetService(typeof(AzureAIInferenceChatClientProvider.AzureInferenceProviderOptions), "OPTIONS"));
371+
Assert.Equal(new Uri("https://ai.azure.com/.default"), options.Endpoint);
372+
Assert.Equal("gpt-5", options.ModelId);
373+
}
374+
375+
[Fact]
376+
public void CanInspectGrokProviderOptions()
377+
{
378+
var configuration = new ConfigurationBuilder()
379+
.AddInMemoryCollection(new Dictionary<string, string?>
380+
{
381+
["ai:clients:grok:modelid"] = "grok-4-fast",
382+
["ai:clients:grok:apikey"] = "xai-asdfasdf",
383+
["ai:clients:grok:endpoint"] = "https://api.x.ai",
384+
})
385+
.Build();
386+
387+
var services = new ServiceCollection()
388+
.AddSingleton<IConfiguration>(configuration)
389+
.AddChatClients(configuration)
390+
.BuildServiceProvider();
391+
392+
var client = services.GetRequiredKeyedService<IChatClient>("grok");
393+
var options = Assert.IsType<GrokChatClientProvider.GrokProviderOptions>(
394+
client.GetService(typeof(object), "options"));
395+
396+
Assert.Same(options, client.GetService(typeof(GrokChatClientProvider.GrokProviderOptions), "OPTIONS"));
397+
Assert.Equal("grok-4-fast", options.ModelId);
398+
}
262399
}

0 commit comments

Comments
 (0)