diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 67037001071..fb0ee48a236 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -25,6 +28,27 @@ namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . internal sealed class OpenAIChatClient : IChatClient { + // These delegate instances are used to call the internal overloads of CompleteChatAsync and CompleteChatStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ChatCompletionOptions, RequestOptions, Task>>? + _completeChatAsync = + (Func, ChatCompletionOptions, RequestOptions, Task>>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, Task>>)); + private static readonly Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>? + _completeChatStreamingAsync = + (Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>)); + /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -64,7 +88,10 @@ public async Task GetResponseAsync( var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); + var task = _completeChatAsync is not null ? + _completeChatAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken); + var response = await task.ConfigureAwait(false); return FromOpenAIChatCompletion(response.Value, openAIOptions); } @@ -79,7 +106,9 @@ public IAsyncEnumerable GetStreamingResponseAsync( var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); + var chatCompletionUpdates = _completeChatStreamingAsync is not null ? + _completeChatStreamingAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index e2fbfdf3f84..fc9f84ed228 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -32,6 +33,23 @@ internal sealed class OpenAIResponsesChatClient : IChatClient private static readonly Type? _internalResponseReasoningSummaryTextDeltaEventType = Type.GetType("OpenAI.Responses.InternalResponseReasoningSummaryTextDeltaEvent, OpenAI"); private static readonly PropertyInfo? _summaryTextDeltaProperty = _internalResponseReasoningSummaryTextDeltaEventType?.GetProperty("Delta"); + // These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ResponseCreationOptions, RequestOptions, Task>>? + _createResponseAsync = + (Func, ResponseCreationOptions, RequestOptions, Task>>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, Task>>)); + private static readonly Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>? + _createResponseStreamingAsync = + (Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>)); + /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -79,7 +97,10 @@ public async Task GetResponseAsync( var openAIOptions = ToOpenAIResponseCreationOptions(options); // Make the call to the OpenAIResponseClient. - var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + var task = _createResponseAsync is not null ? + _createResponseAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken); + var openAIResponse = (await task.ConfigureAwait(false)).Value; // Convert the response to a ChatResponse. return FromOpenAIResponse(openAIResponse, openAIOptions); @@ -208,7 +229,9 @@ public IAsyncEnumerable GetStreamingResponseAsync( var openAIResponseItems = ToOpenAIResponseItems(messages, options); var openAIOptions = ToOpenAIResponseCreationOptions(options); - var streamingUpdates = _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + var streamingUpdates = _createResponseStreamingAsync is not null ? + _createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken); } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs new file mode 100644 index 00000000000..dbe38e42a2c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable CA1307 // Specify StringComparison + +namespace Microsoft.Extensions.AI; + +/// Provides utility methods for creating . +internal static class RequestOptionsExtensions +{ + /// Creates a configured for use with OpenAI. + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + { + RequestOptions requestOptions = new() + { + CancellationToken = cancellationToken, + BufferResponse = !streaming + }; + + requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + + return requestOptions; + } + + /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. + private sealed class MeaiUserAgentPolicy : PipelinePolicy + { + public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); + + private static readonly string _userAgentValue = CreateUserAgentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private static void AddUserAgentHeader(PipelineMessage message) => + message.Request.Headers.Add("User-Agent", _userAgentValue); + + private static string CreateUserAgentValue() + { + const string Name = "MEAI"; + + if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +}