Skip to content

Commit 3bfa24b

Browse files
committed
Add configurable support for Azure Inference and OpenAI
This also adds Azure-specific chat clients that can also select model per-request, for parity with Grok and OpenAI.
1 parent 3fb511b commit 3bfa24b

File tree

8 files changed

+251
-53
lines changed

8 files changed

+251
-53
lines changed

src/Extensions/ConfigurableChatClient.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
using Devlooped.Extensions.AI.Grok;
1+
using Azure;
2+
using Azure.AI.Inference;
3+
using Azure.AI.OpenAI;
4+
using Devlooped.Extensions.AI.Grok;
25
using Devlooped.Extensions.AI.OpenAI;
36
using Microsoft.Extensions.AI;
47
using Microsoft.Extensions.Configuration;
@@ -46,7 +49,7 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerabl
4649

4750
IChatClient Configure(IConfigurationSection configSection)
4851
{
49-
var options = configSection.Get<ConfigurableChatClientOptions>();
52+
var options = configSection.Get<ConfigurableClientOptions>();
5053
Throw.IfNullOrEmpty(options?.ModelId, $"{configSection}:modelid");
5154

5255
// If there was a custom id, we must validate it didn't change since that's not supported.
@@ -74,6 +77,10 @@ IChatClient Configure(IConfigurationSection configSection)
7477

7578
IChatClient client = options.Endpoint?.Host == "api.x.ai"
7679
? new GrokChatClient(apikey, options.ModelId, options)
80+
: options.Endpoint?.Host == "ai.azure.com"
81+
? new ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(apikey), configSection.Get<ConfigurableInferenceOptions>()).AsIChatClient(options.ModelId)
82+
: options.Endpoint?.Host.EndsWith("openai.azure.com") == true
83+
? new AzureOpenAIChatClient(options.Endpoint, new AzureKeyCredential(apikey), options.ModelId, configSection.Get<ConfigurableAzureOptions>())
7784
: new OpenAIChatClient(apikey, options.ModelId, options);
7885

7986
configure?.Invoke(id, client);
@@ -98,7 +105,19 @@ void OnReload(object? state)
98105
[LoggerMessage(LogLevel.Information, "ChatClient {Id} configured.")]
99106
private partial void LogConfigured(string id);
100107

