.NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)

* Bump MEAI to 10.5.1 and add per-call x-client header support

Replaces the brittle UserAgentResponsesClient subclass with a clean
per-call x-client-* header pipeline built on the new Microsoft.Extensions.AI
10.5.1 OpenAIRequestPolicies hook.

Public surface (Microsoft.Agents.AI.Foundry, [Experimental(MAAI001)]):
* chatOptions.WithClientHeader(name, value) and .WithClientHeaders(IEnumerable)
  validate the x-client- prefix (case-insensitive), apply all-or-nothing on
  bulk, and throw InvalidOperationException on foreign-typed slot collision
* myAgent.AsBuilder().UseClientHeaders().Build() opts a customer-built agent
  into the pipeline; idempotent via agent.GetService<ClientHeadersAgent>()
* Foundry-built agents (FoundryAgent.Create*) pre-wire automatically

Internals:
* ClientHeadersAgent decorator snapshots the dict at scope-push time so
  concurrent runs sharing a ChatOptions reference do not leak headers
* ClientHeadersScope is an AsyncLocal<IReadOnlyDictionary<string,string>?>
  with LIFO push/dispose semantics
* ClientHeadersPolicy singleton stamps headers via Headers.Set so per-call
  values overwrite any same-name header from earlier policies and so
  duplicate registration is value-stable
* OpenAIRequestPoliciesReflection dedups against MEAI's private _entries
  field and falls back to AddPolicy on any reflection failure; a CI test
  asserts the field shape on every MEAI bump

Hosting cleanup:
* Deleted UserAgentResponsesClient and its dummy throwing pipeline
* HostedAgentUserAgentPolicy is now registered via OpenAIRequestPolicies
  in FoundryHostingExtensions.TryApplyUserAgent

Tests:
* 19 new unit tests in ClientHeadersExtensionsTests.cs covering validation,
  AsyncLocal isolation, snapshot semantics, end-to-end wire stamping, and
  shared-chat-client dedup
* Updated OpenTelemetryAgentTests for MEAI 10.5.1 changes to web_search
  serialization and the reduced tool definition payload when sensitive
  data capture is disabled

Microsoft.Extensions.Compliance.Abstractions stays at 10.5.0 because no
10.5.1 release exists on nuget.org.

* Address PR review: pre-wire AsAIAgent path and dedup TryApplyUserAgent

* FoundryAgent: extract WireClientHeaders helper and call it from the
  internal (AIProjectClient, ChatClientAgent) constructor used by
  AzureAIProjectChatClientExtensions.AsAIAgent so those Foundry-built
  agents also pre-wire the x-client header pipeline.
* Foundry.Hosting TryApplyUserAgent: dedup HostedAgentUserAgentPolicy
  registration per OpenAIRequestPolicies instance via
  ConditionalWeakTable so per-request resolution does not grow the
  policy list unboundedly on singleton agents.

* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup

Backs the PR review fixes from a4c8f91 with regression tests:
* ClientHeadersExtensionsTests: AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent
  asserts the FoundryAgent built via AzureAIProjectChatClientExtensions.AsAIAgent
  contains a ClientHeadersAgent in its delegating chain (catches future
  regressions of the bypass).
* ClientHeadersExtensionsTests: FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent
  covers the public constructor path the same way.
* ClientHeadersExtensionsTests: UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  invokes UseClientHeaders 25 times on a shared chat client and asserts via
  reflection that OpenAIRequestPolicies._entries length is exactly 1.
* HostedTryApplyUserAgentDedupTests: two tests asserting
  FoundryHostingExtensions.TryApplyUserAgent stays at one entry per
  OpenAIRequestPolicies instance after 50 calls on the same agent and across
  distinct agents on different chat clients.

* Move tests next to their SUT

Removes the dedicated HostedTryApplyUserAgentDedupTests.cs test class.
Tests are co-located with the SUT they exercise:

