From 9f31bb6795e329d42cdd980d4ae9585e0eb9fdb3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 5 May 2026 14:07:22 +0100 Subject: [PATCH 1/6] 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() * 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?> 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. --- dotnet/Directory.Packages.props | 6 +- .../HostedAgentUserAgentPolicy.cs | 8 +- .../ServiceCollectionExtensions.cs | 77 +- .../UserAgentResponsesClient.cs | 113 --- .../ClientHeadersAgent.cs | 105 +++ .../ClientHeadersExtensions.cs | 204 +++++ .../ClientHeadersPolicy.cs | 152 ++++ .../ClientHeadersScope.cs | 46 ++ .../FoundryAgent.cs | 39 +- .../Microsoft.Agents.AI.Foundry.csproj | 2 +- .../UserAgentResponsesClientTests.cs | 452 ----------- .../ClientHeadersExtensionsTests.cs | 706 ++++++++++++++++++ ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 1 + .../OpenTelemetryAgentTests.cs | 37 +- 14 files changed, 1276 insertions(+), 672 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/UserAgentResponsesClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/UserAgentResponsesClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs 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..2735325864 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel.Primitives; using System.Diagnostics.CodeAnalysis; -using System.Reflection; using Azure.AI.AgentServer.Responses; using Azure.Core; using Azure.Identity; @@ -11,7 +11,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 +206,36 @@ 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) + if (chatClient?.GetService() is { } policies) { - return agent; - } - - var meaiType = s_meaiResponsesChatClientType; - if (meaiType is null) - { - return agent; + // The HostedAgentUserAgentPolicy is idempotent on the wire (it skips when the + // supplement is already present in the User-Agent header), so we can register it + // unconditionally without dedup. MEAI's lock-free CAS-append on _entries is safe. + policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall); } - 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; } - - /// - /// 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); } 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..e8e560d735 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs @@ -0,0 +1,105 @@ +// 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) + .WithCancellation(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..72a1be566b --- /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.AgentsAIExperiments)] +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..2f296ea2dd --- /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; +#if NET +using System.Diagnostics.CodeAnalysis; +#endif +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +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. +/// +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..9e1228ed74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs @@ -0,0 +1,46 @@ +// 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. +/// +/// +/// Stack-style usage with using ensures LIFO restoration of any prior value. +/// +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..c2052cf75d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -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 ClientHeadersAgent 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 ClientHeadersAgent CreateResponsesChatClientAgent( AIProjectClient aiProjectClient, ChatClientAgentOptions agentOptions, Func? clientFactory, @@ -210,10 +215,21 @@ private static ChatClientAgent CreateResponsesChatClientAgent( chatClient = clientFactory(chatClient); } - return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); + // Register the ClientHeadersPolicy on the chat client's OpenAIRequestPolicies, if available. + // Silent no-op when the chat client is not OpenAI-backed. + if (chatClient.GetService() is { } policies) + { + OpenAIRequestPoliciesReflection.AddPolicyIfMissing( + policies, + ClientHeadersPolicy.Instance, + System.ClientModel.Primitives.PipelinePosition.PerCall); + } + + var inner = new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); + return new ClientHeadersAgent(inner); } - private static ChatClientAgent CreateInnerAgentFromEndpoint( + private static ClientHeadersAgent CreateInnerAgentFromEndpoint( AIProjectClient aiProjectClient, Uri agentEndpoint, IList? tools, @@ -238,7 +254,16 @@ private static ChatClientAgent CreateInnerAgentFromEndpoint( chatClient = clientFactory(chatClient); } - return new ChatClientAgent(chatClient, agentOptions, services: services); + if (chatClient.GetService() is { } policies) + { + OpenAIRequestPoliciesReflection.AddPolicyIfMissing( + policies, + ClientHeadersPolicy.Instance, + System.ClientModel.Primitives.PipelinePosition.PerCall); + } + + var inner = new ChatClientAgent(chatClient, agentOptions, services: services); + return new ClientHeadersAgent(inner); } private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 71e7df373f..3dc8be0841 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -3,7 +3,7 @@ true true - $(NoWarn);OPENAI001 + $(NoWarn);OPENAI001;MEAI001 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..3919b5c652 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs @@ -0,0 +1,706 @@ +// 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"]); + } + + // ------------------------------------------------------------------------------------------- + // 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/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"])); From a4c8f911b7135c33912ca4f945005229acc95b47 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 5 May 2026 14:18:40 +0100 Subject: [PATCH 2/6] 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. --- .../ServiceCollectionExtensions.cs | 18 ++++++-- .../FoundryAgent.cs | 44 +++++++++++-------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 2735325864..a0f53b342e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System; using System.ClientModel.Primitives; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Azure.AI.AgentServer.Responses; using Azure.Core; using Azure.Identity; @@ -230,12 +231,21 @@ internal static AIAgent TryApplyUserAgent(AIAgent agent) var chatClient = agent.GetService(); if (chatClient?.GetService() is { } policies) { - // The HostedAgentUserAgentPolicy is idempotent on the wire (it skips when the - // supplement is already present in the User-Agent header), so we can register it - // unconditionally without dedup. MEAI's lock-free CAS-append on _entries is safe. - policies.AddPolicy(HostedAgentUserAgentPolicy.Instance, PipelinePosition.PerCall); + // 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); + } } return agent; } + + private static readonly object s_boxedTrue = new(); + private static readonly ConditionalWeakTable s_userAgentRegistrations = new(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index c2052cf75d..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); } @@ -166,7 +166,7 @@ private ChatClientAgent GetInnerChatClientAgent() => #region Private helpers - private static ClientHeadersAgent CreateInnerAgent( + private static AIAgent CreateInnerAgent( AIProjectClient aiProjectClient, string model, string instructions, string? name, string? description, @@ -196,7 +196,7 @@ private static ClientHeadersAgent CreateInnerAgent( return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); } - private static ClientHeadersAgent CreateResponsesChatClientAgent( + private static AIAgent CreateResponsesChatClientAgent( AIProjectClient aiProjectClient, ChatClientAgentOptions agentOptions, Func? clientFactory, @@ -215,9 +215,25 @@ private static ClientHeadersAgent CreateResponsesChatClientAgent( chatClient = clientFactory(chatClient); } - // Register the ClientHeadersPolicy on the chat client's OpenAIRequestPolicies, if available. - // Silent no-op when the chat client is not OpenAI-backed. - if (chatClient.GetService() is { } policies) + 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, @@ -225,11 +241,10 @@ private static ClientHeadersAgent CreateResponsesChatClientAgent( System.ClientModel.Primitives.PipelinePosition.PerCall); } - var inner = new ChatClientAgent(chatClient, agentOptions, loggerFactory, services); - return new ClientHeadersAgent(inner); + return new ClientHeadersAgent(innerAgent); } - private static ClientHeadersAgent CreateInnerAgentFromEndpoint( + private static AIAgent CreateInnerAgentFromEndpoint( AIProjectClient aiProjectClient, Uri agentEndpoint, IList? tools, @@ -254,16 +269,7 @@ private static ClientHeadersAgent CreateInnerAgentFromEndpoint( chatClient = clientFactory(chatClient); } - if (chatClient.GetService() is { } policies) - { - OpenAIRequestPoliciesReflection.AddPolicyIfMissing( - policies, - ClientHeadersPolicy.Instance, - System.ClientModel.Primitives.PipelinePosition.PerCall); - } - - var inner = new ChatClientAgent(chatClient, agentOptions, services: services); - return new ClientHeadersAgent(inner); + return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); } private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null) From 486533248c6224e177955b9cf462ebc991b1fa2c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 5 May 2026 14:53:21 +0100 Subject: [PATCH 3/6] 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. --- .../HostedTryApplyUserAgentDedupTests.cs | 86 +++++++++++++++++++ .../ClientHeadersExtensionsTests.cs | 71 +++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs new file mode 100644 index 0000000000..501b0b4778 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net.Http; +using System.Reflection; +using Microsoft.Extensions.AI; +using OpenAI; + +#pragma warning disable OPENAI001, MEAI001 + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Verifies the dedup behavior of . +/// In hosted mode, calls this method on every +/// inbound request resolution. For singleton agents this would grow MEAI's +/// OpenAIRequestPolicies._entries array unboundedly without per-instance dedup. +/// +public sealed class HostedTryApplyUserAgentDedupTests +{ + [Fact] + public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce() + { + // Arrange + 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: simulate per-request hosted resolution running this many times. + for (int i = 0; i < 50; i++) + { + FoundryHostingExtensions.TryApplyUserAgent(agent); + } + + // Assert: the underlying OpenAIRequestPolicies has exactly one entry, not 50. + var policies = chatClient.GetService(); + Assert.NotNull(policies); + Assert.Equal(1, EntriesCount(policies!)); + } + + [Fact] + public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient() + { + // Arrange: two independent chat clients. + 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: each chat client's OpenAIRequestPolicies has exactly one entry. + Assert.Equal(1, EntriesCount(cc1.GetService()!)); + Assert.Equal(1, EntriesCount(cc2.GetService()!)); + } + + 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 sealed class NoopHandler : HttpMessageHandler + { + protected override System.Threading.Tasks.Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + => System.Threading.Tasks.Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs index 3919b5c652..821b3ec3b9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs @@ -11,6 +11,8 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; using Microsoft.Extensions.AI; using OpenAI; @@ -525,6 +527,75 @@ public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync() Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]); } + // ------------------------------------------------------------------------------------------- + // 20. AsAIAgent path also pre-wires (Foundry-built agent via AzureAIProjectChatClientExtensions + // internal FoundryAgent ctor) — addresses PR review comment on FoundryAgent.cs:229. + // ------------------------------------------------------------------------------------------- + + [Fact] + public void AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent() + { + // Arrange: stand up a real AIProjectClient pointed at a fake transport. + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var http = new HttpClient(handler); +#pragma warning restore CA5399 + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) }); + + // Act: the AsAIAgent extension that previously bypassed the pre-wire path. + var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); + + // Assert: the FoundryAgent's delegating chain now contains a ClientHeadersAgent so + // x-client-* headers stamped on ChatClientAgentRunOptions reach the wire. + Assert.NotNull(agent.GetService()); + } + + [Fact] + public void FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent() + { + // Arrange / Act + var agent = new FoundryAgent( + projectEndpoint: new Uri("https://test.openai.azure.com/"), + credential: new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test instructions"); + + // Assert + Assert.NotNull(agent.GetService()); + } + + // ------------------------------------------------------------------------------------------- + // 21. 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 // ------------------------------------------------------------------------------------------- From 7fd5bccf660feeda7c6af7cd606d1eac8aea9700 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 5 May 2026 15:07:15 +0100 Subject: [PATCH 4/6] 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. --- .../HostedOutboundUserAgentTests.cs | 68 +++++++++++++++ .../HostedTryApplyUserAgentDedupTests.cs | 86 ------------------- .../ClientHeadersExtensionsTests.cs | 44 +--------- .../FoundryAgentTests.cs | 43 ++++++++++ 4 files changed, 112 insertions(+), 129 deletions(-) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs 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/HostedTryApplyUserAgentDedupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs deleted file mode 100644 index 501b0b4778..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedTryApplyUserAgentDedupTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Net.Http; -using System.Reflection; -using Microsoft.Extensions.AI; -using OpenAI; - -#pragma warning disable OPENAI001, MEAI001 - -namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; - -/// -/// Verifies the dedup behavior of . -/// In hosted mode, calls this method on every -/// inbound request resolution. For singleton agents this would grow MEAI's -/// OpenAIRequestPolicies._entries array unboundedly without per-instance dedup. -/// -public sealed class HostedTryApplyUserAgentDedupTests -{ - [Fact] - public void TryApplyUserAgent_RepeatedCalls_OnSameAgent_RegistersPolicyOnce() - { - // Arrange - 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: simulate per-request hosted resolution running this many times. - for (int i = 0; i < 50; i++) - { - FoundryHostingExtensions.TryApplyUserAgent(agent); - } - - // Assert: the underlying OpenAIRequestPolicies has exactly one entry, not 50. - var policies = chatClient.GetService(); - Assert.NotNull(policies); - Assert.Equal(1, EntriesCount(policies!)); - } - - [Fact] - public void TryApplyUserAgent_AcrossDistinctAgents_RegistersPolicyOncePerChatClient() - { - // Arrange: two independent chat clients. - 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: each chat client's OpenAIRequestPolicies has exactly one entry. - Assert.Equal(1, EntriesCount(cc1.GetService()!)); - Assert.Equal(1, EntriesCount(cc2.GetService()!)); - } - - 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 sealed class NoopHandler : HttpMessageHandler - { - protected override System.Threading.Tasks.Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) - => System.Threading.Tasks.Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs index 821b3ec3b9..321dd46ca1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ClientHeadersExtensionsTests.cs @@ -11,8 +11,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Azure.AI.Extensions.OpenAI; -using Azure.AI.Projects; using Microsoft.Extensions.AI; using OpenAI; @@ -528,47 +526,7 @@ public async Task SharedChatClient_AcrossTwoAgents_RegistersPolicyOnceAsync() } // ------------------------------------------------------------------------------------------- - // 20. AsAIAgent path also pre-wires (Foundry-built agent via AzureAIProjectChatClientExtensions - // internal FoundryAgent ctor) — addresses PR review comment on FoundryAgent.cs:229. - // ------------------------------------------------------------------------------------------- - - [Fact] - public void AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent() - { - // Arrange: stand up a real AIProjectClient pointed at a fake transport. - using var handler = new RecordingHandler(); -#pragma warning disable CA5399 - using var http = new HttpClient(handler); -#pragma warning restore CA5399 - var projectClient = new AIProjectClient( - new Uri("https://test.openai.azure.com/"), - new FakeAuthenticationTokenProvider(), - new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) }); - - // Act: the AsAIAgent extension that previously bypassed the pre-wire path. - var agent = projectClient.AsAIAgent(new AgentReference("agent-name")); - - // Assert: the FoundryAgent's delegating chain now contains a ClientHeadersAgent so - // x-client-* headers stamped on ChatClientAgentRunOptions reach the wire. - Assert.NotNull(agent.GetService()); - } - - [Fact] - public void FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent() - { - // Arrange / Act - var agent = new FoundryAgent( - projectEndpoint: new Uri("https://test.openai.azure.com/"), - credential: new FakeAuthenticationTokenProvider(), - model: "gpt-4o-mini", - instructions: "Test instructions"); - - // Assert - Assert.NotNull(agent.GetService()); - } - - // ------------------------------------------------------------------------------------------- - // 21. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations + // 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations // on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario). // ------------------------------------------------------------------------------------------- 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() { From 5c6e21e106f038c3949d3d32142ec8862a5cca43 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 6 May 2026 10:52:47 +0100 Subject: [PATCH 5/6] 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. --- dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs index e8e560d735..b626148949 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersAgent.cs @@ -71,9 +71,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA 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) - .WithCancellation(cancellationToken) - .ConfigureAwait(false)) + await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) { yield return update; } From 66f4a6c9023f2eaf7ef8ce2cb983a1659b6f6c7a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 6 May 2026 15:02:57 +0100 Subject: [PATCH 6/6] 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. --- .../Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs | 2 +- .../src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs | 4 ++-- dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs | 5 ++++- .../Microsoft.Agents.AI.Foundry.csproj | 2 +- dotnet/src/Shared/DiagnosticIds/DiagnosticsIds.cs | 1 + 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs index 72a1be566b..743f1b7574 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs @@ -31,7 +31,7 @@ namespace Microsoft.Agents.AI.Foundry; /// When either condition is not met the call is a silent no-op. /// /// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)] public static class ClientHeadersExtensions { /// The well-known key used to carry the dictionary across packages. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs index 2f296ea2dd..b04232af98 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersPolicy.cs @@ -3,12 +3,11 @@ using System; using System.ClientModel.Primitives; using System.Collections.Generic; -#if NET using System.Diagnostics.CodeAnalysis; -#endif using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Foundry; @@ -72,6 +71,7 @@ private static void Stamp(PipelineMessage message) /// 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(() => diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs index 9e1228ed74..72526183b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersScope.cs @@ -11,7 +11,10 @@ namespace Microsoft.Agents.AI.Foundry; /// running inside the SCM transport pipeline. /// /// -/// Stack-style usage with using ensures LIFO restoration of any prior value. +/// 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 { diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 3dc8be0841..71e7df373f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -3,7 +3,7 @@ true true - $(NoWarn);OPENAI001;MEAI001 + $(NoWarn);OPENAI001 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