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);
+ }
+ }
+}