diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json index 120654b993f..2655aa489db 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.AI.OpenAI, Version=10.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.AI.OpenAI, Version=10.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "static class OpenAI.Assistants.MicrosoftExtensionsAIAssistantsExtensions", @@ -208,6 +208,20 @@ "Stage": "Experimental" } ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.OpenAIRequestPolicies", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.OpenAIRequestPolicies.OpenAIRequestPolicies();", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.OpenAIRequestPolicies.AddPolicy(System.ClientModel.Primitives.PipelinePolicy policy, System.ClientModel.Primitives.PipelinePosition position = System.ClientModel.Primitives.PipelinePosition.PerCall);", + "Stage": "Experimental" + } + ] } ] } \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index ffa326c4994..fb4c0c4c8e1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -22,6 +22,7 @@ #pragma warning disable CA1308 // Normalize strings to uppercase #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable MEAI001 // OpenAIRequestPolicies is experimental namespace Microsoft.Extensions.AI; @@ -55,6 +56,9 @@ internal sealed partial class OpenAIChatClient : IChatClient /// The underlying . private readonly ChatClient _chatClient; + /// Caller-registered policies applied to every . + private readonly OpenAIRequestPolicies _requestPolicies = new(); + /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . @@ -76,6 +80,7 @@ public OpenAIChatClient(ChatClient chatClient) serviceKey is not null ? null : serviceType == typeof(ChatClientMetadata) ? _metadata : serviceType == typeof(ChatClient) ? _chatClient : + serviceType == typeof(OpenAIRequestPolicies) ? _requestPolicies : serviceType.IsInstanceOfType(this) ? this : null; } @@ -94,7 +99,7 @@ public async Task GetResponseAsync( // Make the call to OpenAI. var task = _completeChatAsync is not null ? - _completeChatAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _completeChatAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: false, _requestPolicies)) : _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken); var response = await task.ConfigureAwait(false); @@ -115,7 +120,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( // Make the call to OpenAI. var chatCompletionUpdates = _completeChatStreamingAsync is not null ? - _completeChatStreamingAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _completeChatStreamingAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: true, _requestPolicies)) : _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 06a81519e59..8585b9be9ea 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -13,6 +13,7 @@ using OpenAI.Embeddings; #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable MEAI001 // OpenAIRequestPolicies is experimental namespace Microsoft.Extensions.AI; @@ -40,6 +41,9 @@ internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGeneratorThe number of dimensions produced by the generator. private readonly int? _dimensions; + /// Caller-registered policies applied to every . + private readonly OpenAIRequestPolicies _requestPolicies = new(); + /// Initializes a new instance of the class. /// The underlying client. /// The number of dimensions to generate in each embedding. @@ -66,7 +70,7 @@ public async Task>> GenerateAsync(IEnumerab OpenAI.Embeddings.EmbeddingGenerationOptions? openAIOptions = ToOpenAIOptions(options); var t = _generateEmbeddingsAsync is not null ? - _generateEmbeddingsAsync(_embeddingClient, values, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _generateEmbeddingsAsync(_embeddingClient, values, openAIOptions, cancellationToken.ToRequestOptions(streaming: false, _requestPolicies)) : _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken); var embeddings = (await t.ConfigureAwait(false)).Value; @@ -104,6 +108,7 @@ void IDisposable.Dispose() serviceKey is not null ? null : serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : serviceType == typeof(EmbeddingClient) ? _embeddingClient : + serviceType == typeof(OpenAIRequestPolicies) ? _requestPolicies : serviceType.IsInstanceOfType(this) ? this : null; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRequestPolicies.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRequestPolicies.cs new file mode 100644 index 00000000000..46403352e6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRequestPolicies.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an extension hook for adding instances to the +/// built by Microsoft.Extensions.AI for every outbound OpenAI request +/// made through the owning IChatClient or IEmbeddingGenerator. +/// +/// +/// +/// Retrieve the instance via +/// (or the equivalent on other Microsoft.Extensions.AI client interfaces) using +/// as the service type. The instance is per-client and +/// reachable through any ChatClientBuilder decorator chain. +/// +/// +/// Customer-registered policies are appended after Microsoft.Extensions.AI's own internal +/// policies, so a policy that calls message.Request.Headers.Set("User-Agent", ...) +/// replaces the existing value, while one that calls Headers.Add(...) stacks an +/// additional value. +/// +/// +/// Registration is intended for one-time configuration at startup, but is safe to call +/// concurrently with in-flight requests. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class OpenAIRequestPolicies +{ + private static readonly Entry[] _empty = Array.Empty(); + + private Entry[] _entries = _empty; + + /// Initializes a new instance of the class. + public OpenAIRequestPolicies() + { + } + + /// + /// Adds a to be applied to every + /// produced for outbound OpenAI requests by the owning Microsoft.Extensions.AI client. + /// + /// The pipeline policy to register. Must not be . + /// + /// The position in the pipeline at which to place the policy. Defaults to + /// , which runs the policy once per logical request + /// (for example, to stamp a User-Agent or correlation header). + /// + /// is . + public void AddPolicy(PipelinePolicy policy, PipelinePosition position = PipelinePosition.PerCall) + { + _ = Throw.IfNull(policy); + + var newEntry = new Entry(policy, position); + + // Lock-free append: copy-on-write with CAS retry. + while (true) + { + var current = Volatile.Read(ref _entries); + var updated = new Entry[current.Length + 1]; + Array.Copy(current, updated, current.Length); + updated[current.Length] = newEntry; + + if (Interlocked.CompareExchange(ref _entries, updated, current) == current) + { + return; + } + } + } + + /// + /// Applies all registered policies to the supplied . + /// Called by the Microsoft.Extensions.AI OpenAI clients after their own internal policies + /// have been registered. + /// + internal void ApplyTo(RequestOptions requestOptions) + { + var snapshot = Volatile.Read(ref _entries); + for (int i = 0; i < snapshot.Length; i++) + { + var entry = snapshot[i]; + requestOptions.AddPolicy(entry.Policy, entry.Position); + } + } + + private readonly struct Entry + { + public Entry(PipelinePolicy policy, PipelinePosition position) + { + Policy = policy; + Position = position; + } + + public PipelinePolicy Policy { get; } + public PipelinePosition Position { get; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 63d22d72ced..6bfdf07a1bd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -24,6 +24,7 @@ #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable S3254 // Default parameter values should not be passed as arguments #pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable MEAI001 // OpenAIRequestPolicies is experimental namespace Microsoft.Extensions.AI; @@ -59,6 +60,9 @@ private static readonly FuncThe default model ID to use for the chat client. private readonly string? _defaultModelId; + /// Caller-registered policies applied to every . + private readonly OpenAIRequestPolicies _requestPolicies = new(); + /// Initializes a new instance of the class for the specified . /// The underlying client. /// The default model ID to use for the chat client. @@ -82,6 +86,7 @@ public OpenAIResponsesChatClient(ResponsesClient responseClient, string? default serviceKey is not null ? null : serviceType == typeof(ChatClientMetadata) ? _metadata : serviceType == typeof(ResponsesClient) ? _responseClient : + serviceType == typeof(OpenAIRequestPolicies) ? _requestPolicies : serviceType.IsInstanceOfType(this) ? this : null; } @@ -100,7 +105,7 @@ public async Task GetResponseAsync( // Provided continuation token signals that an existing background response should be fetched. if (GetContinuationToken(messages, options) is { } token) { - var getTask = _responseClient.GetResponseAsync(token.ResponseId, include: null, stream: null, startingAfter: null, includeObfuscation: null, cancellationToken.ToRequestOptions(streaming: false)); + var getTask = _responseClient.GetResponseAsync(token.ResponseId, include: null, stream: null, startingAfter: null, includeObfuscation: null, cancellationToken.ToRequestOptions(streaming: false, _requestPolicies)); var response = (ResponseResult)await getTask.ConfigureAwait(false); return FromOpenAIResponse(response, openAIOptions, openAIConversationId); } @@ -111,7 +116,7 @@ public async Task GetResponseAsync( } // Make the call to the ResponsesClient. - var createTask = _responseClient.CreateResponseAsync((BinaryContent)openAIOptions, cancellationToken.ToRequestOptions(streaming: false)); + var createTask = _responseClient.CreateResponseAsync((BinaryContent)openAIOptions, cancellationToken.ToRequestOptions(streaming: false, _requestPolicies)); var openAIResponsesResult = (ResponseResult)await createTask.ConfigureAwait(false); // Convert the response to a ChatResponse. @@ -330,7 +335,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( Debug.Assert(_getResponseStreamingAsync is not null, $"Unable to find {nameof(_getResponseStreamingAsync)} method"); IAsyncEnumerable getUpdates = _getResponseStreamingAsync is not null ? - _getResponseStreamingAsync(_responseClient, getOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _getResponseStreamingAsync(_responseClient, getOptions, cancellationToken.ToRequestOptions(streaming: true, _requestPolicies)) : _responseClient.GetResponseStreamingAsync(getOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(getUpdates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken); @@ -343,7 +348,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( Debug.Assert(_createResponseStreamingAsync is not null, $"Unable to find {nameof(_createResponseStreamingAsync)} method"); AsyncCollectionResult createUpdates = _createResponseStreamingAsync is not null ? - _createResponseStreamingAsync(_responseClient, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _createResponseStreamingAsync(_responseClient, openAIOptions, cancellationToken.ToRequestOptions(streaming: true, _requestPolicies)) : _responseClient.CreateResponseStreamingAsync(openAIOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(createUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs index dbe38e42a2c..ee14745d3ab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; #pragma warning disable CA1307 // Specify StringComparison +#pragma warning disable MEAI001 // OpenAIRequestPolicies is experimental namespace Microsoft.Extensions.AI; @@ -15,7 +16,15 @@ namespace Microsoft.Extensions.AI; internal static class RequestOptionsExtensions { /// Creates a configured for use with OpenAI. - public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) => + ToRequestOptions(cancellationToken, streaming, policies: null); + + /// + /// Creates a configured for use with OpenAI, applying any + /// caller-registered after Microsoft.Extensions.AI's own + /// internal policies. + /// + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming, OpenAIRequestPolicies? policies) { RequestOptions requestOptions = new() { @@ -25,6 +34,8 @@ public static RequestOptions ToRequestOptions(this CancellationToken cancellatio requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + policies?.ApplyTo(requestOptions); + return requestOptions; } diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index ab5f3eb1ccc..e854d86d983 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -61,6 +61,7 @@ internal static class Experiments internal const string AIToolSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; + internal const string AIOpenAIRequestPolicies = AIExperiments; // These diagnostic IDs are defined by the OpenAI package for its experimental APIs. // We use the same IDs so consumers do not need to suppress additional diagnostics diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRequestPoliciesTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRequestPoliciesTests.cs new file mode 100644 index 00000000000..d46b69b401e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRequestPoliciesTests.cs @@ -0,0 +1,229 @@ +// Licensed to the .NET Foundation under one or more agreements. +// 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.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using OpenAI; +using Xunit; + +#pragma warning disable OPENAI001 // Experimental OpenAI APIs +#pragma warning disable MEAI001 // OpenAIRequestPolicies is experimental + +namespace Microsoft.Extensions.AI; + +public class OpenAIRequestPoliciesTests +{ + [Fact] + public void AddPolicy_NullPolicy_Throws() + { + var policies = new OpenAIRequestPolicies(); + Assert.Throws("policy", () => policies.AddPolicy(null!)); + } + + [Fact] + public void GetService_OpenAIChatClient_ReturnsStableInstance() + { + IChatClient client = NewChatClient(); + + var first = client.GetService(); + var second = client.GetService(); + + Assert.NotNull(first); + Assert.Same(first, second); + } + + [Fact] + public void GetService_OpenAIResponseClient_ReturnsInstance() + { + IChatClient client = new OpenAIClient(new ApiKeyCredential("k")).GetResponsesClient().AsIChatClient("m"); + Assert.NotNull(client.GetService()); + } + + [Fact] + public void GetService_OpenAIEmbeddingGenerator_ReturnsInstance() + { + IEmbeddingGenerator> generator = + new OpenAIClient(new ApiKeyCredential("k")).GetEmbeddingClient("m").AsIEmbeddingGenerator(); + + Assert.NotNull(generator.GetService()); + } + + [Fact] + public void GetService_PerClientIsolation() + { + var openAi = new OpenAIClient(new ApiKeyCredential("k")); + + var policiesA = openAi.GetChatClient("m").AsIChatClient().GetService(); + var policiesB = openAi.GetChatClient("m").AsIChatClient().GetService(); + + Assert.NotSame(policiesA, policiesB); + } + + [Fact] + public void GetService_ReachableThroughDecoratorChain() + { + IChatClient inner = NewChatClient(); + var innerPolicies = inner.GetService(); + + using IChatClient pipeline = inner + .AsBuilder() + .UseFunctionInvocation() + .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) + .Build(); + + Assert.Same(innerPolicies, pipeline.GetService()); + } + + [Fact] + public async Task AddPolicy_CustomUserAgent_ReplacesMeaiHeader() + { + using var handler = new CapturingUserAgentHandler(); + using var http = new HttpClient(handler); + IChatClient client = NewChatClient(http); + + client.GetService()!.AddPolicy(new SetUserAgentPolicy("my-sdk/1.0")); + + await Assert.ThrowsAnyAsync(() => client.GetResponseAsync("hi")); + + // Customer policy ran after MEAI's UA policy and replaced the value. + Assert.NotNull(handler.CapturedUserAgent); + Assert.Equal("my-sdk/1.0", handler.CapturedUserAgent); + } + + [Fact] + public async Task AddPolicy_CustomHeaderAdd_StacksWithMeaiUserAgent() + { + using var handler = new CapturingUserAgentHandler(); + using var http = new HttpClient(handler); + IChatClient client = NewChatClient(http); + + client.GetService()!.AddPolicy(new AddUserAgentPolicy("extra-sdk/9.9")); + + await Assert.ThrowsAnyAsync(() => client.GetResponseAsync("hi")); + + Assert.NotNull(handler.CapturedUserAgent); + Assert.Contains("MEAI", handler.CapturedUserAgent); + Assert.Contains("extra-sdk/9.9", handler.CapturedUserAgent); + } + + [Fact] + public async Task NoPolicyRegistered_MeaiUserAgentStillEmitted() + { + using var handler = new CapturingUserAgentHandler(); + using var http = new HttpClient(handler); + IChatClient client = NewChatClient(http); + + Assert.NotNull(client.GetService()); // touch but don't register + + await Assert.ThrowsAnyAsync(() => client.GetResponseAsync("hi")); + + Assert.NotNull(handler.CapturedUserAgent); + Assert.Contains("MEAI", handler.CapturedUserAgent); + } + + [Fact] + public async Task AddPolicy_Concurrent_AllPoliciesRetained() + { + var policies = new OpenAIRequestPolicies(); + + const int Count = 200; + await Task.WhenAll(Enumerable.Range(0, Count).Select(i => + Task.Run(() => policies.AddPolicy(new NoopPolicy())))); + + // Verify nothing was lost across CAS races. + var entries = (Array)typeof(OpenAIRequestPolicies) + .GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(policies)!; + Assert.Equal(Count, entries.Length); + } + + private static IChatClient NewChatClient(HttpClient? http = null) + { + OpenAIClientOptions options = http is null + ? new OpenAIClientOptions() + : new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) }; + + return new OpenAIClient(new ApiKeyCredential("k"), options) + .GetChatClient("gpt-4o-mini") + .AsIChatClient(); + } + + private sealed class CapturingUserAgentHandler : HttpMessageHandler + { + public string? CapturedUserAgent { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + { + // Capture the User-Agent values exactly as they appear on the outgoing request. + CapturedUserAgent = request.Headers.UserAgent.ToString(); + + // Short-circuit; the test only cares about what was sent. + throw new InvalidOperationException("captured"); + } + } + + private sealed class NoopPolicy : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + private sealed class SetUserAgentPolicy : PipelinePolicy + { + private readonly string _value; + + public SetUserAgentPolicy(string value) + { + _value = value; + } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", _value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Set("User-Agent", _value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } + + private sealed class AddUserAgentPolicy : PipelinePolicy + { + private readonly string _value; + + public AddUserAgentPolicy(string value) + { + _value = value; + } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add("User-Agent", _value); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add("User-Agent", _value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } +}