mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
b12109b7e4
* 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.
233 lines
9.6 KiB
C#
233 lines
9.6 KiB
C#
// 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.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Azure.AI.Extensions.OpenAI;
|
|
using Microsoft.AspNetCore.Builder;
|
|
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
|
|
|
|
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
|
|
|
/// <summary>
|
|
/// End-to-end tests that exercise the FULL hosted ASP.NET Core pipeline:
|
|
/// inbound HTTP → MapFoundryResponses → AgentFrameworkResponseHandler → TryApplyUserAgent →
|
|
/// agent invocation → outbound HTTP from inside the hosted environment.
|
|
/// Verifies that the hosted-agent <c>User-Agent</c> supplement reaches the outbound wire,
|
|
/// not just the inbound request.
|
|
/// </summary>
|
|
public sealed class HostedOutboundUserAgentTests : IAsyncDisposable
|
|
{
|
|
private const string TestEndpoint = "https://fake-foundry.example.com/api/projects/fake-prj";
|
|
private const string Deployment = "fake-deployment";
|
|
|
|
private WebApplication? _app;
|
|
private HttpClient? _inboundClient;
|
|
private RecordingHandler? _outboundHandler;
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
this._inboundClient?.Dispose();
|
|
this._outboundHandler?.Dispose();
|
|
if (this._app is not null)
|
|
{
|
|
await this._app.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Hosted_InboundResponsesRequest_TriggersOutboundCall_WithFoundryHostingSupplementAsync()
|
|
{
|
|
// Arrange: spin up a real ASP.NET Core TestServer that hosts an AIAgent backed by MEAI's
|
|
// OpenAIResponsesChatClient → ProjectResponsesClient → fake HTTP transport. This is the
|
|
// exact production stack minus the network: the only thing not real is the wire transport.
|
|
await this.StartHostedServerAsync();
|
|
|
|
// Act: send an inbound /openai/v1/responses request as the Foundry runtime would.
|
|
using var inboundRequest = new HttpRequestMessage(HttpMethod.Post, "/responses")
|
|
{
|
|
Content = new StringContent(InboundResponsesRequestJson(), Encoding.UTF8, "application/json"),
|
|
};
|
|
using var inboundResponse = await this._inboundClient!.SendAsync(inboundRequest);
|
|
var inboundBody = await inboundResponse.Content.ReadAsStringAsync();
|
|
|
|
// Assert: at least one OUTBOUND request reached the fake transport, AND it carries the
|
|
// foundry-hosting/agent-framework-dotnet/{version} supplement on its User-Agent.
|
|
// (We don't care about the inbound response shape — only that the agent's call to MEAI
|
|
// triggered an outbound request whose UA reaches the sandbox boundary correctly.)
|
|
Assert.True(this._outboundHandler!.Requests.Count > 0,
|
|
$"Expected at least one outbound request. Inbound status: {(int)inboundResponse.StatusCode}, body: {inboundBody}");
|
|
var outbound = this._outboundHandler.Requests[0];
|
|
Assert.StartsWith(TestEndpoint, outbound.Uri);
|
|
Assert.Contains("MEAI/", outbound.UserAgent);
|
|
Assert.Contains("foundry-hosting/agent-framework-dotnet", outbound.UserAgent);
|
|
}
|
|
|
|
private async Task StartHostedServerAsync()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
|
|
// Build a real ChatClientAgent whose IChatClient is MEAI's OpenAIResponsesChatClient
|
|
// wrapping a ProjectResponsesClient backed by a fake HTTP handler. After AgentFrameworkResponseHandler
|
|
// resolves this agent, TryApplyUserAgent will swap the inner _responseClient with our wrapper.
|
|
this._outboundHandler = new RecordingHandler(MinimalResponseJson());
|
|
#pragma warning disable CA5399
|
|
var outboundHttpClient = new HttpClient(this._outboundHandler);
|
|
#pragma warning restore CA5399
|
|
|
|
var projectOptions = new ProjectResponsesClientOptions
|
|
{
|
|
Transport = new HttpClientPipelineTransport(outboundHttpClient),
|
|
};
|
|
var projectResponsesClient = new ProjectResponsesClient(
|
|
new Uri(TestEndpoint),
|
|
new FakeAuthenticationTokenProvider(),
|
|
projectOptions);
|
|
|
|
IChatClient chatClient = projectResponsesClient.AsIChatClient(Deployment);
|
|
AIAgent agent = new ChatClientAgent(chatClient);
|
|
|
|
builder.Services.AddFoundryResponses(agent);
|
|
builder.Services.AddLogging();
|
|
|
|
this._app = builder.Build();
|
|
this._app.MapFoundryResponses();
|
|
|
|
await this._app.StartAsync();
|
|
|
|
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
|
?? throw new InvalidOperationException("TestServer not found");
|
|
|
|
this._inboundClient = testServer.CreateClient();
|
|
}
|
|
|
|
private static string InboundResponsesRequestJson() => """
|
|
{
|
|
"model": "fake-deployment",
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_1",
|
|
"status": "completed",
|
|
"role": "user",
|
|
"content": [{ "type": "input_text", "text": "Hello" }]
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
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}
|
|
}
|
|
""";
|
|
|
|
[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;
|
|
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.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 Uri, string UserAgent);
|
|
}
|