Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeadersExtensions.cs
T
Roger BarretoandGitHub b12109b7e4 .NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)
* Bump MEAI to 10.5.1 and add per-call x-client header support

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

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

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

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

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

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

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

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

* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup

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

* Move tests next to their SUT

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

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

* Remove redundant WithCancellation on inner streaming call

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

* Address PR review: surface downstream MEAI experimental ID

* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
  DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
  AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
  instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
  surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
  suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
  pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
  does NOT auto-restore on method return; explicit using/Dispose is what
  gives stack-style LIFO semantics.
2026-05-06 14:43:08 +00:00

205 lines
9.6 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Provides extension methods for attaching per-call <c>x-client-*</c> headers to an agent run
/// and for opting an existing <see cref="AIAgent"/> into the client-headers pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The Foundry platform forwards headers prefixed with <c>x-client-</c> transparently from the
/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> or
/// <see cref="WithClientHeaders(ChatOptions, IEnumerable{KeyValuePair{string, string}})"/> to
/// stamp headers per <c>RunAsync</c> call (for example to attest the SaaS end-user identity
/// in <c>x-client-end-user-id</c>).
/// </para>
/// <para>
/// Headers are only delivered to the wire when:
/// <list type="number">
/// <item><description>the agent has been wrapped with <see cref="UseClientHeaders(AIAgentBuilder)"/> (or built via a Foundry factory that pre-wires it), and</description></item>
/// <item><description>the underlying <see cref="IChatClient"/> exposes the experimental MEAI 10.5.1 <see cref="OpenAIRequestPolicies"/> service (true for OpenAI-backed clients).</description></item>
/// </list>
/// When either condition is not met the call is a silent no-op.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class ClientHeadersExtensions
{
/// <summary>The well-known <see cref="ChatOptions.AdditionalProperties"/> key used to carry the dictionary across packages.</summary>
internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
/// <summary>The required prefix on every client header name (case-insensitive).</summary>
private const string ClientHeaderPrefix = "x-client-";
/// <summary>
/// Adds a single <c>x-client-*</c> header to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="name">The header name. Must start with <c>x-client-</c> (case-insensitive).</param>
/// <param name="value">The header value. Must be non-empty.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/>, <paramref name="name"/>, or <paramref name="value"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> does not start with <c>x-client-</c>, or is empty/whitespace, or <paramref name="value"/> is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeader(this ChatOptions options, string name, string value)
{
_ = Throw.IfNull(options);
ValidateHeader(name, value);
var dict = GetOrCreateHeadersDictionary(options);
dict[name] = value;
return options;
}
/// <summary>
/// Adds multiple <c>x-client-*</c> headers to the per-call carrier on <paramref name="options"/>.
/// </summary>
/// <remarks>Validation is all-or-nothing: if any entry is invalid no entries are written.</remarks>
/// <param name="options">The <see cref="ChatOptions"/> instance to mutate.</param>
/// <param name="headers">The headers to add. Each name must start with <c>x-client-</c>.</param>
/// <returns><paramref name="options"/> for fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="headers"/> is <see langword="null"/>, or any element of <paramref name="headers"/> has a <see langword="null"/> name or value.</exception>
/// <exception cref="ArgumentException">Any header name does not start with <c>x-client-</c>, or any name is empty/whitespace, or any value is empty.</exception>
/// <exception cref="InvalidOperationException">The carrier slot on <see cref="ChatOptions.AdditionalProperties"/> is occupied by a value of a foreign type.</exception>
public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable<KeyValuePair<string, string>> headers)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(headers);
// Validate first; mutate only when every entry passes.
var staged = new List<KeyValuePair<string, string>>();
foreach (var kvp in headers)
{
ValidateHeader(kvp.Key, kvp.Value);
staged.Add(kvp);
}
if (staged.Count == 0)
{
return options;
}
var dict = GetOrCreateHeadersDictionary(options);
foreach (var kvp in staged)
{
dict[kvp.Key] = kvp.Value;
}
return options;
}
/// <summary>
/// Wraps the agent built by <paramref name="builder"/> so that headers stamped by
/// <see cref="WithClientHeader(ChatOptions, string, string)"/> on the per-call
/// <see cref="ChatOptions"/> are forwarded onto the outbound HTTP request.
/// </summary>
/// <remarks>
/// <para>
/// Idempotent: if the inner agent is already wrapped with a <see cref="ClientHeadersAgent"/>
/// anywhere in its delegating chain, the agent is returned unchanged. This makes
/// <c>myFoundryAgent.AsBuilder().UseClientHeaders().Build()</c> safe even though Foundry
/// agents are pre-wired automatically.
/// </para>
/// <para>
/// Also registers <see cref="ClientHeadersPolicy"/> against the underlying chat client's
/// <see cref="OpenAIRequestPolicies"/> service if available. When the underlying chat client
/// is not OpenAI-backed (the service lookup returns <see langword="null"/>), the registration
/// step is silently skipped; the agent decorator still runs but no headers are stamped on
/// the wire. See the type-level remarks for the conditions under which delivery happens.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="AIAgentBuilder"/> to extend.</param>
/// <returns>The same builder, to allow fluent chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static AIAgentBuilder UseClientHeaders(this AIAgentBuilder builder) =>
Throw.IfNull(builder).Use((AIAgent innerAgent, IServiceProvider services) =>
{
// Agent-side dedup: if any decorator in the chain is already a ClientHeadersAgent, no-op.
if (innerAgent.GetService<ClientHeadersAgent>() is not null)
{
return innerAgent;
}
// Best-effort policy registration on the underlying OpenAI-backed chat client.
// Silent no-op when the service is unavailable (non-OpenAI providers).
if (innerAgent.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
});
/// <summary>Reads the headers dictionary stamped by callers, or <see langword="null"/> if none.</summary>
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
internal static IReadOnlyDictionary<string, string>? GetClientHeaders(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
if (!options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var raw))
{
return null;
}
return raw as Dictionary<string, string>;
}
private static Dictionary<string, string> GetOrCreateHeadersDictionary(ChatOptions options)
{
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
{
if (existing is Dictionary<string, string> dict)
{
return dict;
}
throw new InvalidOperationException(
$"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary<string, string>.");
}
var fresh = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
options.AdditionalProperties[ClientHeadersKey] = fresh;
return fresh;
}
private static void ValidateHeader(string name, string value)
{
_ = Throw.IfNull(name);
_ = Throw.IfNull(value);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Header name must not be empty or whitespace.", nameof(name));
}
if (value.Length == 0)
{
throw new ArgumentException("Header value must not be empty.", nameof(value));
}
if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Header name '{name}' must start with '{ClientHeaderPrefix}' (case-insensitive). Only x-client-* headers are forwarded by the Foundry platform.",
nameof(name));
}
}
}