101-
class ConfigurableChatClientOptions : OpenAIClientOptions
108+
class ConfigurableClientOptions : OpenAIClientOptions
109+
{
110+
public string? ApiKey { get; set; }
111+
public string? ModelId { get; set; }
112+
}
113+
114+
class ConfigurableInferenceOptions : AzureAIInferenceClientOptions
115+
{
116+
public string? ApiKey { get; set; }
117+
public string? ModelId { get; set; }
118+
}
119+
120+
class ConfigurableAzureOptions : AzureOpenAIClientOptions
102121
{
103122
public string? ApiKey { get; set; }
104123
public string? ModelId { get; set; }

src/Extensions/Extensions.csproj

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
<LangVersion>Preview</LangVersion>
66
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
77
<AssemblyName>Devlooped.Extensions.AI</AssemblyName>
8-
<PackageId>Devlooped.Extensions.AI</PackageId>
8+
<RootNamespace>$(AssemblyName)</RootNamespace>
9+
<PackageId>$(AssemblyName)</PackageId>
910
<Description>Extensions for Microsoft.Extensions.AI</Description>
1011
<PackageLicenseExpression></PackageLicenseExpression>
1112
<PackageLicenseFile>OSMFEULA.txt</PackageLicenseFile>
@@ -14,11 +15,13 @@
1415
</PropertyGroup>
1516

1617
<ItemGroup>
17-
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
18+
<PackageReference Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
19+
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.9.1-preview.1.25474.6" />
1820
<PackageReference Include="NuGetizer" Version="1.3.1" PrivateAssets="all" />
1921
<PackageReference Include="Microsoft.Extensions.AI" Version="9.9.1" />
2022
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.1-preview.1.25474.6" />
2123
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
24+
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
2225
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.9" />
2326
<PackageReference Include="Spectre.Console" Version="0.51.1" />
2427
<PackageReference Include="Spectre.Console.Json" Version="0.51.1" />
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Collections.Concurrent;
2+
using Azure;
3+
using Azure.AI.Inference;
4+
using Microsoft.Extensions.AI;
5+
6+
namespace Devlooped.Extensions.AI.OpenAI;
7+
8+
/// <summary>
9+
/// An <see cref="IChatClient"/> implementation for Azure AI Inference that supports per-request model selection.
10+
/// </summary>
11+
public class AzureInferenceChatClient : IChatClient
12+
{
13+
readonly ConcurrentDictionary<string, IChatClient> clients = new();
14+
15+
readonly string modelId;
16+
readonly ChatCompletionsClient client;
17+
readonly ChatClientMetadata? metadata;
18+
19+
/// <summary>
20+
/// Initializes the client with the specified API key, model ID, and optional OpenAI client options.
21+
/// </summary>
22+
public AzureInferenceChatClient(Uri endpoint, AzureKeyCredential credential, string modelId, AzureAIInferenceClientOptions? options = default)
23+
{
24+
this.modelId = modelId;
25+
26+
// NOTE: by caching the pipeline, we speed up creation of new chat clients per model,
27+
// since the pipeline will be the same for all of them.
28+
client = new ChatCompletionsClient(endpoint, credential, options);
29+
metadata = client.AsIChatClient(modelId)
30+
.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
31+
}
32+
33+
/// <inheritdoc/>
34+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
35+
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options, cancellation);
36+
37+
/// <inheritdoc/>
38+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
39+
=> GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options, cancellation);
40+
41+
IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, client.AsIChatClient);
42+
43+
void IDisposable.Dispose() => GC.SuppressFinalize(this);
44+
45+
/// <inheritdoc />
46+
public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
47+
{
48+
Type t when t == typeof(ChatClientMetadata) => metadata,
49+
_ => null
50+
};
51+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.ClientModel;
2+
using System.ClientModel.Primitives;
3+
using System.Collections.Concurrent;
4+
using Azure.AI.OpenAI;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Devlooped.Extensions.AI.OpenAI;
8+
9+
/// <summary>
10+
/// An <see cref="IChatClient"/> implementation for Azure OpenAI that supports per-request model selection.
11+
/// </summary>
12+
public class AzureOpenAIChatClient : IChatClient
13+
{
14+
readonly ConcurrentDictionary<string, IChatClient> clients = new();
15+
16+
readonly Uri endpoint;
17+
readonly string modelId;
18+
readonly ClientPipeline pipeline;
19+
readonly AzureOpenAIClientOptions options;
20+
readonly ChatClientMetadata? metadata;
21+
22+
/// <summary>
23+
/// Initializes the client with the given endpoint, API key, model ID, and optional Azure OpenAI client options.
24+
/// </summary>
25+
public AzureOpenAIChatClient(Uri endpoint, ApiKeyCredential credential, string modelId, AzureOpenAIClientOptions? options = default)
26+
{
27+
this.endpoint = endpoint;
28+
this.modelId = modelId;
29+
this.options = options ?? new();
30+
31+
// NOTE: by caching the pipeline, we speed up creation of new chat clients per model,
32+
// since the pipeline will be the same for all of them.
33+
var client = new AzureOpenAIClient(endpoint, credential, options);
34+
metadata = client.GetChatClient(modelId)
35+
.AsIChatClient()
36+
.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
37+
38+
metadata = new ChatClientMetadata(
39+
providerName: "azure.ai.openai",
40+
providerUri: metadata?.ProviderUri ?? endpoint,
41+
defaultModelId: metadata?.DefaultModelId ?? modelId);
42+
43+
pipeline = client.Pipeline;
44+
}
45+
46+
/// <inheritdoc/>
47+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
48+
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.SetResponseOptions(), cancellation);
49+
50+
/// <inheritdoc/>
51+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
52+
=> GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options.SetResponseOptions(), cancellation);
53+
54+
IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model
55+
=> new PipelineClient(pipeline, endpoint, options).GetOpenAIResponseClient(modelId).AsIChatClient());
56+
57+
void IDisposable.Dispose() => GC.SuppressFinalize(this);
58+
59+
/// <inheritdoc />
60+
public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch
61+
{
62+
Type t when t == typeof(ChatClientMetadata) => metadata,
63+
_ => null
64+
};
65+
66+
// Allows creating the base OpenAIClient with a pre-created pipeline.
67+
class PipelineClient(ClientPipeline pipeline, Uri endpoint, AzureOpenAIClientOptions options) : AzureOpenAIClient(pipeline, endpoint, options) { }
68+
}
Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
using System.ClientModel;
22
using System.ClientModel.Primitives;
33
using System.Collections.Concurrent;
4-
using System.Text.Json;
54
using Microsoft.Extensions.AI;
65
using OpenAI;
7-
using OpenAI.Responses;
86

