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 ///