.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:
Roger Barreto
2026-05-06 14:43:08 +00:00
committed by GitHub
parent be8d2619e4
commit b12109b7e4
16 changed files with 1433 additions and 671 deletions
@@ -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.Hosting.Server;
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 @@ public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
}
""";
[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<OpenAIRequestPolicies>();
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<OpenAIRequestPolicies>()!));
Assert.Equal(1, EntriesCount(cc2.GetService<OpenAIRequestPolicies>()!));
}
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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
@@ -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;
/// <summary>
/// Verifies that <see cref="UserAgentResponsesClient"/> 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 <see cref="ProjectResponsesClient"/> and the native OpenAI
/// <see cref="ResponsesClient"/>.
/// </summary>
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<ResponsesClient>() 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<RecordedRequest> Requests { get; } = [];
public RecordingHandler(string body)
{
this._body = body;
}
protected override Task<HttpResponseMessage> 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<PipelinePolicy> 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<PipelinePolicy> pipeline, int currentIndex)
{
for (int i = 0; i <= this._extraAttempts; i++)
{
this.InvocationCount++;
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,735 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using OpenAI;
#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001
namespace Microsoft.Agents.AI.Foundry.UnitTests;
/// <summary>
/// Tests for the per-call <c>x-client-*</c> header pipeline:
/// <see cref="ClientHeadersExtensions.WithClientHeader(ChatOptions, string, string)"/>,
/// <see cref="ClientHeadersExtensions.UseClientHeaders(AIAgentBuilder)"/>,
/// the <c>ClientHeadersAgent</c> decorator, the <c>ClientHeadersScope</c> AsyncLocal,
/// and the <c>ClientHeadersPolicy</c> stamping policy.
/// </summary>
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<Dictionary<string, string>>(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<ArgumentException>(() => options.WithClientHeader(name, "value"));
}
// -------------------------------------------------------------------------------------------
// 3. WithClientHeader rejects null/empty name and value
// -------------------------------------------------------------------------------------------
[Fact]
public void WithClientHeader_RejectsNullName()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader(null!, "v"));
}
[Fact]
public void WithClientHeader_RejectsNullValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentNullException>(() => options.WithClientHeader("x-client-foo", null!));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void WithClientHeader_RejectsEmptyOrWhitespaceName(string name)
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => options.WithClientHeader(name, "v"));
}
[Fact]
public void WithClientHeader_RejectsEmptyValue()
{
var options = new ChatOptions();
Assert.Throws<ArgumentException>(() => 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<string, string>("x-client-end-user-id", "alice"),
new KeyValuePair<string, string>("Authorization", "secret"), // invalid prefix
new KeyValuePair<string, string>("x-client-end-chat-id", "chat-1"),
};
// Act / Assert: throws, and no entries are written.
Assert.Throws<ArgumentException>(() => 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<InvalidOperationException>(() => 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<ClientHeadersAgent>());
Assert.NotNull(second.GetService<ClientHeadersAgent>());
// 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<string, string>? 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<string, string>? 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<string, string> { ["x-client-end-user-id"] = "alice" };
var dictB = new Dictionary<string, string> { ["x-client-end-user-id"] = "bob" };
// Act / Assert
await Task.WhenAll(
ProbeAsync(dictA, "alice"),
ProbeAsync(dictB, "bob"));
async Task ProbeAsync(Dictionary<string, string> 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<string, string> { ["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<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
// And on the wire, the per-call header is stamped exactly once (no duplication).
var runOptions = new ChatClientAgentRunOptions(new ChatOptions());
runOptions.ChatOptions!.WithClientHeader("x-client-end-user-id", "alice");
try
{
await agent1.RunAsync("hi", options: runOptions);
}
catch
{
// tolerate parser issues; we assert on the wire.
}
Assert.True(handler.Requests.Count > 0);
Assert.Equal("alice", handler.Requests[0].Headers["x-client-end-user-id"]);
}
// -------------------------------------------------------------------------------------------
// 20. ClientHeadersPolicy registration via UseClientHeaders is deduped across many invocations
// on the same chat client (mirrors the Foundry.Hosting per-request resolution scenario).
// -------------------------------------------------------------------------------------------
[Fact]
public void UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce()
{
// Arrange: a chat client whose OpenAIRequestPolicies service we can inspect.
using var handler = new RecordingHandler(MinimalResponseJson());
#pragma warning disable CA5399
using var http = new HttpClient(handler);
#pragma warning restore CA5399
var openAIClient = new OpenAIClient(new ApiKeyCredential("fake"),
new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) });
IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient();
// Act: simulate N hosted-resolution-style wirings on top of the same shared chat client.
for (int i = 0; i < 25; i++)
{
_ = new ChatClientAgent(chatClient).AsBuilder().UseClientHeaders().Build();
}
// Assert: exactly one ClientHeadersPolicy entry on the shared OpenAIRequestPolicies.
var policies = chatClient.GetService<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
}
// -------------------------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------------------------
private static int EntriesCount(OpenAIRequestPolicies policies)
{
var field = typeof(OpenAIRequestPolicies).GetField("_entries", BindingFlags.Instance | BindingFlags.NonPublic);
var array = (Array?)field?.GetValue(policies);
return array?.Length ?? -1;
}
private static string MinimalResponseJson() => """
{
"id":"resp_1","object":"response","created_at":1700000000,"status":"completed",
"model":"fake","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
""";
/// <summary>An <see cref="HttpClientHandler"/> that records request headers and returns a fixed response body.</summary>
private sealed class RecordingHandler : HttpClientHandler
{
private readonly string _body;
public RecordingHandler(string body = """{}""")
{
this._body = body;
}
public List<RecordedRequest> Requests { get; } = [];
public Dictionary<string, string> Headers => this.Requests.Count > 0 ? this.Requests[0].Headers : new Dictionary<string, string>();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var headers = new Dictionary<string, string>(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<string, string> headers)
{
this.Uri = uri;
this.Headers = headers;
}
public string Uri { get; }
public Dictionary<string, string> Headers { get; }
}
/// <summary>A pipeline policy that always stamps a fixed header value via Headers.Set.</summary>
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<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
/// <summary>A trivial session used by fake agents in these tests.</summary>
private sealed class TrivialSession : AgentSession { }
/// <summary>A minimal AIAgent that does nothing; used to test decorator wiring.</summary>
private sealed class FakeAgent : AIAgent
{
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent that invokes a probe action each time RunAsync is called.</summary>
private sealed class ProbeAgent : AIAgent
{
private readonly Func<CancellationToken, Task> _probe;
public ProbeAgent(Func<CancellationToken, Task> probe)
{
this._probe = probe;
}
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
return new AgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await this._probe(cancellationToken);
yield break;
}
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
/// <summary>An AIAgent whose streaming method invokes <c>onYield</c> at each yield point.</summary>
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<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentResponse());
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> 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<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TrivialSession());
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(JsonDocument.Parse("{}").RootElement);
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions, CancellationToken cancellationToken = default) =>
new(new TrivialSession());
}
}
@@ -5,6 +5,7 @@ using System.ClientModel.Primitives;
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 class FoundryAgentTests
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<ClientHeadersAgent>());
}
[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<ClientHeadersAgent>());
}
private sealed class NoopHandler : HttpClientHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
[Fact]
public void GetService_ReturnsIChatClient()
{
@@ -18,6 +18,7 @@
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
<Compile Remove="ClientHeadersExtensionsTests.cs" />
</ItemGroup>
<ItemGroup>
@@ -577,7 +577,8 @@ public class OpenTelemetryAgentTests
}
},
{
"type": "web_search"
"type": "web_search",
"name": "web_search"
},
{
"type": "function",
@@ -604,43 +605,21 @@ public class OpenTelemetryAgentTests
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"]));