97
namespace Devlooped.Extensions.AI.OpenAI;
108

119
/// <summary>
12-
/// An <see cref="IChatClient"/> implementation for OpenAI.
10+
/// An <see cref="IChatClient"/> implementation for OpenAI that supports per-request model selection.
1311
/// </summary>
1412
public class OpenAIChatClient : IChatClient
1513
{
@@ -39,38 +37,15 @@ public OpenAIChatClient(string apiKey, string modelId, OpenAIClientOptions? opti
3937

4038
/// <inheritdoc/>
4139
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
42-
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, SetOptions(options), cancellation);
40+
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.SetResponseOptions(), cancellation);
4341

4442
/// <inheritdoc/>
4543
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
46-
=> GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, SetOptions(options), cancellation);
44+
=> GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options.SetResponseOptions(), cancellation);
4745

4846
IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model
4947
=> new PipelineClient(pipeline, options).GetOpenAIResponseClient(modelId).AsIChatClient());
5048

51-
static ChatOptions? SetOptions(ChatOptions? options)
52-
{
53-
if (options is null)
54-
return null;
55-
56-
if (options.ReasoningEffort.HasValue || options.Verbosity.HasValue)
57-
{
58-
options.RawRepresentationFactory = _ =>
59-
{
60-
var creation = new ResponseCreationOptions();
61-
if (options.ReasoningEffort.HasValue)
62-
creation.ReasoningOptions = new ReasoningEffortOptions(options.ReasoningEffort!.Value);
63-
64-
if (options.Verbosity.HasValue)
65-
creation.TextOptions = new VerbosityOptions(options.Verbosity!.Value);
66-
67-
return creation;
68-
};
69-
}
70-
71-
return options;
72-
}
73-
7449
void IDisposable.Dispose() => GC.SuppressFinalize(this);
7550

7651
/// <inheritdoc />
@@ -82,24 +57,4 @@ IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model
8257

