mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: dotnet: Add hosted-agent User-Agent supplement to outgoing requests (#5453)
* dotnet: Add hosted-agent User-Agent supplement to outgoing requests
When an agent runs inside a Foundry Hosted Agent, the outgoing
User-Agent header now includes 'agent-framework-hosted/{version}'
alongside the existing 'MEAI/{version}' segment.
- Add HostedAgentContext with AsyncLocal<string?> property
- MeaiUserAgentPolicy reads the supplement per-call
- AgentFrameworkResponseHandler sets/restores the context
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: update hosted UA format to foundry-hosting/agent-framework-dotnet/{version}
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Trying to get UA flowing, no luck yet.
* .NET: Polyfill MEAI OpenAIResponsesChatClient to add hosted-agent User-Agent supplement
When AgentFrameworkResponseHandler resolves an agent (i.e. we are running in a
hosted context), TryApplyUserAgent walks the agent's IChatClient decorator chain
to find MEAI's internal OpenAIResponsesChatClient and reflectively swaps its
inner _responseClient field with a DelegatingResponsesClient wrapper. The
wrapper overrides the public-virtual protocol methods to add a per-call
HostedAgentUserAgentPolicy to the RequestOptions and delegate to the inner
ResponsesClient. The OpenAI SDK's internal streaming overloads bottom out in
calls to the public-virtual non-streaming overloads via virtual dispatch on
this, so streaming is covered without overriding any non-virtual member.
The wrapper accepts any ResponsesClient-derived inner — both the Foundry
ProjectResponsesClient and the native OpenAI ResponsesClient — and preserves
the inner client's full pipeline (Transport, RetryPolicy, NetworkTimeout,
OrganizationId / ProjectId / UserAgentApplicationId, custom policies).
- Add DelegatingResponsesClient + HostedAgentUserAgentPolicy in Microsoft.Agents.AI.Foundry.Hosting.
- Add TryApplyUserAgent next to ApplyOpenTelemetry in FoundryHostingExtensions; wire it into AgentFrameworkResponseHandler.GetAgent for both keyed and default-agent paths.
- Drop earlier-iteration dead code: AddHostedAgentTelemetry extension, HostedUserAgentPolicy class, HostedAgentContext.cs, and the never-called ToRequestOptions helper.
- Revert RequestOptionsExtensions.MeaiUserAgentPolicy to MEAI-only (the supplement is now injected by the polyfill).
- Revert unrelated whitespace change in Agent_Step25_ToolboxServerSideTools sample.
- Tests cover streaming AND non-streaming, retry policy preservation, OrganizationId/ProjectId/UserAgentApplicationId pass-through, idempotency, native OpenAI ResponsesClient, and reflection guards for MEAI/OpenAI shape drift.
* .NET: Address review feedback on hosted-agent User-Agent polyfill
- TryApplyUserAgent: replace silent null-return with ArgumentNullException to match the codebase's convention.
- Add idempotency test (TryApplyUserAgent_CalledTwiceOnSameAgent_DoesNotDoubleWrap) — runs the polyfill twice on the same agent and asserts the wire UA contains exactly one foundry-hosting segment, proving the 'current is DelegatingResponsesClient' guard prevents nested wrapping.
- Add retry-double-append test (Polyfill_RetryWithinCall_DoesNotDuplicateSupplementInUserAgent) — exercises the HostedAgentUserAgentPolicy Contains-guard via a custom retry policy that re-runs the inner pipeline on the same message.
- Replace TryApplyUserAgent_NullAgent_ReturnsNullWithoutThrowing with TryApplyUserAgent_NullAgent_ThrowsArgumentNullException to match the new contract.
* .NET: Drop null check from TryApplyUserAgent and its now-redundant test
The two call sites in AgentFrameworkResponseHandler.GetAgent already null-check the agent before invoking TryApplyUserAgent, so the defensive ArgumentNullException is unreachable. Remove it and the corresponding test.
* .NET: Remove unused Microsoft.Shared.Diagnostics import in ServiceCollectionExtensions
The Throw.IfNull helper from this namespace was used by the now-removed null check in TryApplyUserAgent. Drop the unused import to satisfy IDE0005 in CI's full-project dotnet format run.
---------
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
6853f64de8
commit
6cd81286a9
@@ -297,6 +297,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var agent = this._serviceProvider.GetKeyedService<AIAgent>(agentName);
|
||||
if (agent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(agent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(agent);
|
||||
}
|
||||
|
||||
@@ -310,12 +311,13 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var defaultAgent = this._serviceProvider.GetService<AIAgent>();
|
||||
if (defaultAgent is not null)
|
||||
{
|
||||
FoundryHostingExtensions.TryApplyUserAgent(defaultAgent);
|
||||
return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent);
|
||||
}
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent.";
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent) or services.AddKeyedSingleton<AIAgent>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
@@ -352,7 +354,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
|
||||
var errorMessage = string.IsNullOrEmpty(agentName)
|
||||
? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered."
|
||||
: $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore.";
|
||||
: $"AgentSessionStore for agent '{agentName}' not found. Ensure it is registered via AddFoundryResponses(services, agent, agentSessionStore) or services.AddKeyedSingleton<AgentSessionStore>(\"{agentName}\", ...).";
|
||||
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 DelegatingResponsesClient : ResponsesClient
|
||||
{
|
||||
private readonly ResponsesClient _inner;
|
||||
|
||||
public DelegatingResponsesClient(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 =
|
||||
"DelegatingResponsesClient transport invoked bypassed the override-and-delegate design. This exception should be unreachable and should never be thrown following the correct usage of DelegatingResponsesClient.";
|
||||
|
||||
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,84 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Pipeline policy that appends the hosted-agent <c>User-Agent</c> segment
|
||||
/// (e.g. <c>"foundry-hosting/agent-framework-dotnet/{version}"</c>) to outgoing requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The supplement value is computed once from the Microsoft.Agents.AI.Foundry.Hosting
|
||||
/// assembly's informational version. The policy is idempotent on retries: if the segment
|
||||
/// 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="DelegatingResponsesClient"/> when invoking the wrapped
|
||||
/// <see cref="OpenAI.Responses.ResponsesClient"/>. It is only registered when an agent is
|
||||
/// resolved by the Foundry hosting layer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class HostedAgentUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
public static HostedAgentUserAgentPolicy Instance { get; } = new HostedAgentUserAgentPolicy();
|
||||
|
||||
private static readonly string s_supplementValue = CreateSupplementValue();
|
||||
|
||||
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
|
||||
{
|
||||
AppendHeader(message);
|
||||
await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AppendHeader(PipelineMessage message)
|
||||
{
|
||||
if (message.Request.Headers.TryGetValue("User-Agent", out var existing) && !string.IsNullOrEmpty(existing))
|
||||
{
|
||||
// Guard against double-append on retries or when the policy
|
||||
// is registered on multiple pipeline positions.
|
||||
if (existing.Contains(s_supplementValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
message.Request.Headers.Set("User-Agent", $"{existing} {s_supplementValue}");
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Request.Headers.Set("User-Agent", s_supplementValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateSupplementValue()
|
||||
{
|
||||
const string Name = "foundry-hosting/agent-framework-dotnet";
|
||||
|
||||
if (typeof(HostedAgentUserAgentPolicy).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,15 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
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;
|
||||
|
||||
@@ -36,7 +35,7 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Example:
|
||||
/// <code>
|
||||
/// builder.AddAIAgent("my-agent", ...);
|
||||
/// builder.Services.AddKeyedSingleton<AIAgent>("my-agent", myAgent);
|
||||
/// builder.Services.AddFoundryResponses();
|
||||
///
|
||||
/// var app = builder.Build();
|
||||
@@ -181,13 +180,6 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
|
||||
if (endpoints is IApplicationBuilder app)
|
||||
{
|
||||
// Ensure the middleware is added to the pipeline
|
||||
app.UseMiddleware<AgentFrameworkUserAgentMiddleware>();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -216,46 +208,85 @@ public static class FoundryHostingExtensions
|
||||
.Build();
|
||||
}
|
||||
|
||||
private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next)
|
||||
/// <summary>
|
||||
/// Attempts to wrap the agent's underlying <see cref="ResponsesClient"/>
|
||||
/// with a <see cref="DelegatingResponsesClient"/> so every outgoing Responses-API request
|
||||
/// carries the hosted-agent <c>User-Agent</c> segment.
|
||||
/// </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="DelegatingResponsesClient"/>.</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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static AIAgent TryApplyUserAgent(AIAgent agent)
|
||||
{
|
||||
private static readonly string s_userAgentValue = CreateUserAgentValue();
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
if (chatClient is null)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
var userAgent = headers.UserAgent.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
headers.UserAgent = s_userAgentValue;
|
||||
}
|
||||
else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
headers.UserAgent = $"{userAgent} {s_userAgentValue}";
|
||||
}
|
||||
|
||||
await next(context).ConfigureAwait(false);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private static string CreateUserAgentValue()
|
||||
var meaiType = s_meaiResponsesChatClientType;
|
||||
if (meaiType is null)
|
||||
{
|
||||
const string Name = "agent-framework-dotnet";
|
||||
|
||||
if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion is string version)
|
||||
{
|
||||
int pos = version.IndexOf('+');
|
||||
if (pos >= 0)
|
||||
{
|
||||
version = version.Substring(0, pos);
|
||||
}
|
||||
|
||||
if (version.Length > 0)
|
||||
{
|
||||
return $"{Name}/{version}";
|
||||
}
|
||||
}
|
||||
|
||||
return Name;
|
||||
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 DelegatingResponsesClient)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
|
||||
field.SetValue(meaiInstance, new DelegatingResponsesClient(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);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
@@ -13,20 +12,6 @@ internal static class RequestOptionsExtensions
|
||||
/// <summary>Gets the singleton <see cref="PipelinePolicy"/> that adds a MEAI user-agent header.</summary>
|
||||
internal static PipelinePolicy UserAgentPolicy => MeaiUserAgentPolicy.Instance;
|
||||
|
||||
/// <summary>Creates a <see cref="RequestOptions"/> configured for use with Foundry Agents.</summary>
|
||||
public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming)
|
||||
{
|
||||
RequestOptions requestOptions = new()
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
BufferResponse = !streaming
|
||||
};
|
||||
|
||||
requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall);
|
||||
|
||||
return requestOptions;
|
||||
}
|
||||
|
||||
/// <summary>Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header.</summary>
|
||||
private sealed class MeaiUserAgentPolicy : PipelinePolicy
|
||||
{
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
// 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.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DelegatingResponsesClient"/> 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 DelegatingResponsesClientTests
|
||||
{
|
||||
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 DelegatingResponsesClient(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 DelegatingResponsesClient(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
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.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#pragma warning disable OPENAI001, SCME0001, SCME0002, MEAI001
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <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}
|
||||
}
|
||||
""";
|
||||
|
||||
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);
|
||||
}
|
||||
+43
@@ -4,8 +4,10 @@ using System;
|
||||
using System.Linq;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
@@ -93,4 +95,45 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
Assert.Same(instrumented, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithoutChatClient_NoOp()
|
||||
{
|
||||
// Arrange: agent.GetService<IChatClient>() returns null.
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryApplyUserAgent_AgentWithNonMeaiChatClient_NoOp()
|
||||
{
|
||||
// Arrange: chat client that does not return MEAI's OpenAIResponsesChatClient via GetService.
|
||||
var mockChatClient = new Mock<IChatClient>();
|
||||
mockChatClient.Setup(c => c.GetService(It.IsAny<Type>(), It.IsAny<object?>())).Returns(null!);
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.Setup(a => a.GetService(typeof(IChatClient), It.IsAny<object?>())).Returns(mockChatClient.Object);
|
||||
|
||||
// Act
|
||||
var result = FoundryHostingExtensions.TryApplyUserAgent(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockAgent.Object, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiOpenAIResponsesChatClient_TypeFullName_ReflectionGuard()
|
||||
{
|
||||
// Guards the polyfill's reflection target type-name.
|
||||
var meaiType = typeof(MicrosoftExtensionsAIResponsesExtensions).Assembly
|
||||
.GetType("Microsoft.Extensions.AI.OpenAIResponsesChatClient");
|
||||
Assert.NotNull(meaiType);
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
}
|
||||
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>AgentFrameworkUserAgentMiddleware</c> registered by
|
||||
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>.
|
||||
/// </summary>
|
||||
public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable
|
||||
{
|
||||
private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?";
|
||||
|
||||
private WebApplication? _app;
|
||||
private HttpClient? _httpClient;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this._httpClient?.Dispose();
|
||||
if (this._app != null)
|
||||
{
|
||||
await this._app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("MyApp/1.0", userAgent);
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
// First request to capture the actual middleware-generated value
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
var firstResponse = await this._httpClient!.SendAsync(firstRequest);
|
||||
var middlewareValue = await firstResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Act: send a second request that already contains the middleware value
|
||||
using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}");
|
||||
var secondResponse = await this._httpClient!.SendAsync(secondRequest);
|
||||
var userAgent = await secondResponse.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should remain unchanged (no duplication)
|
||||
Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent);
|
||||
Assert.Single(VersionedUserAgentRegex().Matches(userAgent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync()
|
||||
{
|
||||
// Arrange
|
||||
await this.CreateTestServerAsync();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua");
|
||||
|
||||
// Act
|
||||
var response = await this._httpClient!.SendAsync(request);
|
||||
var userAgent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Assert: should match "agent-framework-dotnet/x.y.z" pattern
|
||||
Assert.Matches(VersionedUserAgentPattern, userAgent);
|
||||
}
|
||||
|
||||
private async Task CreateTestServerAsync()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
this._app = builder.Build();
|
||||
this._app.MapFoundryResponses();
|
||||
|
||||
// Test endpoint that echoes the User-Agent header after middleware processing
|
||||
this._app.MapGet("/test-ua", (HttpContext ctx) =>
|
||||
Results.Text(ctx.Request.Headers.UserAgent.ToString()));
|
||||
|
||||
await this._app.StartAsync();
|
||||
|
||||
var testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
|
||||
this._httpClient = testServer.CreateClient();
|
||||
}
|
||||
|
||||
[GeneratedRegex(VersionedUserAgentPattern)]
|
||||
private static partial Regex VersionedUserAgentRegex();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-call <c>MeaiUserAgentPolicy</c> exposed via
|
||||
/// <see cref="RequestOptionsExtensions.UserAgentPolicy"/>. The policy is reachable through the
|
||||
/// public <see cref="FoundryAgent"/> constructors (which add it to the internally-built
|
||||
/// <see cref="Azure.AI.Projects.AIProjectClient"/>'s pipeline), so its behavior is part of the
|
||||
/// public API surface.
|
||||
/// </summary>
|
||||
public sealed class RequestOptionsExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_AddsMeaiSegment_ToOutgoingRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, handler.Count);
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.Contains("MEAI/", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeaiUserAgentPolicy_DoesNotAddFoundryHostingSegmentAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var handler = new RecordingHandler();
|
||||
#pragma warning disable CA5399
|
||||
using var httpClient = new HttpClient(handler);
|
||||
#pragma warning restore CA5399
|
||||
var pipeline = ClientPipeline.Create(
|
||||
new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(httpClient) },
|
||||
perCallPolicies: [RequestOptionsExtensions.UserAgentPolicy],
|
||||
perTryPolicies: default,
|
||||
beforeTransportPolicies: default);
|
||||
|
||||
// Act
|
||||
var message = pipeline.CreateMessage();
|
||||
message.Request.Method = "POST";
|
||||
message.Request.Uri = new System.Uri("https://example.test/anything");
|
||||
await pipeline.SendAsync(message);
|
||||
|
||||
// Assert: the policy is MEAI-only; the foundry-hosting supplement is added elsewhere
|
||||
// (by the polyfill DelegatingResponsesClient → HostedAgentUserAgentPolicy).
|
||||
Assert.NotNull(handler.LastUserAgent);
|
||||
Assert.DoesNotContain("foundry-hosting/agent-framework-dotnet", handler.LastUserAgent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserAgentPolicy_ExposesSingletonInstance()
|
||||
{
|
||||
// Two reads of the static property must return the same instance — the policy is stateless and shared.
|
||||
var first = RequestOptionsExtensions.UserAgentPolicy;
|
||||
var second = RequestOptionsExtensions.UserAgentPolicy;
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeaiUserAgentPolicy_ValueIncludesAFFoundryAssemblyVersion_ReflectionGuard()
|
||||
{
|
||||
// The policy emits "MEAI/{Microsoft.Agents.AI.Foundry assembly InformationalVersion}".
|
||||
// If the assembly metadata stops being readable, the policy falls back to "MEAI" without a version,
|
||||
// which is a measurable telemetry regression.
|
||||
var attr = typeof(RequestOptionsExtensions).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
Assert.False(string.IsNullOrEmpty(attr!.InformationalVersion));
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpClientHandler
|
||||
{
|
||||
public int Count { get; private set; }
|
||||
public string? LastUserAgent { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.Count++;
|
||||
this.LastUserAgent = request.Headers.TryGetValues("User-Agent", out var values)
|
||||
? string.Join(",", values)
|
||||
: null;
|
||||
|
||||
var resp = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
||||
RequestMessage = request,
|
||||
};
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user