* FoundryAgentTests.cs gains the Constructor_PreWiresClientHeadersAgent
  and Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent
  cases, since FoundryAgent is the SUT for the pre-wire behavior.
* HostedOutboundUserAgentTests.cs gains the two TryApplyUserAgent dedup
  cases, since FoundryHostingExtensions.TryApplyUserAgent is the SUT
  it already covers.
* ClientHeadersExtensionsTests.cs keeps only the
  UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
  case, which exercises the public ClientHeadersExtensions surface.

* Remove redundant WithCancellation on inner streaming call

ct is already passed to InnerAgent.RunStreamingAsync, so
.WithCancellation(ct) on the resulting IAsyncEnumerable is a no-op.
Caught by Sergey on PR review.

* Address PR review: surface downstream MEAI experimental ID

* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
  DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
  AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
  instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
  surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
  suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
  pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
  does NOT auto-restore on method return; explicit using/Dispose is what
  gives stack-style LIFO semantics.
This commit is contained in:
Roger Barreto
2026-05-06 15:43:08 +01:00
committed by GitHub
Unverified
parent be8d2619e4
commit b12109b7e4
16 changed files with 1433 additions and 671 deletions
@@ -4,6 +4,7 @@ using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -18,10 +19,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// is already present in the <c>User-Agent</c> header, the policy does not append it again.
/// </para>
/// <para>
/// This policy is added at request time (per-call <see cref="PipelinePosition"/>)
/// by <see cref="UserAgentResponsesClient"/> when invoking the wrapped
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
/// resolved by the Foundry hosting layer.
/// This policy is added at hosted-agent resolution time via the MEAI 10.5.1
/// <see cref="OpenAIRequestPolicies"/> hook on the agent's underlying chat client. It is only
/// registered when an agent is resolved by the Foundry hosting layer.
/// </para>
/// </remarks>
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using Azure.AI.AgentServer.Responses;
using Azure.Core;
using Azure.Identity;
@@ -11,7 +12,6 @@ using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.DiagnosticIds;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -207,84 +207,45 @@ public static class FoundryHostingExtensions
}
/// <summary>
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
/// with a <see cref="UserAgentResponsesClient"/> so every outgoing Responses-API request
/// carries the hosted-agent <c>User-Agent</c> segment.
/// Registers the hosted-agent <c>User-Agent</c> supplement policy
/// (<see cref="HostedAgentUserAgentPolicy"/>) on the agent's underlying chat client via the
/// MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> hook so every outgoing OpenAI Responses
/// request carries the segment <c>foundry-hosting/agent-framework-dotnet/{version}</c>.
/// </summary>
/// <remarks>
/// <para>
/// Best-effort and idempotent. The method is a no-op when:
/// <list type="bullet">
/// <item><description><paramref name="agent"/> exposes no <see cref="IChatClient"/>;</description></item>
/// <item><description>the chat client is not backed by MEAI's internal <c>OpenAIResponsesChatClient</c> (e.g., a non-OpenAI provider or a custom impl);</description></item>
/// <item><description>the inner <see cref="ResponsesClient"/> is already a <see cref="UserAgentResponsesClient"/>.</description></item>
/// <item><description>the chat client is not OpenAI-backed (the <see cref="OpenAIRequestPolicies"/> service lookup returns <see langword="null"/>);</description></item>
/// <item><description>the policy was already registered on this client by a prior invocation (deduped via reflection on <c>OpenAIRequestPolicies._entries</c>).</description></item>
/// </list>
/// </para>
/// <para>
/// Works for any <see cref="ResponsesClient"/>-derived inner client — both the Foundry-specific
/// <see cref="Azure.AI.Extensions.OpenAI.ProjectResponsesClient"/> and the native OpenAI
/// <see cref="ResponsesClient"/> obtained from <see cref="OpenAI.OpenAIClient"/>. The wrapper preserves
/// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
/// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
/// </para>
/// <para>
/// Returns the same <paramref name="agent"/> instance unchanged. Mutation happens via
/// reflection on MEAI's private <c>_responseClient</c> field; the agent itself is not wrapped.
/// Returns the same <paramref name="agent"/> instance unchanged. The policy is installed
/// on the chat client; the agent itself is not wrapped.
/// </para>
/// </remarks>
internal static AIAgent TryApplyUserAgent(AIAgent agent)
{
var chatClient = agent.GetService<IChatClient>();
if (chatClient is null)
if (chatClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
return agent;
// Hosted agents are typically singletons resolved per request, so AddPolicy must be
// called at most once per OpenAIRequestPolicies instance to avoid unbounded growth of
// the policy list (each entry adds per-request CPU work even though the User-Agent
// value stays stable). Track which instances we have already wired with a
// ConditionalWeakTable keyed on the OpenAIRequestPolicies reference; the table holds
// weak references so it does not extend the lifetime of the chat client.
if (s_userAgentRegistrations.TryAdd(policies, s_boxedTrue))
{
policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
}
}
var meaiType = s_meaiResponsesChatClientType;
if (meaiType is null)
{
return agent;
}
var meaiInstance = chatClient.GetService(meaiType);
if (meaiInstance is null)
{
return agent;
}
var field = s_meaiResponseClientField;
if (field is null)
{
return agent;
}
var current = field.GetValue(meaiInstance) as ResponsesClient;
if (current is null or UserAgentResponsesClient)
{
return agent;
}
field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
return agent;
}
/// <summary>
/// MEAI's internal <c>OpenAIResponsesChatClient</c> type, resolved once via reflection.
/// <see langword="null"/> if the type cannot be found (e.g., MEAI version drift).
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode",
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
[UnconditionalSuppressMessage("Trimming", "IL2073:RequiresUnreferencedCode",
Justification = "MEAI's OpenAIResponsesChatClient is referenced through MicrosoftExtensionsAIResponsesExtensions and survives trimming.")]
private static readonly Type? s_meaiResponsesChatClientType =
typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
/// <summary>
/// MEAI's internal <c>_responseClient</c> field on <c>OpenAIResponsesChatClient</c>,
/// resolved once via reflection. <see langword="null"/> if the field cannot be found.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2080:RequiresDynamicallyAccessedMembers",
Justification = "OpenAIResponsesChatClient and its private fields are preserved by the polyfill design; MEAI does the same reflection internally.")]
private static readonly FieldInfo? s_meaiResponseClientField =
s_meaiResponsesChatClientType?.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly object s_boxedTrue = new();
private static readonly ConditionalWeakTable<OpenAIRequestPolicies, object> s_userAgentRegistrations = new();
}
@@ -1,113 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenAI;
using OpenAI.Responses;
#pragma warning disable OPENAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// A <see cref="ResponsesClient"/> subclass that delegates every protocol-level request to a
/// wrapped <see cref="ResponsesClient"/>. Before each call, a
/// <see cref="HostedAgentUserAgentPolicy"/> is added to the per-call
/// <see cref="RequestOptions"/> so the wrapped client's pipeline appends the hosted-agent
/// <c>User-Agent</c> segment on the wire.
/// </summary>
/// <remarks>
/// <para>
/// The streaming overloads MEAI binds via reflection (<c>internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)</c>
/// and <c>internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)</c>) bottom out
/// in calls to the public-virtual non-streaming protocol overloads on <see langword="this"/>. Overriding those
/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
/// </para>
/// <para>
/// The base pipeline supplied to <see cref="ResponsesClient(ClientPipeline, OpenAIClientOptions)"/>
/// is a dummy pipeline whose terminal transport throws if invoked. Every override on this class
/// delegates to the inner client BEFORE any code path reaches <see cref="ResponsesClient.Pipeline"/>, so the dummy is
/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
/// </para>
/// </remarks>
internal sealed class UserAgentResponsesClient : ResponsesClient
{
private readonly ResponsesClient _inner;
public UserAgentResponsesClient(ResponsesClient inner)
: base(BuildDummyPipeline(), new OpenAIClientOptions { Endpoint = inner?.Endpoint })
{
this._inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public override async Task<ClientResult> CreateResponseAsync(BinaryContent content, RequestOptions? options = null)
=> await this._inner.CreateResponseAsync(content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CreateResponse(BinaryContent content, RequestOptions? options = null)
=> this._inner.CreateResponse(content, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetResponseAsync(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
=> await this._inner.GetResponseAsync(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetResponse(string responseId, IEnumerable<IncludedResponseProperty>? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
=> this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
public override async Task<ClientResult> DeleteResponseAsync(string responseId, RequestOptions options)
=> await this._inner.DeleteResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult DeleteResponse(string responseId, RequestOptions options)
=> this._inner.DeleteResponse(responseId, AddUserAgentPolicy(options));
public override async Task<ClientResult> CancelResponseAsync(string responseId, RequestOptions options)
=> await this._inner.CancelResponseAsync(responseId, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CancelResponse(string responseId, RequestOptions options)
=> this._inner.CancelResponse(responseId, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetInputTokenCountAsync(string contentType, BinaryContent content, RequestOptions? options = null)
=> await this._inner.GetInputTokenCountAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetInputTokenCount(string contentType, BinaryContent content, RequestOptions? options = null)
=> this._inner.GetInputTokenCount(contentType, content, AddUserAgentPolicy(options));
public override async Task<ClientResult> CompactResponseAsync(string contentType, BinaryContent content, RequestOptions? options = null)
=> await this._inner.CompactResponseAsync(contentType, content, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult CompactResponse(string contentType, BinaryContent content, RequestOptions? options = null)
=> this._inner.CompactResponse(contentType, content, AddUserAgentPolicy(options));
public override async Task<ClientResult> GetResponseInputItemCollectionPageAsync(string responseId, int? limit, string order, string after, string before, RequestOptions options)
=> await this._inner.GetResponseInputItemCollectionPageAsync(responseId, limit, order, after, before, AddUserAgentPolicy(options)).ConfigureAwait(false);
public override ClientResult GetResponseInputItemCollectionPage(string responseId, int? limit, string order, string after, string before, RequestOptions options)
=> this._inner.GetResponseInputItemCollectionPage(responseId, limit, order, after, before, AddUserAgentPolicy(options));
private static RequestOptions AddUserAgentPolicy(RequestOptions? options)
{
options ??= new RequestOptions();
options.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall);
return options;
}
private static ClientPipeline BuildDummyPipeline()
{
var options = new ClientPipelineOptions
{
Transport = new ThrowingTransport(),
};
return ClientPipeline.Create(options, default, default, default);
}
private sealed class ThrowingTransport : PipelineTransport
{
private const string Message =
"UserAgentResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of UserAgentResponsesClient.";
protected override PipelineMessage CreateMessageCore() => throw new InvalidOperationException(Message);
protected override void ProcessCore(PipelineMessage message) => throw new InvalidOperationException(Message);
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new InvalidOperationException(Message);
}
}
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Delegating <see cref="AIAgent"/> that captures any <c>x-client-*</c> headers stored on
/// <see cref="ChatClientAgentRunOptions.ChatOptions"/> by callers of
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> and pushes
/// them onto a <see cref="ClientHeadersScope"/> for the lifetime of the run. The scope is read by
/// <see cref="ClientHeadersPolicy"/> inside the SCM transport pipeline and stamped onto the
/// outbound request.
/// </summary>
/// <remarks>
/// <para>
/// The decorator snapshots the header dictionary at scope-push time so concurrent runs that share
/// the same <see cref="ChatOptions"/> reference are isolated; mutating the source dictionary after
/// <c>RunAsync</c> begins does not leak into in-flight requests.
/// </para>
/// <para>
/// Streaming uses the async-iterator pattern so the AsyncLocal scope stays alive across yields,
/// which is required because the underlying HTTP send happens during enumeration.
/// </para>
/// </remarks>
internal sealed class ClientHeadersAgent : DelegatingAIAgent
{
public ClientHeadersAgent(AIAgent innerAgent)
: base(innerAgent)
{
}
/// <inheritdoc/>
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
if (snapshot is null)
{
return this.InnerAgent.RunAsync(messages, session, options, cancellationToken);
}
return RunAsyncCoreAsync(messages, session, options, snapshot, cancellationToken);
async Task<AgentResponse> RunAsyncCoreAsync(
IEnumerable<ChatMessage> innerMessages,
AgentSession? innerSession,
AgentRunOptions? innerOptions,
Dictionary<string, string> innerSnapshot,
CancellationToken innerCt)
{
using var _ = ClientHeadersScope.Push(innerSnapshot);
return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
}
}
/// <inheritdoc/>
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var snapshot = TrySnapshot(options);
using var _ = snapshot is null ? default : ClientHeadersScope.Push(snapshot);
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <summary>Reads the header dictionary stamped by <c>WithClientHeader(s)</c> and returns an immutable snapshot, or <see langword="null"/> if none.</summary>
private static Dictionary<string, string>? TrySnapshot(AgentRunOptions? options)
{
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
{
return null;
}
var headers = chatOptions.GetClientHeaders();
if (headers is null || headers.Count == 0)
{
return null;
}
// Copy to defeat caller mutation after RunAsync starts.
var copy = new Dictionary<string, string>(headers.Count, System.StringComparer.OrdinalIgnoreCase);
foreach (var kvp in headers)
{
copy[kvp.Key] = kvp.Value;
}
return copy;
}
}
@@ -0,0 +1,204 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Provides extension methods for attaching per-call <c>x-client-*</c> headers to an agent run
/// and for opting an existing <see cref="AIAgent"/> into the client-headers pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The Foundry platform forwards headers prefixed with <c>x-client-</c> transparently from the
/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> or
/// <see cref="WithClientHeaders(ChatOptions, IEnumerable{KeyValuePair{string, string}})"/> to
/// stamp headers per <c>RunAsync</c> call (for example to attest the SaaS end-user identity
/// in <c>x-client-end-user-id</c>).
/// </para>
/// <para>
/// Headers are only delivered to the wire when:
/// <list type="number">
/// <item><description>the agent has been wrapped with <see cref="UseClientHeaders(AIAgentBuilder)"/> (or built via a Foundry factory that pre-wires it), and</description></item>
/// <item><description>the underlying <see cref="IChatClient"/> exposes the experimental MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> service (true for OpenAI-backed clients).</description></item>
/// </list>
/// When either condition is not met the call is a silent no-op.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class ClientHeadersExtensions
{
/// <summary>The well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry the dictionary across packages.</summary>
internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
/// <summary>The required prefix on every client header name (case-insensitive).</summary>
private const string ClientHeaderPrefix = "x-client-";
/// <summary>
/// Adds a single <c>x-client-*</c> header to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="name">The header name. Must start with <c>x-client-</c> (case-insensitive).</param>
/// <param name="value">The header value. Must be non-empty.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/>, <paramref name="name"/>, or <paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> does not start with <c>x-client-</c>, or is empty/whitespace, or <paramref name="value"/> is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeader(this ChatOptions options, string name, string value)
{
_ = Throw.IfNull(options);
ValidateHeader(name, value);
var dict = GetOrCreateHeadersDictionary(options);
dict[name] = value;
return options;
}
/// <summary>
/// Adds multiple <c>x-client-*</c> headers to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <remarks>Validation is all-or-nothing: if any entry is invalid no entries are written.</remarks>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="headers">The headers to add. Each name must start with <c>x-client-</c>.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="headers"/> is <see langword="null"/>, or any element of <paramref name="headers"/> has a <see langword="null"/> name or value.</exception>
/// <exception cref="ArgumentException">Any header name does not start with <c>x-client-</c>, or any name is empty/whitespace, or any value is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable<KeyValuePair<string, string>> headers)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(headers);
// Validate first; mutate only when every entry passes.
var staged = new List<KeyValuePair<string, string>>();
foreach (var kvp in headers)
{
ValidateHeader(kvp.Key, kvp.Value);
staged.Add(kvp);
}
if (staged.Count == 0)
{
return options;
}
var dict = GetOrCreateHeadersDictionary(options);
foreach (var kvp in staged)
{
dict[kvp.Key] = kvp.Value;
}
return options;
}
/// <summary>
/// Wraps the agent built by <paramref name="builder"/> so that headers stamped by
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> on the per-call
/// <see cref="ChatOptions"/> are forwarded onto the outbound HTTP request.
/// </summary>
/// <remarks>
/// <para>
/// Idempotent: if the inner agent is already wrapped with a <see cref="ClientHeadersAgent"/>
/// anywhere in its delegating chain, the agent is returned unchanged. This makes
/// <c>myFoundryAgent.AsBuilder().UseClientHeaders().Build()</c> safe even though Foundry
/// agents are pre-wired automatically.
/// </para>
/// <para>
/// Also registers <see cref="ClientHeadersPolicy"/> against the underlying chat client's
/// <see cref="OpenAIRequestPolicies"/> service if available. When the underlying chat client
/// is not OpenAI-backed (the service lookup returns <see langword="null"/>), the registration
/// step is silently skipped; the agent decorator still runs but no headers are stamped on
/// the wire. See the type-level remarks for the conditions under which delivery happens.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to extend.</param>
/// <returns>The same builder, to allow fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static AIAgentBuilder UseClientHeaders(this AIAgentBuilder builder) =>
Throw.IfNull(builder).Use((AIAgent innerAgent, IServiceProvider services) =>
{
// Agent-side dedup: if any decorator in the chain is already a ClientHeadersAgent, no-op.
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
// Best-effort policy registration on the underlying OpenAI-backed chat client.
// Silent no-op when the service is unavailable (non-OpenAI providers).
if (innerAgent.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
});
/// <summary>Reads the headers dictionary stamped by callers, or <see langword="null"/> if none.</summary>
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
internal static IReadOnlyDictionary<string, string>? GetClientHeaders(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
if (!options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var raw))
{
return null;
}
return raw as Dictionary<string, string>;
}
private static Dictionary<string, string> GetOrCreateHeadersDictionary(ChatOptions options)
{
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
{
if (existing is Dictionary<string, string> dict)
{
return dict;
}
throw new InvalidOperationException(
$"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary<string, string>.");
}
var fresh = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
options.AdditionalProperties[ClientHeadersKey] = fresh;
return fresh;
}
private static void ValidateHeader(string name, string value)
{
_ = Throw.IfNull(name);
_ = Throw.IfNull(value);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Header name must not be empty or whitespace.", nameof(name));
}
if (value.Length == 0)
{
throw new ArgumentException("Header value must not be empty.", nameof(value));
}
if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Header name '{name}' must start with '{ClientHeaderPrefix}' (case-insensitive). Only x-client-* headers are forwarded by the Foundry platform.",
nameof(name));
}
}
}
@@ -0,0 +1,152 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Pipeline policy that stamps <c>x-client-*</c> headers from the current
/// <see cref="ClientHeadersScope"/> onto outbound OpenAI Responses requests.
/// </summary>
/// <remarks>
/// <para>
/// Registered once per <see cref="OpenAIRequestPolicies"/> instance via the new MEAI 10.5.1
/// extension hook. Headers are written using <see cref="PipelineRequestHeaders.Set(string, string)"/>
/// so per-call values overwrite anything stamped earlier in the pipeline (for example by static
/// pipeline policies registered on the underlying client). This also makes accidental double
/// registration value-stable.
/// </para>
/// </remarks>
internal sealed class ClientHeadersPolicy : PipelinePolicy
{
public static ClientHeadersPolicy Instance { get; } = new ClientHeadersPolicy();
private ClientHeadersPolicy()
{
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
Stamp(message);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
Stamp(message);
return ProcessNextAsync(message, pipeline, currentIndex);
}
private static void Stamp(PipelineMessage message)
{
var headers = ClientHeadersScope.Current;
if (headers is null || headers.Count == 0)
{
return;
}
foreach (var kvp in headers)
{
// Per-call wins: Set overwrites any same-name header previously stamped by other policies.
message.Request.Headers.Set(kvp.Key, kvp.Value);
}
}
}
/// <summary>
/// Best-effort reflection helpers for <see cref="OpenAIRequestPolicies"/>. MEAI 10.5.1 does not
/// publicly expose its registered-policies list, so we reach into the private <c>_entries</c>
/// field to detect duplicate registrations of <see cref="ClientHeadersPolicy.Instance"/>.
/// </summary>
/// <remarks>
/// All access is guarded with try/catch and graceful fallback. If MEAI changes the field name
/// or shape in a future bump, dedup degrades to "always add" but stamping stays correct because
/// <see cref="ClientHeadersPolicy"/> uses <c>Headers.Set</c>. A CI test asserts the field shape
/// to fail loudly on future MEAI bumps.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
internal static class OpenAIRequestPoliciesReflection
{
private static readonly Lazy<FieldInfo?> s_entriesField = new(() =>
{
try
{
return typeof(OpenAIRequestPolicies).GetField(
"_entries",
BindingFlags.Instance | BindingFlags.NonPublic);
}
catch
{
return null;
}
});
/// <summary>Returns <see langword="true"/> if <paramref name="policies"/> already contains <paramref name="policy"/>.</summary>
/// <remarks>Returns <see langword="false"/> on any reflection failure (caller should treat the registration as not yet done).</remarks>
#if NET
[UnconditionalSuppressMessage("Trimming", "IL2075:RequiresUnreferencedCode",
Justification = "Reflecting on the private Entry struct shipped by Microsoft.Extensions.AI.OpenAI; falls back gracefully if shape changes. CI test asserts the field shape on every MEAI bump.")]
#endif
public static bool ContainsPolicy(OpenAIRequestPolicies policies, PipelinePolicy policy)
{
try
{
if (s_entriesField.Value?.GetValue(policies) is not Array entries)
{
return false;
}
for (int i = 0; i < entries.Length; i++)
{
var entry = entries.GetValue(i);
if (entry is null)
{
continue;
}
// Entry is a private struct with a Policy property/field. Try property first, then field.
var entryType = entry.GetType();
var policyMember = entryType.GetProperty("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
object? value = policyMember is not null
? policyMember.GetValue(entry)
: entryType.GetField("Policy", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(entry);
if (ReferenceEquals(value, policy))
{
return true;
}
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Registers <paramref name="policy"/> on <paramref name="policies"/> if not already present.
/// </summary>
/// <returns>
/// <see langword="true"/> if <c>AddPolicy</c> was called on this invocation; <see langword="false"/>
/// when the policy was already detected as present and the call was skipped.
/// </returns>
public static bool AddPolicyIfMissing(OpenAIRequestPolicies policies, PipelinePolicy policy, PipelinePosition position = PipelinePosition.PerCall)
{
if (ContainsPolicy(policies, policy))
{
return false;
}
policies.AddPolicy(policy, position);
return true;
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// AsyncLocal carrier that bridges per-call client-header values from the
/// <see cref="ClientHeadersAgent"/> decorator down to the
/// <see cref="ClientHeadersPolicy"/> running inside the SCM transport pipeline.
/// </summary>
/// <remarks>
/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
/// setting method returns. This type pairs each <see cref="Push(IReadOnlyDictionary{string, string}?)"/>
/// with a disposable that explicitly restores the prior value, giving stack-style LIFO semantics
/// for nested or sequential per-call scopes on the same async flow.
/// </remarks>
internal static class ClientHeadersScope
{
private static readonly AsyncLocal<IReadOnlyDictionary<string, string>?> s_current = new();
/// <summary>Gets the dictionary captured by the most recent <see cref="Push(IReadOnlyDictionary{string, string}?)"/> on this async flow.</summary>
public static IReadOnlyDictionary<string, string>? Current => s_current.Value;
/// <summary>
/// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
/// </summary>
/// <param name="headers">The header dictionary to surface to the policy. May be <see langword="null"/>.</param>
public static Scope Push(IReadOnlyDictionary<string, string>? headers)
{
var previous = s_current.Value;
s_current.Value = headers;
return new Scope(previous);
}
/// <summary>Disposable token that restores the previous scope on <see cref="Dispose"/>.</summary>
internal readonly struct Scope : System.IDisposable
{
private readonly IReadOnlyDictionary<string, string>? _previous;
internal Scope(IReadOnlyDictionary<string, string>? previous)
{
this._previous = previous;
}
public void Dispose() => s_current.Value = this._previous;
}
}
@@ -102,7 +102,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// Internal constructor used by <c>AsAIAgent</c> extension methods that already have an <see cref="AIProjectClient"/> and a configured <see cref="ChatClientAgent"/>.
/// </summary>
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
: base(Throw.IfNull(innerAgent))
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
@@ -128,7 +128,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// </para>
/// </remarks>
public ValueTask<AgentSession> CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
=> ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken);
=> this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);
/// <summary>
/// Creates a server-side conversation session that appears in the Foundry Project UI.
@@ -143,9 +143,14 @@ public sealed class FoundryAgent : DelegatingAIAgent
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
return (ChatClientAgentSession)await ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
return (ChatClientAgentSession)await this.GetInnerChatClientAgent().CreateSessionAsync(conversation.Id, cancellationToken).ConfigureAwait(false);
}
/// <summary>Walks the delegating chain to find the inner <see cref="ChatClientAgent"/>.</summary>
private ChatClientAgent GetInnerChatClientAgent() =>
this.GetService<ChatClientAgent>()
?? throw new InvalidOperationException("FoundryAgent inner chain does not contain a ChatClientAgent.");
#endregion
/// <inheritdoc/>
@@ -161,7 +166,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
#region Private helpers
private static ChatClientAgent CreateInnerAgent(
private static AIAgent CreateInnerAgent(
AIProjectClient aiProjectClient,
string model, string instructions,
string? name, string? description,
@@ -191,7 +196,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
}
private static ChatClientAgent CreateResponsesChatClientAgent(
private static AIAgent CreateResponsesChatClientAgent(
AIProjectClient aiProjectClient,
ChatClientAgentOptions agentOptions,
Func<IChatClient, IChatClient>? clientFactory,
@@ -210,10 +215,36 @@ public sealed class FoundryAgent : DelegatingAIAgent
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
}
private static ChatClientAgent CreateInnerAgentFromEndpoint(
/// <summary>
/// Registers <see cref="ClientHeadersPolicy"/> on the agent's underlying chat client (if it
/// exposes <see cref="OpenAIRequestPolicies"/>) and wraps the agent in a
/// <see cref="ClientHeadersAgent"/> so per-call <c>x-client-*</c> headers stamped via
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/> reach
/// the wire. Idempotent: if the chain already contains a <see cref="ClientHeadersAgent"/>,
/// the original instance is returned unchanged.
/// </summary>
private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
{
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
if (innerAgent.ChatClient.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
private static AIAgent CreateInnerAgentFromEndpoint(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList<AITool>? tools,
@@ -238,7 +269,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
chatClient = clientFactory(chatClient);
}
return new ChatClientAgent(chatClient, agentOptions, services: services);
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
@@ -21,6 +21,7 @@ internal static class DiagnosticIds
internal const string AIResponseContinuations = MEAIExperiments;
internal const string AIMcpServers = MEAIExperiments;
internal const string AIFunctionApprovals = MEAIExperiments;
internal const string AIOpenAIRequestPolicies = MEAIExperiments;
// 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