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