1- using Azure . AI . OpenAI ;
1+ using Azure . Core ;
22using Microsoft . Extensions . Logging ;
33using Microsoft . Extensions . Options ;
44using ModelContextProtocol . Client ;
55using ModelContextProtocol . Protocol ;
66using OpenAI . Responses ;
77using System . ClientModel ;
8+ using System . ClientModel . Primitives ;
89using System . Collections . Frozen ;
910
1011namespace EssentialCSharp . Chat . Common . Services ;
@@ -15,7 +16,6 @@ namespace EssentialCSharp.Chat.Common.Services;
1516public partial class AIChatService : IChatCompletionService
1617{
1718 private readonly AIOptions _Options ;
18- private readonly AzureOpenAIClient _AzureClient ;
1919#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.
2020 private readonly ResponsesClient _ResponseClient ;
2121#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
2525 public bool IsAvailable => true ;
2626 public bool SupportsContextualSearch => true ;
2727
28- public AIChatService ( IOptions < AIOptions > options , AISearchService searchService , AzureOpenAIClient azureClient , ILogger < AIChatService > logger )
28+ // The scope required for Azure OpenAI token auth via managed identity.
29+ private const string AzureCognitiveServicesScope = "https://cognitiveservices.azure.com/.default" ;
30+
31+ // The Azure OpenAI REST API version used by Azure.AI.OpenAI 2.9.0-beta.1.
32+ // AzureOpenAIClient.GetResponsesClient() is binary-incompatible with OpenAI 2.12.0
33+ // because OpenAI changed the ResponsesClient constructor from (ClientPipeline, OpenAIClientOptions)
34+ // to (ClientPipeline, ResponsesClientOptions). Until Azure.AI.OpenAI ships an update that
35+ // supports OpenAI 2.12+, we construct ResponsesClient directly using BearerTokenPolicy.
36+ private const string AzureApiVersion = "2025-04-01-preview" ;
37+
38+ public AIChatService ( IOptions < AIOptions > options , AISearchService searchService , TokenCredential credential , ILogger < AIChatService > logger )
2939 {
3040 _Options = options . Value ;
3141 _SearchService = searchService ;
3242 _Logger = logger ;
3343 _AllowedMcpTools = _Options . AllowedMcpTools . ToFrozenSet ( StringComparer . Ordinal ) ;
3444
35- // Initialize Azure OpenAI client and get the Response Client from it
36- _AzureClient = azureClient ;
37-
3845#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.
39- _ResponseClient = _AzureClient . GetResponsesClient ( ) ;
46+ // Build an Azure-authenticated ResponsesClient directly, targeting the deployment endpoint.
47+ // The endpoint is: {AzureEndpoint}/openai/deployments/{ChatDeploymentName}
48+ // ResponsesClient appends "/responses" to produce the full Azure REST path.
49+ var deploymentEndpoint = new Uri (
50+ $ "{ _Options . Endpoint . TrimEnd ( '/' ) } /openai/deployments/{ _Options . ChatDeploymentName } ") ;
51+
52+ var responsesOptions = new ResponsesClientOptions { Endpoint = deploymentEndpoint } ;
53+ responsesOptions . AddPolicy ( new ApiVersionPipelinePolicy ( AzureApiVersion ) , PipelinePosition . PerCall ) ;
54+
55+ var tokenProvider = new AzureCogServicesTokenProvider ( credential ) ;
56+ var bearerPolicy = new BearerTokenPolicy ( tokenProvider , AzureCognitiveServicesScope ) ;
57+ _ResponseClient = new ResponsesClient ( bearerPolicy , responsesOptions ) ;
4058#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.
4159 }
4260
61+ /// <summary>
62+ /// Adds the Azure api-version query parameter to every outgoing request.
63+ /// Required because the base ResponsesClient does not know the Azure API version;
64+ /// that concern was previously handled internally by AzureResponsesClient.
65+ /// </summary>
66+ private sealed class ApiVersionPipelinePolicy ( string apiVersion ) : PipelinePolicy
67+ {
68+ public override void Process ( PipelineMessage message , IReadOnlyList < PipelinePolicy > pipeline , int currentIndex )
69+ {
70+ AppendApiVersion ( message ) ;
71+ ProcessNext ( message , pipeline , currentIndex ) ;
72+ }
73+
74+ public override async ValueTask ProcessAsync ( PipelineMessage message , IReadOnlyList < PipelinePolicy > pipeline , int currentIndex )
75+ {
76+ AppendApiVersion ( message ) ;
77+ await ProcessNextAsync ( message , pipeline , currentIndex ) ;
78+ }
79+
80+ private void AppendApiVersion ( PipelineMessage message )
81+ {
82+ var uri = message . Request . Uri ? . ToString ( ) ;
83+ if ( uri is null || uri . Contains ( "api-version" , StringComparison . OrdinalIgnoreCase ) )
84+ return ;
85+ var separator = uri . Contains ( '?' ) ? '&' : '?' ;
86+ message . Request . Uri = new Uri ( uri + separator + "api-version=" + apiVersion ) ;
87+ }
88+ }
89+
90+ /// <summary>
91+ /// Bridges Azure.Core's <see cref="TokenCredential"/> into the System.ClientModel
92+ /// <see cref="AuthenticationTokenProvider"/> abstraction used by <see cref="BearerTokenPolicy"/>.
93+ /// </summary>
94+ private sealed class AzureCogServicesTokenProvider ( TokenCredential credential ) : AuthenticationTokenProvider
95+ {
96+ public override GetTokenOptions CreateTokenOptions ( IReadOnlyDictionary < string , object > context )
97+ => new ( context ) ;
98+
99+ public override AuthenticationToken GetToken ( GetTokenOptions options , CancellationToken cancellationToken )
100+ {
101+ var token = credential . GetToken ( new TokenRequestContext ( [ AzureCognitiveServicesScope ] ) , cancellationToken ) ;
102+ return new AuthenticationToken ( token . Token , "Bearer" , token . ExpiresOn , null ) ;
103+ }
104+
105+ public override async ValueTask < AuthenticationToken > GetTokenAsync ( GetTokenOptions options , CancellationToken cancellationToken )
106+ {
107+ var token = await credential . GetTokenAsync ( new TokenRequestContext ( [ AzureCognitiveServicesScope ] ) , cancellationToken ) ;
108+ return new AuthenticationToken ( token . Token , "Bearer" , token . ExpiresOn , null ) ;
109+ }
110+ }
111+
43112 /// <summary>
44113 /// Gets a single chat completion response with all optional features
45114 /// </summary>
0 commit comments