mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* 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.
205 lines
9.6 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|