8358
// Allows creating the base OpenAIClient with a pre-created pipeline.
8459
class PipelineClient(ClientPipeline pipeline, OpenAIClientOptions? options) : OpenAIClient(pipeline, options) { }
85-
86-
class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions
87-
{
88-
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
89-
{
90-
writer.WritePropertyName("effort"u8);
91-
writer.WriteStringValue(effort.ToString().ToLowerInvariant());
92-
base.JsonModelWriteCore(writer, options);
93-
}
94-
}
95-
96-
class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions
97-
{
98-
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
99-
{
100-
writer.WritePropertyName("verbosity"u8);
101-
writer.WriteStringValue(verbosity.ToString().ToLowerInvariant());
102-
base.JsonModelWriteCore(writer, options);
103-
}
104-
}
10560
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.ClientModel.Primitives;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.AI;
4+
using OpenAI.Responses;
5+
6+
namespace Devlooped.Extensions.AI.OpenAI;
7+
8+
static class OpenAIExtensions
9+
{
10+
public static ChatOptions? SetResponseOptions(this ChatOptions? options)
11+
{
12+
if (options is null)
13+
return null;
14+
15+
if (options.ReasoningEffort.HasValue || options.Verbosity.HasValue)
16+
{
17+
options.RawRepresentationFactory = _ =>
18+
{
19+
var creation = new ResponseCreationOptions();
20+
if (options.ReasoningEffort.HasValue)
21+
creation.ReasoningOptions = new ReasoningEffortOptions(options.ReasoningEffort!.Value);
22+
23+
if (options.Verbosity.HasValue)
24+
creation.TextOptions = new VerbosityOptions(options.Verbosity!.Value);
25+
26+
return creation;
27+
};
28+
}
29+
30+
return options;
31+
}
32+
33+
class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions
34+
{
35+
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
36+
{
37+
writer.WritePropertyName("effort"u8);
38+
writer.WriteStringValue(effort.ToString().ToLowerInvariant());
39+
base.JsonModelWriteCore(writer, options);
40+
}
41+
}
42+
43+
class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions
44+
{
45+
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
46+
{
47+
writer.WritePropertyName("verbosity"u8);
48+
writer.WriteStringValue(verbosity.ToString().ToLowerInvariant());
49+
base.JsonModelWriteCore(writer, options);
50+
}
51+
}
52+
}

src/Extensions/UseChatClientsExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public static IServiceCollection UseChatClients(this IServiceCollection services
2121
var id = configuration[$"{section}:id"] ?? section[(prefix.Length + 1)..];
2222

2323
var options = configuration.GetRequiredSection(section).Get<ChatClientOptions>();
24+
// We need logging set up for the configurable client to log changes
2425
services.AddLogging();
2526

2627
var builder = services.AddKeyedChatClient(id,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,53 @@ public void CanChangeAndSwapProvider()
163163
Assert.Equal("xai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
164164
Assert.Equal("grok-4", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
165165
}
166+
167+
[Fact]
168+
public void CanConfigureAzureInference()
169+
{
170+
var configuration = new ConfigurationBuilder()
171+
.AddInMemoryCollection(new Dictionary<string, string?>
172+
{
173+
["ai:clients:chat:modelid"] = "gpt-5",
174+
["ai:clients:chat:apikey"] = "asdfasdf",
175+
["ai:clients:chat:endpoint"] = "https://ai.azure.com/.default"
176+
})
177+
.Build();
178+
179+
var services = new ServiceCollection()
180+
.AddSingleton<IConfiguration>(configuration)
181+
.AddLogging(builder => builder.AddTestOutput(output))
182+
.UseChatClients(configuration)
183+
.BuildServiceProvider();
184+
185+
var client = services.GetRequiredKeyedService<IChatClient>("chat");
186+
187+
Assert.Equal("azure.ai.inference", client.GetRequiredService<ChatClientMetadata>().ProviderName);
188+
Assert.Equal("gpt-5", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
189+
}
190+
191+
[Fact]
192+
public void CanConfigureAzureOpenAI()
193+
{
194+
var configuration = new ConfigurationBuilder()
195+
.AddInMemoryCollection(new Dictionary<string, string?>
196+
{
197+
["ai:clients:chat:modelid"] = "gpt-5",
198+
["ai:clients:chat:apikey"] = "asdfasdf",
199+
["ai:clients:chat:endpoint"] = "https://chat.openai.azure.com/",
200+
["ai:clients:chat:UserAgentApplicationId"] = "myapp/1.0"
201+
})
202+
.Build();
203+
204+
var services = new ServiceCollection()
205+
.AddSingleton<IConfiguration>(configuration)
206+
.AddLogging(builder => builder.AddTestOutput(output))
207+
.UseChatClients(configuration)
208+
.BuildServiceProvider();
209+
210+
var client = services.GetRequiredKeyedService<IChatClient>("chat");
211+
212+
Assert.Equal("azure.ai.openai", client.GetRequiredService<ChatClientMetadata>().ProviderName);
213+
Assert.Equal("gpt-5", client.GetRequiredService<ChatClientMetadata>().DefaultModelId);
214+
}
166215
}

0 commit comments

Comments
 (0)