diff --git a/Directory.Packages.props b/Directory.Packages.props
index c2bb1244..2cae051d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -54,7 +54,7 @@
-
+
@@ -64,7 +64,7 @@
-
+
diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
index 9692e002..42c5fa71 100644
--- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
+++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
@@ -53,6 +53,10 @@ public static IServiceCollection AddAzureOpenAIServices(
services.AddSingleton(provider =>
new AzureOpenAIClient(endpoint, credential));
+ // Register the resolved credential so AIChatService can build its ResponsesClient
+ // directly, bypassing the binary-incompatible AzureOpenAIClient.GetResponsesClient().
+ services.AddSingleton(credential);
+
services.AddAzureOpenAIChatCompletion(
aiOptions.ChatDeploymentName,
aiOptions.Endpoint,
diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs
index da3c4c34..3c0fed69 100644
--- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs
+++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs
@@ -1,10 +1,11 @@
-using Azure.AI.OpenAI;
+using Azure.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using OpenAI.Responses;
using System.ClientModel;
+using System.ClientModel.Primitives;
using System.Collections.Frozen;
namespace EssentialCSharp.Chat.Common.Services;
@@ -15,7 +16,6 @@ namespace EssentialCSharp.Chat.Common.Services;
public partial class AIChatService : IChatCompletionService
{
private readonly AIOptions _Options;
- private readonly AzureOpenAIClient _AzureClient;
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private readonly ResponsesClient _ResponseClient;
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -25,21 +25,90 @@ public partial class AIChatService : IChatCompletionService
public bool IsAvailable => true;
public bool SupportsContextualSearch => true;
- public AIChatService(IOptions options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger logger)
+ // The scope required for Azure OpenAI token auth via managed identity.
+ private const string AzureCognitiveServicesScope = "https://cognitiveservices.azure.com/.default";
+
+ // The Azure OpenAI REST API version used by Azure.AI.OpenAI 2.9.0-beta.1.
+ // AzureOpenAIClient.GetResponsesClient() is binary-incompatible with OpenAI 2.12.0
+ // because OpenAI changed the ResponsesClient constructor from (ClientPipeline, OpenAIClientOptions)
+ // to (ClientPipeline, ResponsesClientOptions). Until Azure.AI.OpenAI ships an update that
+ // supports OpenAI 2.12+, we construct ResponsesClient directly using BearerTokenPolicy.
+ private const string AzureApiVersion = "2025-04-01-preview";
+
+ public AIChatService(IOptions options, AISearchService searchService, TokenCredential credential, ILogger logger)
{
_Options = options.Value;
_SearchService = searchService;
_Logger = logger;
_AllowedMcpTools = _Options.AllowedMcpTools.ToFrozenSet(StringComparer.Ordinal);
- // Initialize Azure OpenAI client and get the Response Client from it
- _AzureClient = azureClient;
-
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- _ResponseClient = _AzureClient.GetResponsesClient();
+ // Build an Azure-authenticated ResponsesClient directly, targeting the deployment endpoint.
+ // The endpoint is: {AzureEndpoint}/openai/deployments/{ChatDeploymentName}
+ // ResponsesClient appends "/responses" to produce the full Azure REST path.
+ var deploymentEndpoint = new Uri(
+ $"{_Options.Endpoint.TrimEnd('/')}/openai/deployments/{_Options.ChatDeploymentName}");
+
+ var responsesOptions = new ResponsesClientOptions { Endpoint = deploymentEndpoint };
+ responsesOptions.AddPolicy(new ApiVersionPipelinePolicy(AzureApiVersion), PipelinePosition.PerCall);
+
+ var tokenProvider = new AzureCogServicesTokenProvider(credential);
+ var bearerPolicy = new BearerTokenPolicy(tokenProvider, AzureCognitiveServicesScope);
+ _ResponseClient = new ResponsesClient(bearerPolicy, responsesOptions);
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
+ ///
+ /// Adds the Azure api-version query parameter to every outgoing request.
+ /// Required because the base ResponsesClient does not know the Azure API version;
+ /// that concern was previously handled internally by AzureResponsesClient.
+ ///
+ private sealed class ApiVersionPipelinePolicy(string apiVersion) : PipelinePolicy
+ {
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ AppendApiVersion(message);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ AppendApiVersion(message);
+ await ProcessNextAsync(message, pipeline, currentIndex);
+ }
+
+ private void AppendApiVersion(PipelineMessage message)
+ {
+ var uri = message.Request.Uri?.ToString();
+ if (uri is null || uri.Contains("api-version", StringComparison.OrdinalIgnoreCase))
+ return;
+ var separator = uri.Contains('?') ? '&' : '?';
+ message.Request.Uri = new Uri(uri + separator + "api-version=" + apiVersion);
+ }
+ }
+
+ ///
+ /// Bridges Azure.Core's into the System.ClientModel
+ /// abstraction used by .
+ ///
+ private sealed class AzureCogServicesTokenProvider(TokenCredential credential) : AuthenticationTokenProvider
+ {
+ public override GetTokenOptions CreateTokenOptions(IReadOnlyDictionary context)
+ => new(context);
+
+ public override AuthenticationToken GetToken(GetTokenOptions options, CancellationToken cancellationToken)
+ {
+ var token = credential.GetToken(new TokenRequestContext([AzureCognitiveServicesScope]), cancellationToken);
+ return new AuthenticationToken(token.Token, "Bearer", token.ExpiresOn, null);
+ }
+
+ public override async ValueTask GetTokenAsync(GetTokenOptions options, CancellationToken cancellationToken)
+ {
+ var token = await credential.GetTokenAsync(new TokenRequestContext([AzureCognitiveServicesScope]), cancellationToken);
+ return new AuthenticationToken(token.Token, "Bearer", token.ExpiresOn, null);
+ }
+ }
+
///
/// Gets a single chat completion response with all optional features
///