Add OpenAIRequestPolicies extension hook for MEAI OpenAI clients#7495
Conversation
Introduces a new experimental sealed type OpenAIRequestPolicies retrievable via IChatClient.GetService<T>() / IEmbeddingGenerator.GetService<T>() on the three Microsoft.Extensions.AI OpenAI clients that go through the ToRequestOptions chokepoint (OpenAIChatClient, OpenAIEmbeddingGenerator, OpenAIResponsesChatClient). The type exposes a single AddPolicy(PipelinePolicy, PipelinePosition = PerCall) method so downstream SDKs that receive a customer-built IChatClient (and therefore cannot reconfigure the underlying OpenAIClient pipeline) can append their own pipeline policies, for example to stamp or replace the User-Agent header. Customer policies run after MEAI's internal user-agent policy, so Headers.Set replaces and Headers.Add stacks.
There was a problem hiding this comment.
Pull request overview
Adds an experimental extensibility hook (OpenAIRequestPolicies) to the MEAI OpenAI adapters so downstream components can append PipelinePolicy instances to every RequestOptions built at the ToRequestOptions chokepoint (e.g., for User-Agent branding / correlation headers) without mutating the underlying OpenAIClient pipeline.
Changes:
- Introduces experimental
OpenAIRequestPolicieswith an append-onlyAddPolicyAPI and internalApplyTo(RequestOptions)integration. - Wires per-client
OpenAIRequestPoliciesinstances intoOpenAIChatClient,OpenAIEmbeddingGenerator, andOpenAIResponsesChatClientand applies them after MEAI’s internal User-Agent policy. - Adds unit tests validating service reachability/stability, per-client isolation, ordering semantics, and basic concurrent registration behavior; updates API baseline.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIRequestPoliciesTests.cs | Adds coverage for policy registration, GetService exposure, ordering semantics, and concurrent add smoke test. |
| src/Shared/DiagnosticIds/DiagnosticIds.cs | Adds a named experiment constant for the new experimental surface under the shared AI experiments diagnostic ID. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs | Extends ToRequestOptions to optionally apply caller-registered OpenAIRequestPolicies after MEAI’s internal policies. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs | Exposes a per-client OpenAIRequestPolicies via GetService and applies it to request options. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs | Exposes a per-generator OpenAIRequestPolicies via GetService and applies it to request options. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs | Exposes a per-client OpenAIRequestPolicies via GetService and applies it to request options for create/get + streaming reflection paths. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRequestPolicies.cs | New experimental type implementing copy-on-write policy registration and application to RequestOptions. |
| src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.json | Updates API baseline (version + new OpenAIRequestPolicies type). |
jozkee
left a comment
There was a problem hiding this comment.
Makes sense for cases where you don't control OpenAIClient creation.
Will it be possible to use |
…tion message Replaces ThrowUserAgentExceptionHandler with a CapturingUserAgentHandler that records request.Headers.UserAgent.ToString() and asserts on the captured value, addressing jozkee's feedback that Message-based assertions were too loose. Also surfaced that the runtime User-Agent is 'OpenAI/x.y.z (...) MEAI/x.y.z' (OpenAI prepends, MEAI appends), so the no-policy test now uses Contains rather than StartsWith.
@jozkee, Yes, no additional types should be needed. I checked the openai-dotnet public surface and every one of the currently out-of-scope clients exposes public protocol methods that accept a RequestOptions:
|
* Add OpenAIRequestPolicies extension hook for MEAI OpenAI clients Introduces a new experimental sealed type OpenAIRequestPolicies retrievable via IChatClient.GetService<T>() / IEmbeddingGenerator.GetService<T>() on the three Microsoft.Extensions.AI OpenAI clients that go through the ToRequestOptions chokepoint (OpenAIChatClient, OpenAIEmbeddingGenerator, OpenAIResponsesChatClient). The type exposes a single AddPolicy(PipelinePolicy, PipelinePosition = PerCall) method so downstream SDKs that receive a customer-built IChatClient (and therefore cannot reconfigure the underlying OpenAIClient pipeline) can append their own pipeline policies, for example to stamp or replace the User-Agent header. Customer policies run after MEAI's internal user-agent policy, so Headers.Set replaces and Headers.Add stacks. * Address review: capture request headers instead of asserting on exception message Replaces ThrowUserAgentExceptionHandler with a CapturingUserAgentHandler that records request.Headers.UserAgent.ToString() and asserts on the captured value, addressing jozkee's feedback that Message-based assertions were too loose. Also surfaced that the runtime User-Agent is 'OpenAI/x.y.z (...) MEAI/x.y.z' (OpenAI prepends, MEAI appends), so the no-policy test now uses Contains rather than StartsWith.
Summary
Adds a new experimental
OpenAIRequestPoliciestype that downstream SDKs can use to appendSystem.ClientModel.PipelinePolicyinstances to theRequestOptionsbuilt byMicrosoft.Extensions.AIfor every outbound OpenAI request. The motivating scenario: an SDK author is handed anIChatClientconstructed by a customer from anOpenAIClientthey do not own, and needs to brand the outgoingUser-Agentheader (or attach correlation metadata) without mutating the customer's pipeline.Surface
The type is reachable via
GetService<T>on the three MEAI OpenAI clients that already funnel through theToRequestOptionschokepoint (OpenAIChatClient,OpenAIEmbeddingGenerator,OpenAIResponsesChatClient):Customer policies run after MEAI's internal User-Agent policy, so
message.Request.Headers.Set(""User-Agent"", ...)replaces the value andHeaders.Add(...)stacks an additional value, with predictable last-writer-wins semantics. The instance is per client (no shared state, no weak tables) and reachable through anyChatClientBuilderdecorator chain via the standardGetServicetraversal.Design choices
AddPolicymethod.RemoveandClearare intentionally deferred until a real consumer asks; the experimental attribute keeps that door open.RequestOptions, soCancellationToken,BufferResponse, and MEAI's own User-Agent policy stay safe from accidental tampering.T[]swapped viaInterlocked.CompareExchange. NoSystem.Collections.Immutabledependency (would not flow through netstandard2.0 / net462).MEAI001(AIExperiments) alias rather thanOPENAI001.OPENAI001is reserved for surfaces that wrap the OpenAI SDK's experimental APIs; this hook is generic MEAI extensibility.ToRequestOptionschokepoint. The other OpenAI MEAI clients (Realtime, S2T, T2S, Image, Assistants, HostedFile) bypassToRequestOptionstoday, do not currently receive MEAI's User-Agent either, and would each require their own refactor (or a websocket-specific story for Realtime). Out of scope here; revisit if asked.Why not configure the underlying
OpenAIClient?The OpenAI .NET SDK already supports
OpenAIClientOptions.AddPolicyat construction time. That is the right path when you control construction. This PR addresses the case where you do not, the SDK author receives only anIChatClientand must add behavior without reaching into the customer's pipeline configuration.Tests
New
OpenAIRequestPoliciesTests(10 tests, all passing on net462 / net8 / net9 / net10):AddPolicy(null)throwsArgumentNullException.GetService<OpenAIRequestPolicies>()returns a stable per-client instance on each of the three wired client types.OpenAIClientget distinctOpenAIRequestPoliciesinstances.ChatClientBuilderdecorator chain.Headers.Set(""User-Agent"", ...)replaces MEAI's value (proves ordering: customer-after-MEAI).Headers.Add(""User-Agent"", ...)stacks alongside MEAI's value.AddPolicyinvocations retain all entries (CAS-loop concurrency smoke).Existing 356
Microsoft.Extensions.AI.OpenAI.Testscontinue to pass.API baseline
Microsoft.Extensions.AI.OpenAI.jsonregenerated via./scripts/MakeApiBaselines.ps1. The version line moves from10.5.0.0to10.6.0.0because the previous baseline predates a repo-wideMinorVersionbump ineng/Versions.props; the siblingAbstractions.jsonbaseline was already at10.6.0.0.Microsoft Reviewers: Open in CodeFlow