diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 56de97dbcb..a520bf9428 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -71,12 +71,12 @@
-
-
+
+
-
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs
index c4130599a9..e5e773db87 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs
@@ -4,6 +4,7 @@
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 User-Agent header, the policy does not append it again.
///
///
-/// This policy is added at request time (per-call )
-/// by when invoking the wrapped
-/// . 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
+/// hook on the agent's underlying chat client. It is only
+/// registered when an agent is resolved by the Foundry hosting layer.
///
///
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
index eb1c1df5a5..a0f53b342e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs
@@ -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.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.DiagnosticIds;
-using OpenAI.Responses;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -207,84 +207,45 @@ internal static AIAgent ApplyOpenTelemetry(AIAgent agent)
}
///
- /// Attempts to wrap the agent's underlying
- /// with a so every outgoing Responses-API request
- /// carries the hosted-agent User-Agent segment.
+ /// Registers the hosted-agent User-Agent supplement policy
+ /// () on the agent's underlying chat client via the
+ /// MEAI 10.5.1 hook so every outgoing OpenAI Responses
+ /// request carries the segment foundry-hosting/agent-framework-dotnet/{version}.
///
///
///
/// Best-effort and idempotent. The method is a no-op when:
///
/// - exposes no ;
- /// - the chat client is not backed by MEAI's internal OpenAIResponsesChatClient (e.g., a non-OpenAI provider or a custom impl);
- /// - the inner is already a .
+ /// - the chat client is not OpenAI-backed (the service lookup returns );
+ /// - the policy was already registered on this client by a prior invocation (deduped via reflection on OpenAIRequestPolicies._entries).
///
///
///
- /// Works for any -derived inner client — both the Foundry-specific
- /// and the native OpenAI
- /// obtained from . The wrapper preserves
- /// the inner client's pipeline (Transport, RetryPolicy, NetworkTimeout, OrganizationId / ProjectId /
- /// UserAgentApplicationId, custom policies) because every override delegates to the inner instance.
- ///
- ///
- /// Returns the same instance unchanged. Mutation happens via
- /// reflection on MEAI's private _responseClient field; the agent itself is not wrapped.
+ /// Returns the same instance unchanged. The policy is installed
+ /// on the chat client; the agent itself is not wrapped.
///
///
internal static AIAgent TryApplyUserAgent(AIAgent agent)
{
var chatClient = agent.GetService();
- if (chatClient is null)
- {
- return agent;
- }
-
- 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)
+ if (chatClient?.GetService() is { } policies)
{
- return agent;
- }
-
- var current = field.GetValue(meaiInstance) as ResponsesClient;
- if (current is null or UserAgentResponsesClient)
- {
- 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);
+ }
}
- field.SetValue(meaiInstance, new UserAgentResponsesClient(current));
return agent;
}
- ///
- /// MEAI's internal OpenAIResponsesChatClient type, resolved once via reflection.
- /// if the type cannot be found (e.g., MEAI version drift).
- ///
- [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");
-
- ///
- /// MEAI's internal _responseClient field on OpenAIResponsesChatClient,
- /// resolved once via reflection. if the field cannot be found.
- ///
- [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 s_userAgentRegistrations = new();
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/UserAgentResponsesClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/UserAgentResponsesClient.cs
deleted file mode 100644
index aaddfd89da..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/UserAgentResponsesClient.cs
+++ /dev/null
@@ -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;
-
-///
-/// A subclass that delegates every protocol-level request to a
-/// wrapped . Before each call, a
-/// is added to the per-call
-/// so the wrapped client's pipeline appends the hosted-agent
-/// User-Agent segment on the wire.
-///
-///
-///
-/// The streaming overloads MEAI binds via reflection (internal CreateResponseStreamingAsync(CreateResponseOptions, RequestOptions)
-/// and internal GetResponseStreamingAsync(GetResponseOptions, RequestOptions)) bottom out
-/// in calls to the public-virtual non-streaming protocol overloads on . Overriding those
-/// non-streaming overloads is therefore sufficient to intercept both streaming and non-streaming traffic.
-///
-///
-/// The base pipeline supplied to
-/// 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 , so the dummy is
-/// never expected to run; the throwing transport surfaces any unexpected escape route loudly.
-///
-///
-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 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 GetResponseAsync(string responseId, IEnumerable? 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? include, bool? stream, int? startingAfter, bool? includeObfuscation, RequestOptions options)
- => this._inner.GetResponse(responseId, include, stream, startingAfter, includeObfuscation, AddUserAgentPolicy(options));
-
- public override async Task 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 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 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 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 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);
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs
new file mode 100644
index 0000000000..b626148949
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs
@@ -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;
+
+///
+/// Delegating that captures any x-client-* headers stored on
+/// by callers of
+/// and pushes
+/// them onto a for the lifetime of the run. The scope is read by
+/// inside the SCM transport pipeline and stamped onto the
+/// outbound request.
+///
+///
+///
+/// The decorator snapshots the header dictionary at scope-push time so concurrent runs that share
+/// the same reference are isolated; mutating the source dictionary after
+/// RunAsync begins does not leak into in-flight requests.
+///
+///
+/// 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.
+///
+///
+internal sealed class ClientHeadersAgent : DelegatingAIAgent
+{
+ public ClientHeadersAgent(AIAgent innerAgent)
+ : base(innerAgent)
+ {
+ }
+
+ ///
+ protected override Task RunCoreAsync(
+ IEnumerable 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 RunAsyncCoreAsync(
+ IEnumerable innerMessages,
+ AgentSession? innerSession,
+ AgentRunOptions? innerOptions,
+ Dictionary innerSnapshot,
+ CancellationToken innerCt)
+ {
+ using var _ = ClientHeadersScope.Push(innerSnapshot);
+ return await this.InnerAgent.RunAsync(innerMessages, innerSession, innerOptions, innerCt).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable 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;
+ }
+ }
+
+ /// Reads the header dictionary stamped by WithClientHeader(s) and returns an immutable snapshot, or if none.
+ private static Dictionary? 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(headers.Count, System.StringComparer.OrdinalIgnoreCase);
+ foreach (var kvp in headers)
+ {
+ copy[kvp.Key] = kvp.Value;
+ }
+
+ return copy;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs
new file mode 100644
index 0000000000..743f1b7574
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs
@@ -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;
+
+///
+/// Provides extension methods for attaching per-call x-client-* headers to an agent run
+/// and for opting an existing into the client-headers pipeline.
+///
+///
+///
+/// The Foundry platform forwards headers prefixed with x-client- transparently from the
+/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
+/// or
+/// to
+/// stamp headers per RunAsync call (for example to attest the SaaS end-user identity
+/// in x-client-end-user-id).
+///
+///
+/// Headers are only delivered to the wire when:
+///
+/// - the agent has been wrapped with (or built via a Foundry factory that pre-wires it), and
+/// - the underlying exposes the experimental MEAI 10.5.1 service (true for OpenAI-backed clients).
+///
+/// When either condition is not met the call is a silent no-op.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
+public static class ClientHeadersExtensions
+{
+ /// The well-known key used to carry the dictionary across packages.
+ internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
+
+ /// The required prefix on every client header name (case-insensitive).
+ private const string ClientHeaderPrefix = "x-client-";
+
+ ///
+ /// Adds a single x-client-* header to the per-call carrier on .
+ ///
+ /// The instance to mutate.
+ /// The header name. Must start with x-client- (case-insensitive).
+ /// The header value. Must be non-empty.
+ /// for fluent chaining.
+ /// , , or is .
+ /// does not start with x-client-, or is empty/whitespace, or is empty.
+ /// The carrier slot on is occupied by a value of a foreign type.
+ 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;
+ }
+
+ ///
+ /// Adds multiple x-client-* headers to the per-call carrier on .
+ ///
+ /// Validation is all-or-nothing: if any entry is invalid no entries are written.
+ /// The instance to mutate.
+ /// The headers to add. Each name must start with x-client-.
+ /// for fluent chaining.
+ /// or is , or any element of has a name or value.
+ /// Any header name does not start with x-client-, or any name is empty/whitespace, or any value is empty.
+ /// The carrier slot on is occupied by a value of a foreign type.
+ public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable> headers)
+ {
+ _ = Throw.IfNull(options);
+ _ = Throw.IfNull(headers);
+
+ // Validate first; mutate only when every entry passes.
+ var staged = new List>();
+ 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;
+ }
+
+ ///
+ /// Wraps the agent built by so that headers stamped by
+ /// on the per-call
+ /// are forwarded onto the outbound HTTP request.
+ ///
+ ///
+ ///
+ /// Idempotent: if the inner agent is already wrapped with a
+ /// anywhere in its delegating chain, the agent is returned unchanged. This makes
+ /// myFoundryAgent.AsBuilder().UseClientHeaders().Build() safe even though Foundry
+ /// agents are pre-wired automatically.
+ ///
+ ///
+ /// Also registers against the underlying chat client's
+ /// service if available. When the underlying chat client
+ /// is not OpenAI-backed (the service lookup returns ), 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.
+ ///
+ ///
+ /// The to extend.
+ /// The same builder, to allow fluent chaining.
+ /// is .
+ 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() 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() is { } policies)
+ {
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
+ policies,
+ ClientHeadersPolicy.Instance,
+ System.ClientModel.Primitives.PipelinePosition.PerCall);
+ }
+
+ return new ClientHeadersAgent(innerAgent);
+ });
+
+ /// Reads the headers dictionary stamped by callers, or if none.
+ [SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
+ internal static IReadOnlyDictionary? 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;
+ }
+
+ private static Dictionary GetOrCreateHeadersDictionary(ChatOptions options)
+ {
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+
+ if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
+ {
+ if (existing is Dictionary dict)
+ {
+ return dict;
+ }
+
+ throw new InvalidOperationException(
+ $"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary.");
+ }
+
+ var fresh = new Dictionary(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));
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs
new file mode 100644
index 0000000000..b04232af98
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs
@@ -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;
+
+///
+/// Pipeline policy that stamps x-client-* headers from the current
+/// onto outbound OpenAI Responses requests.
+///
+///
+///
+/// Registered once per instance via the new MEAI 10.5.1
+/// extension hook. Headers are written using
+/// 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.
+///
+///
+internal sealed class ClientHeadersPolicy : PipelinePolicy
+{
+ public static ClientHeadersPolicy Instance { get; } = new ClientHeadersPolicy();
+
+ private ClientHeadersPolicy()
+ {
+ }
+
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ Stamp(message);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList 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);
+ }
+ }
+}
+
+///
+/// Best-effort reflection helpers for . MEAI 10.5.1 does not
+/// publicly expose its registered-policies list, so we reach into the private _entries
+/// field to detect duplicate registrations of .
+///
+///
+/// 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
+/// uses Headers.Set. A CI test asserts the field shape
+/// to fail loudly on future MEAI bumps.
+///
+[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
+internal static class OpenAIRequestPoliciesReflection
+{
+ private static readonly Lazy s_entriesField = new(() =>
+ {
+ try
+ {
+ return typeof(OpenAIRequestPolicies).GetField(
+ "_entries",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ }
+ catch
+ {
+ return null;
+ }
+ });
+
+ /// Returns if already contains .
+ /// Returns on any reflection failure (caller should treat the registration as not yet done).
+#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;
+ }
+ }
+
+ ///
+ /// Registers on if not already present.
+ ///
+ ///
+ /// if AddPolicy was called on this invocation;
+ /// when the policy was already detected as present and the call was skipped.
+ ///
+ public static bool AddPolicyIfMissing(OpenAIRequestPolicies policies, PipelinePolicy policy, PipelinePosition position = PipelinePosition.PerCall)
+ {
+ if (ContainsPolicy(policies, policy))
+ {
+ return false;
+ }
+
+ policies.AddPolicy(policy, position);
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs
new file mode 100644
index 0000000000..72526183b7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+
+namespace Microsoft.Agents.AI.Foundry;
+
+///
+/// AsyncLocal carrier that bridges per-call client-header values from the
+/// decorator down to the
+/// running inside the SCM transport pipeline.
+///
+///
+/// AsyncLocal flows the value into downstream awaits but does not roll the value back when the
+/// setting method returns. This type pairs each
+/// 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.
+///
+internal static class ClientHeadersScope
+{
+ private static readonly AsyncLocal?> s_current = new();
+
+ /// Gets the dictionary captured by the most recent on this async flow.
+ public static IReadOnlyDictionary? Current => s_current.Value;
+
+ ///
+ /// Pushes a new value as the current scope. Disposing the returned token restores the previous value.
+ ///
+ /// The header dictionary to surface to the policy. May be .
+ public static Scope Push(IReadOnlyDictionary? headers)
+ {
+ var previous = s_current.Value;
+ s_current.Value = headers;
+ return new Scope(previous);
+ }
+
+ /// Disposable token that restores the previous scope on .
+ internal readonly struct Scope : System.IDisposable
+ {
+ private readonly IReadOnlyDictionary? _previous;
+
+ internal Scope(IReadOnlyDictionary? previous)
+ {
+ this._previous = previous;
+ }
+
+ public void Dispose() => s_current.Value = this._previous;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
index 6a40c129ec..db66b30a88 100644
--- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs
@@ -102,7 +102,7 @@ public FoundryAgent(
/// Internal constructor used by AsAIAgent extension methods that already have an and a configured .
///
internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgent)
- : base(Throw.IfNull(innerAgent))
+ : base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
}
@@ -128,7 +128,7 @@ internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgen
///
///
public ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default)
- => ((ChatClientAgent)this.InnerAgent).CreateSessionAsync(conversationId, cancellationToken);
+ => this.GetInnerChatClientAgent().CreateSessionAsync(conversationId, cancellationToken);
///
/// Creates a server-side conversation session that appears in the Foundry Project UI.
@@ -143,9 +143,14 @@ public async Task CreateConversationSessionAsync(Cancell
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);
}
+ /// Walks the delegating chain to find the inner .
+ private ChatClientAgent GetInnerChatClientAgent() =>
+ this.GetService()
+ ?? throw new InvalidOperationException("FoundryAgent inner chain does not contain a ChatClientAgent.");
+
#endregion
///
@@ -161,7 +166,7 @@ public async Task CreateConversationSessionAsync(Cancell
#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 @@ private static ChatClientAgent CreateInnerAgent(
return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services);
}
- private static ChatClientAgent CreateResponsesChatClientAgent(
+ private static AIAgent CreateResponsesChatClientAgent(
AIProjectClient aiProjectClient,
ChatClientAgentOptions agentOptions,
Func? clientFactory,
@@ -210,10 +215,36 @@ private static ChatClientAgent CreateResponsesChatClientAgent(
chatClient = clientFactory(chatClient);
}
- return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
+ return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, loggerFactory, services));
+ }
+
+ ///
+ /// Registers on the agent's underlying chat client (if it
+ /// exposes ) and wraps the agent in a
+ /// so per-call x-client-* headers stamped via
+ /// reach
+ /// the wire. Idempotent: if the chain already contains a ,
+ /// the original instance is returned unchanged.
+ ///
+ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent)
+ {
+ if (innerAgent.GetService() is not null)
+ {
+ return innerAgent;
+ }
+
+ if (innerAgent.ChatClient.GetService() is { } policies)
+ {
+ OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
+ policies,
+ ClientHeadersPolicy.Instance,
+ System.ClientModel.Primitives.PipelinePosition.PerCall);
+ }
+
+ return new ClientHeadersAgent(innerAgent);
}
- private static ChatClientAgent CreateInnerAgentFromEndpoint(
+ private static AIAgent CreateInnerAgentFromEndpoint(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
IList? tools,
@@ -238,7 +269,7 @@ private static ChatClientAgent CreateInnerAgentFromEndpoint(
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)
diff --git a/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs b/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs
index 2d4d02e3bf..721bfd674d 100644
--- a/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs
+++ b/dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs
@@ -21,6 +21,7 @@ internal static class Experiments
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
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs
index 31981509e4..b68661cea2 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedOutboundUserAgentTests.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
@@ -14,6 +15,7 @@
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
+using OpenAI;
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
@@ -134,6 +136,72 @@ private static string MinimalResponseJson() => """
}
""";
+ [Fact]
+ public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce()
+ {
+ // Arrange: hosted resolution calls TryApplyUserAgent on every request. Without per-instance
+ // dedup, each call would append another policy entry to the shared OpenAIRequestPolicies,
+ // producing unbounded growth on singleton agents (one chat client reused across requests).
+ using var http = new HttpClient(new NoopHandler());
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
+ IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
+ AIAgent agent = new ChatClientAgent(chatClient);
+
+ // Act
+ for (int i = 0; i < 50; i++)
+ {
+ FoundryHostingExtensions.TryApplyUserAgent(agent);
+ }
+
+ // Assert: exactly one HostedAgentUserAgentPolicy entry on the shared OpenAIRequestPolicies.
+ var policies = chatClient.GetService();
+ Assert.NotNull(policies);
+ Assert.Equal(1, EntriesCount(policies!));
+ }
+
+ [Fact]
+ public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient()
+ {
+ // Arrange: dedup is per-OpenAIRequestPolicies-instance, not global, so two agents on
+ // different chat clients each get exactly one registration.
+ using var http1 = new HttpClient(new NoopHandler());
+ using var http2 = new HttpClient(new NoopHandler());
+ var client1 = new OpenAIClient(new ApiKeyCredential("k1"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http1) });
+ var client2 = new OpenAIClient(new ApiKeyCredential("k2"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http2) });
+
+ IChatClient cc1 = client1.GetResponsesClient().AsIChatClient();
+ IChatClient cc2 = client2.GetResponsesClient().AsIChatClient();
+ AIAgent a1 = new ChatClientAgent(cc1);
+ AIAgent a2 = new ChatClientAgent(cc2);
+
+ // Act
+ for (int i = 0; i < 10; i++)
+ {
+ FoundryHostingExtensions.TryApplyUserAgent(a1);
+ FoundryHostingExtensions.TryApplyUserAgent(a2);
+ }
+
+ // Assert
+ Assert.Equal(1, EntriesCount(cc1.GetService()!));
+ Assert.Equal(1, EntriesCount(cc2.GetService()!));
+ }
+
+ private static int EntriesCount(OpenAIRequestPolicies policies)
+ {
+ var field = typeof(OpenAIRequestPolicies).GetField("_entries", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ var array = (Array?)field?.GetValue(policies);
+ return array?.Length ?? -1;
+ }
+
+ private sealed class NoopHandler : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+ }
+
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/UserAgentResponsesClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/UserAgentResponsesClientTests.cs
deleted file mode 100644
index c57bf6802e..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/UserAgentResponsesClientTests.cs
+++ /dev/null
@@ -1,452 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.ClientModel;
-using System.ClientModel.Primitives;
-using System.Collections.Generic;
-using System.Net;
-using System.Net.Http;
-using System.Reflection;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Azure.AI.Extensions.OpenAI;
-using Microsoft.Extensions.AI;
-using OpenAI;
-using OpenAI.Responses;
-
-#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
-
-namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
-
-///
-/// Verifies that preserves user-supplied client options
-/// (Transport, RetryPolicy, UserAgentApplicationId, OrganizationId, ProjectId) and adds the
-/// hosted-agent User-Agent supplement on every outgoing request, including streaming.
-/// Covers both the Azure-flavored and the native OpenAI
-/// .
-///
-public sealed partial class UserAgentResponsesClientTests
-{
- private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
- private const string OpenAIEndpoint = "https://fake-openai.example.com/v1";
- private const string Deployment = "fake-deployment";
-
- [System.Text.RegularExpressions.GeneratedRegex("foundry-hosting/agent-framework-dotnet")]
- private static partial System.Text.RegularExpressions.Regex SupplementRegex();
-
- [Fact]
- public async Task Polyfill_NonStreaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
- {
- // Arrange
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var chat = MakeWithDelegating(inner);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- Assert.StartsWith(TestEndpoint, req.Uri);
- }
-
- [Fact]
- public async Task Polyfill_Streaming_PreservesAppId_ThroughCustomTransport_AddsSupplementAsync()
- {
- // Arrange
- using var handler = new RecordingHandler(MinimalSseResponse());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var chat = MakeWithDelegating(inner);
-
- // Act
- await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
- {
- }
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- Assert.StartsWith(TestEndpoint, req.Uri);
- }
-
- [Fact]
- public async Task Polyfill_PreservesOrganizationAndProjectHeadersAsync()
- {
- // Arrange
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient,
- userAgentApplicationId: "MY_APP_ID",
- organizationId: "org_xyz",
- projectId: "proj_abc");
- var chat = MakeWithDelegating(inner);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- }
-
- [Fact]
- public async Task Polyfill_HonorsUserSuppliedRetryPolicy_ByCountingRetriesAsync()
- {
- // Arrange
- var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
- var chat = MakeWithDelegating(inner);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert: retry policy ran (1 + 2 extras = 3 attempts).
- Assert.Equal(3, handler.Requests.Count);
- Assert.Equal(3, retryPolicy.InvocationCount);
- foreach (var req in handler.Requests)
- {
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- }
- }
-
- [Fact]
- public async Task Baseline_NonStreaming_DoesNotInjectSupplementAsync()
- {
- // Arrange
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var chat = inner.AsIChatClient(Deployment);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- }
-
- [Fact]
- public async Task Polyfill_NativeOpenAIResponsesClient_NonStreaming_AddsSupplementAsync()
- {
- // Arrange: use the NATIVE OpenAI SDK ResponsesClient (no Foundry / Azure project involved).
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var chat = MakeWithDelegating(inner);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- Assert.StartsWith(OpenAIEndpoint, req.Uri);
- }
-
- [Fact]
- public async Task Polyfill_NativeOpenAIResponsesClient_Streaming_AddsSupplementAsync()
- {
- // Arrange
- using var handler = new RecordingHandler(MinimalSseResponse());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var chat = MakeWithDelegating(inner);
-
- // Act
- await foreach (var _ in chat.GetStreamingResponseAsync("hello"))
- {
- }
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("MEAI/", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- Assert.StartsWith(OpenAIEndpoint, req.Uri);
- }
-
- [Theory]
- [InlineData("DeleteResponseAsync")]
- [InlineData("CancelResponseAsync")]
- [InlineData("GetInputTokenCountAsync")]
- [InlineData("CompactResponseAsync")]
- [InlineData("GetResponseInputItemCollectionPageAsync")]
- public async Task Polyfill_AncillaryProtocolMethod_AddsSupplementAsync(string method)
- {
- // Arrange: hit the wrapper DIRECTLY (no MEAI in the chain) to simulate user code that
- // grabs the underlying ResponsesClient via chat.GetService() and invokes
- // a non-Create/Get protocol method. This is the regression path: without overriding these,
- // the wrapper's dummy throwing pipeline would fire.
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildOpenAIInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- var wrapper = new UserAgentResponsesClient(inner);
-
- // Act
- switch (method)
- {
- case "DeleteResponseAsync":
- _ = await wrapper.DeleteResponseAsync("resp_1", options: null!);
- break;
- case "CancelResponseAsync":
- _ = await wrapper.CancelResponseAsync("resp_1", options: null!);
- break;
- case "GetInputTokenCountAsync":
- _ = await wrapper.GetInputTokenCountAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
- break;
- case "CompactResponseAsync":
- _ = await wrapper.CompactResponseAsync("application/json", BinaryContent.Create(BinaryData.FromString("{}")));
- break;
- case "GetResponseInputItemCollectionPageAsync":
- _ = await wrapper.GetResponseInputItemCollectionPageAsync("resp_1", limit: null, order: "asc", after: "a", before: "b", options: null!);
- break;
- default:
- Assert.Fail($"Unhandled method: {method}");
- break;
- }
-
- // Assert
- var req = Assert.Single(handler.Requests);
- Assert.Contains("MY_APP_ID", req.UserAgent);
- Assert.Contains("foundry-hosting/agent-framework-dotnet", req.UserAgent);
- }
-
- [Fact]
- public async Task Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgentAsync()
- {
- // Arrange: a custom retry policy that re-runs the inner pipeline on the SAME message,
- // so the per-call HostedAgentUserAgentPolicy fires multiple times against the same headers.
- // The policy's Contains-guard must prevent the supplement from appearing twice.
- var retryPolicy = new CountingRetryPolicy(extraAttempts: 2);
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID", retryPolicy: retryPolicy);
- var chat = MakeWithDelegating(inner);
-
- // Act
- _ = await chat.GetResponseAsync("hello");
-
- // Assert: each retry attempt must have exactly ONE foundry-hosting segment, never two.
- Assert.Equal(3, handler.Requests.Count);
- foreach (var req in handler.Requests)
- {
- int matches = SupplementRegex().Matches(req.UserAgent).Count;
- Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment per retry attempt, got {matches}. UA: {req.UserAgent}");
- }
- }
-
- [Fact]
- public async Task TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrapAsync()
- {
- // Arrange: build a real ChatClientAgent whose IChatClient resolves to MEAI's
- // OpenAIResponsesChatClient → ProjectResponsesClient (with a fake transport).
- using var handler = new RecordingHandler(MinimalResponseJson());
-#pragma warning disable CA5399
- using var httpClient = new HttpClient(handler);
-#pragma warning restore CA5399
- var inner = BuildInner(httpClient, userAgentApplicationId: "MY_APP_ID");
- IChatClient chatClient = inner.AsIChatClient(Deployment);
- AIAgent agent = new ChatClientAgent(chatClient);
-
- // Act: apply twice.
- FoundryHostingExtensions.TryApplyUserAgent(agent);
- FoundryHostingExtensions.TryApplyUserAgent(agent);
-
- // Assert: invoking the agent produces exactly ONE outbound request whose UA contains
- // the supplement EXACTLY ONCE (would be twice if the wrapper were nested).
- _ = await chatClient.GetResponseAsync("hello");
- var req = Assert.Single(handler.Requests);
- int matches = SupplementRegex().Matches(req.UserAgent).Count;
- Assert.True(matches == 1, $"Expected exactly one foundry-hosting segment, got {matches}. UA: {req.UserAgent}");
- }
-
- [Fact]
- public void OpenAIResponsesChatClient_ResponseClientField_ReflectionGuard()
- {
- // Guards the polyfill's reflection target. Failure here means MEAI internals
- // changed and the polyfill needs updating.
- var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
- .GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
- Assert.NotNull(meaiType);
-
- var field = meaiType!.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance);
- Assert.NotNull(field);
- Assert.True(typeof(ResponsesClient).IsAssignableFrom(field!.FieldType),
- $"Expected _responseClient to be assignable to ResponsesClient but was {field.FieldType}.");
- }
-
- [Fact]
- public void ResponsesClient_PipelineProperty_ReflectionGuard()
- {
- // The polyfill design assumes ResponsesClient.Pipeline remains accessible.
- var pipelineProp = typeof(ResponsesClient).GetProperty("Pipeline", BindingFlags.Public | BindingFlags.Instance);
- Assert.NotNull(pipelineProp);
- Assert.Equal(typeof(ClientPipeline), pipelineProp!.PropertyType);
- }
-
- private static IChatClient MakeWithDelegating(ResponsesClient inner)
- {
- IChatClient meai = inner.AsIChatClient(Deployment);
- var meaiType = meai.GetType();
- var field = meaiType.GetField("_responseClient", BindingFlags.NonPublic | BindingFlags.Instance)!;
- field.SetValue(meai, new UserAgentResponsesClient(inner));
- return meai;
- }
-
- private static ProjectResponsesClient BuildInner(
- HttpClient httpClient,
- string? userAgentApplicationId = null,
- string? organizationId = null,
- string? projectId = null,
- PipelinePolicy? retryPolicy = null)
- {
- var options = new ProjectResponsesClientOptions
- {
- Transport = new HttpClientPipelineTransport(httpClient),
- };
- if (userAgentApplicationId is not null)
- {
- options.UserAgentApplicationId = userAgentApplicationId;
- }
- if (organizationId is not null)
- {
- options.OrganizationId = organizationId;
- }
- if (projectId is not null)
- {
- options.ProjectId = projectId;
- }
- if (retryPolicy is not null)
- {
- options.RetryPolicy = retryPolicy;
- }
-
- return new ProjectResponsesClient(new Uri(TestEndpoint), new FakeAuthenticationTokenProvider(), options);
- }
-
- private static ResponsesClient BuildOpenAIInner(
- HttpClient httpClient,
- string? userAgentApplicationId = null)
- {
- var options = new OpenAIClientOptions
- {
- Transport = new HttpClientPipelineTransport(httpClient),
- Endpoint = new Uri(OpenAIEndpoint),
- };
- if (userAgentApplicationId is not null)
- {
- options.UserAgentApplicationId = userAgentApplicationId;
- }
-
- return new ResponsesClient(new ApiKeyCredential("test-key"), options);
- }
-
- private static string MinimalResponseJson() => """
- {
- "id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
- "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
- }
- """;
-
- private static string MinimalSseResponse()
- {
- var sb = new StringBuilder();
- sb.Append("event: response.completed\n");
- sb.Append("data: ").Append("""{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1700000000,"status":"completed","model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}""").Append("\n\n");
- sb.Append("data: [DONE]\n\n");
- return sb.ToString();
- }
-
- private sealed class RecordingHandler : HttpClientHandler
- {
- private readonly string _body;
- public List Requests { get; } = [];
-
- public RecordingHandler(string body)
- {
- this._body = body;
- }
-
- protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
- {
- string ua = request.Headers.TryGetValues("User-Agent", out var values)
- ? string.Join(",", values)
- : "(none)";
- this.Requests.Add(new RecordedRequest(request.Method.Method, request.RequestUri?.ToString() ?? "?", ua));
-
- var resp = new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
- RequestMessage = request,
- };
- return Task.FromResult(resp);
- }
- }
-
- private readonly record struct RecordedRequest(string Method, string Uri, string UserAgent);
-
- private sealed class CountingRetryPolicy : PipelinePolicy
- {
- private readonly int _extraAttempts;
- public int InvocationCount { get; private set; }
-
- public CountingRetryPolicy(int extraAttempts)
- {
- this._extraAttempts = extraAttempts;
- }
-
- public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
- {
- for (int i = 0; i <= this._extraAttempts; i++)
- {
- this.InvocationCount++;
- ProcessNext(message, pipeline, currentIndex);
- }
- }
-
- public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
- {
- for (int i = 0; i <= this._extraAttempts; i++)
- {
- this.InvocationCount++;
- await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
- }
- }
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs
new file mode 100644
index 0000000000..321dd46ca1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs
@@ -0,0 +1,735 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI;
+
+#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
+
+namespace Microsoft.Agents.AI.Foundry.UnitTests;
+
+///
+/// Tests for the per-call x-client-* header pipeline:
+/// ,
+/// ,
+/// the ClientHeadersAgent decorator, the ClientHeadersScope AsyncLocal,
+/// and the ClientHeadersPolicy stamping policy.
+///
+public sealed class ClientHeadersExtensionsTests
+{
+ // -------------------------------------------------------------------------------------------
+ // 1. WithClientHeader writes namespaced key with valid value
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void WithClientHeader_WritesNamespacedKey_WithValidValue()
+ {
+ // Arrange
+ var options = new ChatOptions();
+
+ // Act
+ options.WithClientHeader("x-client-end-user-id", "alice");
+
+ // Assert
+ Assert.NotNull(options.AdditionalProperties);
+ var raw = options.AdditionalProperties[ClientHeadersExtensions.ClientHeadersKey];
+ var dict = Assert.IsType>(raw);
+ Assert.Equal("alice", dict["X-CLIENT-END-USER-ID"]); // OrdinalIgnoreCase
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 2. WithClientHeader rejects non-x-client- prefix
+ // -------------------------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Authorization")]
+ [InlineData("X-Custom-Header")]
+ [InlineData("client-end-user-id")]
+ [InlineData("xclient-end-user-id")]
+ public void WithClientHeader_RejectsInvalidPrefix(string name)
+ {
+ // Arrange
+ var options = new ChatOptions();
+
+ // Act / Assert
+ Assert.Throws(() => options.WithClientHeader(name, "value"));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 3. WithClientHeader rejects null/empty name and value
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void WithClientHeader_RejectsNullName()
+ {
+ var options = new ChatOptions();
+ Assert.Throws(() => options.WithClientHeader(null!, "v"));
+ }
+
+ [Fact]
+ public void WithClientHeader_RejectsNullValue()
+ {
+ var options = new ChatOptions();
+ Assert.Throws(() => options.WithClientHeader("x-client-foo", null!));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void WithClientHeader_RejectsEmptyOrWhitespaceName(string name)
+ {
+ var options = new ChatOptions();
+ Assert.Throws(() => options.WithClientHeader(name, "v"));
+ }
+
+ [Fact]
+ public void WithClientHeader_RejectsEmptyValue()
+ {
+ var options = new ChatOptions();
+ Assert.Throws(() => options.WithClientHeader("x-client-foo", ""));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 4. WithClientHeaders (bulk) is all-or-nothing on first invalid key
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void WithClientHeaders_AllOrNothing_OnInvalidKey()
+ {
+ // Arrange
+ var options = new ChatOptions();
+ var headers = new[]
+ {
+ new KeyValuePair("x-client-end-user-id", "alice"),
+ new KeyValuePair("Authorization", "secret"), // invalid prefix
+ new KeyValuePair("x-client-end-chat-id", "chat-1"),
+ };
+
+ // Act / Assert: throws, and no entries are written.
+ Assert.Throws(() => options.WithClientHeaders(headers));
+ Assert.Null(options.GetClientHeaders());
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 5. Multiple WithClientHeader calls accumulate (additive)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void WithClientHeader_Accumulates_MultipleCalls()
+ {
+ // Arrange
+ var options = new ChatOptions();
+
+ // Act
+ options.WithClientHeader("x-client-a", "1");
+ options.WithClientHeader("x-client-b", "2");
+ options.WithClientHeader("x-client-a", "1-updated"); // upsert
+
+ // Assert
+ var dict = options.GetClientHeaders();
+ Assert.NotNull(dict);
+ Assert.Equal(2, dict!.Count);
+ Assert.Equal("1-updated", dict["x-client-a"]);
+ Assert.Equal("2", dict["x-client-b"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 6. Conflict on slot occupied by foreign type throws InvalidOperationException
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void WithClientHeader_ForeignTypeAtSlot_Throws()
+ {
+ // Arrange
+ var options = new ChatOptions
+ {
+ AdditionalProperties = new AdditionalPropertiesDictionary
+ {
+ [ClientHeadersExtensions.ClientHeadersKey] = "this is not a dictionary",
+ },
+ };
+
+ // Act / Assert
+ Assert.Throws(() => options.WithClientHeader("x-client-foo", "v"));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 7. UseClientHeaders is idempotent (already-wired returns innerAgent)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void UseClientHeaders_IsIdempotent()
+ {
+ // Arrange
+ var inner = new FakeAgent();
+ var first = inner.AsBuilder().UseClientHeaders().Build();
+
+ // Act
+ var second = first.AsBuilder().UseClientHeaders().Build();
+
+ // Assert: only one ClientHeadersAgent in the chain.
+ Assert.NotNull(first.GetService());
+ Assert.NotNull(second.GetService());
+ // The second call should return the same agent unchanged because the chain is already wired.
+ Assert.Same(first, second);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 8. ClientHeadersAgent snapshots dict at push time (mid-run mutation does not leak)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task ClientHeadersAgent_SnapshotsAtPush_MidRunMutationDoesNotLeakAsync()
+ {
+ // Arrange: a fake inner agent that exposes ClientHeadersScope.Current at the moment of RunAsync.
+ IReadOnlyDictionary? observed = null;
+ var inner = new ProbeAgent(_ =>
+ {
+ observed = ClientHeadersScope.Current;
+ // Mutate the source dictionary mid-run; snapshot must not see the mutation.
+ return Task.CompletedTask;
+ });
+
+ var agent = new ClientHeadersAgent(inner);
+ var chatOptions = new ChatOptions();
+ chatOptions.WithClientHeader("x-client-end-user-id", "alice");
+
+ // Act
+ var task = agent.RunAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions));
+ // Mutate the source after RunAsync starts.
+ chatOptions.WithClientHeader("x-client-end-user-id", "bob");
+ await task;
+
+ // Assert: probe saw "alice", not "bob".
+ Assert.NotNull(observed);
+ Assert.Equal("alice", observed!["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 9. ClientHeadersAgent streaming keeps scope alive across yields
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task ClientHeadersAgent_Streaming_HasScopeAtFirstYieldAsync()
+ {
+ // Arrange: in production the SCM pipeline policy fires once at the first MoveNextAsync
+ // (when MEAI's OpenAIResponsesChatClient initiates the HTTP request). We assert that at
+ // that critical moment the AsyncLocal scope is observable. End-to-end coverage of the wire
+ // behavior is provided by EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync.
+ IReadOnlyDictionary? observedAtFirstYield = null;
+ var inner = new ProbeStreamingAgent(yields: 1, onYield: () => observedAtFirstYield = ClientHeadersScope.Current);
+ var agent = new ClientHeadersAgent(inner);
+
+ var chatOptions = new ChatOptions();
+ chatOptions.WithClientHeader("x-client-end-user-id", "carol");
+
+ // Act
+ await foreach (var _ in agent.RunStreamingAsync(messages: [], options: new ChatClientAgentRunOptions(chatOptions)))
+ {
+ // drain
+ }
+
+ // Assert
+ Assert.NotNull(observedAtFirstYield);
+ Assert.Equal("carol", observedAtFirstYield!["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 10. ClientHeadersScope.Push is LIFO and AsyncLocal-isolated (parallel runs don't leak)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task ClientHeadersScope_IsLifoAndAsyncLocalIsolatedAsync()
+ {
+ // Arrange
+ var dictA = new Dictionary { ["x-client-end-user-id"] = "alice" };
+ var dictB = new Dictionary { ["x-client-end-user-id"] = "bob" };
+
+ // Act / Assert
+ await Task.WhenAll(
+ ProbeAsync(dictA, "alice"),
+ ProbeAsync(dictB, "bob"));
+
+ async Task ProbeAsync(Dictionary dict, string expected)
+ {
+ using (ClientHeadersScope.Push(dict))
+ {
+ await Task.Yield();
+ Assert.Equal(expected, ClientHeadersScope.Current!["x-client-end-user-id"]);
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 11. ClientHeadersPolicy no-ops when scope is null
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task ClientHeadersPolicy_NoOps_WhenScopeIsNullAsync()
+ {
+ // Arrange
+ using var handler = new RecordingHandler();
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var pipeline = ClientPipeline.Create(
+ new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
+ perCallPolicies: [ClientHeadersPolicy.Instance],
+ perTryPolicies: default,
+ beforeTransportPolicies: default);
+
+ // Act: no scope pushed
+ var msg = pipeline.CreateMessage();
+ msg.Request.Method = "GET";
+ msg.Request.Uri = new Uri("https://example.test/");
+ await pipeline.SendAsync(msg);
+
+ // Assert
+ Assert.DoesNotContain(handler.Headers, kv => kv.Key.StartsWith("x-client-", StringComparison.OrdinalIgnoreCase));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 12. ClientHeadersPolicy stamps with Set (overwrites pre-existing same-name header)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task ClientHeadersPolicy_StampsWithSet_OverwritesPreExistingHeaderAsync()
+ {
+ // Arrange
+ using var handler = new RecordingHandler();
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+
+ // A pre-existing policy that always sets x-client-end-user-id=initial.
+ var preExisting = new HeaderSetterPolicy("x-client-end-user-id", "initial");
+
+ var pipeline = ClientPipeline.Create(
+ new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) },
+ perCallPolicies: [preExisting, ClientHeadersPolicy.Instance],
+ perTryPolicies: default,
+ beforeTransportPolicies: default);
+
+ var perCall = new Dictionary { ["x-client-end-user-id"] = "alice" };
+
+ // Act
+ using (ClientHeadersScope.Push(perCall))
+ {
+ var msg = pipeline.CreateMessage();
+ msg.Request.Method = "GET";
+ msg.Request.Uri = new Uri("https://example.test/");
+ await pipeline.SendAsync(msg);
+ }
+
+ // Assert: the per-call value won.
+ Assert.Equal("alice", handler.Headers["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 13. Reflection dedup catches duplicate registration on a single OpenAIRequestPolicies
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void OpenAIRequestPoliciesReflection_DedupsDuplicateRegistration()
+ {
+ // Arrange
+ var policies = new OpenAIRequestPolicies();
+
+ // Act
+ var firstAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
+ var secondAdded = OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, ClientHeadersPolicy.Instance);
+
+ // Assert
+ Assert.True(firstAdded);
+ Assert.False(secondAdded);
+ Assert.Equal(1, EntriesCount(policies));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 14. Reflection dedup gracefully fails when shape is wrong (use a fake type to simulate)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void OpenAIRequestPoliciesReflection_ContainsPolicy_ReturnsFalse_OnNullEntries()
+ {
+ // Arrange: a fresh OpenAIRequestPolicies (Entries field exists, but is empty).
+ var policies = new OpenAIRequestPolicies();
+
+ // Act / Assert
+ Assert.False(OpenAIRequestPoliciesReflection.ContainsPolicy(policies, ClientHeadersPolicy.Instance));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 15. CI guardrail: assert OpenAIRequestPolicies._entries field shape
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void OpenAIRequestPolicies_EntriesField_ShapeGuardrail()
+ {
+ // Arrange / Act
+ var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ // Assert: this test fails loudly if MEAI renames the field, so we know to update
+ // OpenAIRequestPoliciesReflection. The Entry array element type is private so we only
+ // assert that the field is an Array; the ContainsPolicy method itself reflects the Policy
+ // member dynamically so it survives Entry-shape changes too.
+ Assert.NotNull(field);
+ Assert.True(typeof(Array).IsAssignableFrom(field!.FieldType),
+ $"Expected _entries to be an Array, got {field.FieldType}.");
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 16. Foundry hosting end-to-end: per-call x-client-end-user-id reaches the wire
+ // (Covered by the existing HostedOutboundUserAgentTests pattern; we add a focused unit test
+ // here that verifies UseClientHeaders + the OpenAIRequestPolicies bridge stamps headers
+ // on the wire when invoked through a real ChatClientAgent.)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EndToEnd_UseClientHeaders_StampsOnWireAsync()
+ {
+ // Arrange: build a real OpenAI ResponsesClient pointed at a fake handler.
+ using var handler = new RecordingHandler(MinimalResponseJson());
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
+ var responsesClient = openAIClient.GetResponsesClient();
+ IChatClient chatClient = responsesClient.AsIChatClient();
+
+ AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
+
+ var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
+ runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
+
+ // Act
+ await agent.RunAsync("hi", options: runOptions);
+
+ // Assert
+ Assert.True(handler.Requests.Count > 0);
+ Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 17. Customer raw end-to-end: covered by #16 (which uses raw new ChatClientAgent + AsBuilder).
+ // Add a streaming variant here.
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task EndToEnd_UseClientHeaders_Streaming_StampsOnWireAsync()
+ {
+ // Arrange
+ using var handler = new RecordingHandler(MinimalResponseJson());
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
+ var responsesClient = openAIClient.GetResponsesClient();
+ IChatClient chatClient = responsesClient.AsIChatClient();
+
+ AIAgent agent = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
+
+ var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
+ runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "carol");
+
+ // Act
+ try
+ {
+ await foreach (var _ in agent.RunStreamingAsync("hi", options: runOptions))
+ {
+ // drain
+ }
+ }
+ catch
+ {
+ // The fake handler returns a non-streaming JSON; MEAI may throw mid-stream while parsing.
+ // The wire request is captured before parsing, so the assertion below still validates the header.
+ }
+
+ // Assert
+ Assert.True(handler.Requests.Count > 0);
+ Assert.Equal("carol", handler.Requests[0].Headers["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 18. Headers-set-but-no-bridge: silent no-op confirmed (non-OpenAI mock)
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task UseClientHeaders_OnNonOpenAIClient_IsSilentNoOpAsync()
+ {
+ // Arrange: a non-OpenAI fake agent that does not expose OpenAIRequestPolicies.
+ var inner = new FakeAgent();
+ var agent = inner.AsBuilder().UseClientHeaders().Build();
+
+ var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
+ runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
+
+ // Act / Assert: no throw. AsyncLocal flows but no policy stamps anything because the
+ // chat client doesn't have OpenAIRequestPolicies registered.
+ await agent.RunAsync("hi", options: runOptions);
+ Assert.True(true);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 19. Shared IChatClient across two agents both calling UseClientHeaders registers
+ // ClientHeadersPolicy exactly once on the shared OpenAIRequestPolicies.
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync()
+ {
+ // Arrange
+ using var handler = new RecordingHandler(MinimalResponseJson());
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIOptions = new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) };
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"), openAIOptions);
+ var responsesClient = openAIClient.GetResponsesClient();
+ IChatClient chatClient = responsesClient.AsIChatClient();
+
+ // Act: build two agents that share the same chat client. Each calls UseClientHeaders.
+ AIAgent agent1 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
+ AIAgent agent2 = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
+
+ // Assert: the shared OpenAIRequestPolicies has exactly one ClientHeadersPolicy registered.
+ var policies = chatClient.GetService();
+ Assert.NotNull(policies);
+ Assert.Equal(1, EntriesCount(policies!));
+
+ // And on the wire, the per-call header is stamped exactly once (no duplication).
+ var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
+ runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
+ try
+ {
+ await agent1.RunAsync("hi", options: runOptions);
+ }
+ catch
+ {
+ // tolerate parser issues; we assert on the wire.
+ }
+ Assert.True(handler.Requests.Count > 0);
+ Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations
+ // on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario).
+ // -------------------------------------------------------------------------------------------
+
+ [Fact]
+ public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce()
+ {
+ // Arrange: a chat client whose OpenAIRequestPolicies service we can inspect.
+ using var handler = new RecordingHandler(MinimalResponseJson());
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
+ new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
+ IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
+
+ // Act: simulate N hosted-resolution-style wirings on top of the same shared chat client.
+ for (int i = 0; i < 25; i++)
+ {
+ _ = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
+ }
+
+ // Assert: exactly one ClientHeadersPolicy entry on the shared OpenAIRequestPolicies.
+ var policies = chatClient.GetService();
+ Assert.NotNull(policies);
+ Assert.Equal(1, EntriesCount(policies!));
+ }
+
+ // -------------------------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------------------------
+
+ private static int EntriesCount(OpenAIRequestPolicies policies)
+ {
+ var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
+ var array = (Array?)field?.GetValue(policies);
+ return array?.Length ?? -1;
+ }
+
+ private static string MinimalResponseJson() => """
+ {
+ "id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
+ "model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
+ }
+ """;
+
+ /// An that records request headers and returns a fixed response body.
+ private sealed class RecordingHandler : HttpClientHandler
+ {
+ private readonly string _body;
+
+ public RecordingHandler(string body = """{}""")
+ {
+ this._body = body;
+ }
+
+ public List Requests { get; } = [];
+
+ public Dictionary Headers => this.Requests.Count > 0 ? this.Requests[0].Headers : new Dictionary();
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var h in request.Headers)
+ {
+ headers[h.Key] = string.Join(",", h.Value);
+ }
+ this.Requests.Add(new RecordedRequest(request.RequestUri?.ToString() ?? "?", headers));
+
+ var resp = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(this._body, Encoding.UTF8, "application/json"),
+ RequestMessage = request,
+ };
+ return Task.FromResult(resp);
+ }
+ }
+
+ private sealed class RecordedRequest
+ {
+ public RecordedRequest(string uri, Dictionary headers)
+ {
+ this.Uri = uri;
+ this.Headers = headers;
+ }
+
+ public string Uri { get; }
+ public Dictionary Headers { get; }
+ }
+
+ /// A pipeline policy that always stamps a fixed header value via Headers.Set.
+ private sealed class HeaderSetterPolicy : PipelinePolicy
+ {
+ private readonly string _name;
+ private readonly string _value;
+
+ public HeaderSetterPolicy(string name, string value)
+ {
+ this._name = name;
+ this._value = value;
+ }
+
+ public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(this._name, this._value);
+ ProcessNext(message, pipeline, currentIndex);
+ }
+
+ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex)
+ {
+ message.Request.Headers.Set(this._name, this._value);
+ return ProcessNextAsync(message, pipeline, currentIndex);
+ }
+ }
+
+ /// A trivial session used by fake agents in these tests.
+ private sealed class TrivialSession : AgentSession { }
+
+ /// A minimal AIAgent that does nothing; used to test decorator wiring.
+ private sealed class FakeAgent : AIAgent
+ {
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new AgentResponse());
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.Yield();
+ yield break;
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(JsonDocument.Parse("{}").RootElement);
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+ }
+
+ /// An AIAgent that invokes a probe action each time RunAsync is called.
+ private sealed class ProbeAgent : AIAgent
+ {
+ private readonly Func _probe;
+
+ public ProbeAgent(Func probe)
+ {
+ this._probe = probe;
+ }
+
+ protected override async Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ await this._probe(cancellationToken);
+ return new AgentResponse();
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await this._probe(cancellationToken);
+ yield break;
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(JsonDocument.Parse("{}").RootElement);
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+ }
+
+ /// An AIAgent whose streaming method invokes onYield at each yield point.
+ private sealed class ProbeStreamingAgent : AIAgent
+ {
+ private readonly int _yields;
+ private readonly Action _onYield;
+
+ public ProbeStreamingAgent(int yields, Action onYield)
+ {
+ this._yields = yields;
+ this._onYield = onYield;
+ }
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ => Task.FromResult(new AgentResponse());
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ for (int i = 0; i < this._yields; i++)
+ {
+ this._onYield();
+ await Task.Yield();
+ yield return new AgentResponseUpdate();
+ }
+ }
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(JsonDocument.Parse("{}").RootElement);
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
+ new(new TrivialSession());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
index 31f981d5c6..45d09689ff 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs
@@ -5,6 +5,7 @@
using System.Net;
using System.Net.Http;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
@@ -153,6 +154,48 @@ public void GetService_ReturnsChatClientAgent()
Assert.NotNull(innerAgent);
}
+ [Fact]
+ public void Constructor_PreWiresClientHeadersAgent()
+ {
+ // Arrange / Act: the public FoundryAgent ctor should pre-wire the client-headers
+ // pipeline so x-client-* headers stamped on ChatClientAgentRunOptions reach the wire.
+ FoundryAgent agent = new(
+ s_testEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ model: "gpt-4o-mini",
+ instructions: "Test");
+
+ // Assert: ClientHeadersAgent decorator is present in the delegating chain.
+ Assert.NotNull(agent.GetService());
+ }
+
+ [Fact]
+ public void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent()
+ {
+ // Arrange: stand up a real AIProjectClient pointed at a fake transport.
+ using var handler = new NoopHandler();
+#pragma warning disable CA5399
+ using var http = new HttpClient(handler);
+#pragma warning restore CA5399
+ var projectClient = new AIProjectClient(
+ s_testEndpoint,
+ new FakeAuthenticationTokenProvider(),
+ new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) });
+
+ // Act: this AsAIAgent path constructs FoundryAgent via its internal
+ // (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
+ var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
+
+ // Assert
+ Assert.NotNull(agent.GetService());
+ }
+
+ private sealed class NoopHandler : HttpClientHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+ }
+
[Fact]
public void GetService_ReturnsIChatClient()
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
index cfa5e7a11f..3b7176711a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj
@@ -18,6 +18,7 @@
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
index 48bdca287e..b8ddd2feaa 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs
@@ -577,7 +577,8 @@ async static IAsyncEnumerable CallbackAsync(
}
},
{
- "type": "web_search"
+ "type": "web_search",
+ "name": "web_search"
},
{
"type": "function",
@@ -604,43 +605,21 @@ async static IAsyncEnumerable CallbackAsync(
Assert.False(tags.ContainsKey("gen_ai.output.messages"));
Assert.False(tags.ContainsKey("gen_ai.system_instructions"));
- // gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+)
+ // gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+).
+ // ME.AI 10.5.1 omits description/parameters for function tools when sensitive data is disabled.
Assert.Equal(ReplaceWhitespace("""
[
{
"type": "function",
- "name": "GetPersonAge",
- "description": "Gets the age of a person by name.",
- "parameters": {
- "type": "object",
- "properties": {
- "personName": {
- "type": "string"
- }
- },
- "required": [
- "personName"
- ]
- }
+ "name": "GetPersonAge"
},
{
- "type": "web_search"
+ "type": "web_search",
+ "name": "web_search"
},
{
"type": "function",
- "name": "GetCurrentWeather",
- "description": "Gets the current weather for a location.",
- "parameters": {
- "type": "object",
- "properties": {
- "location": {
- "type": "string"
- }
- },
- "required": [
- "location"
- ]
- }
+ "name": "GetCurrentWeather"
}
]
"""), ReplaceWhitespace(tags["gen_ai.tool.definitions"]));