mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Bump MEAI to 10.5.1 and add Foundry per-call x-client header support (#5652)
* Bump MEAI to 10.5.1 and add per-call x-client header support
Replaces the brittle UserAgentResponsesClient subclass with a clean
per-call x-client-* header pipeline built on the new Microsoft.Extensions.AI
10.5.1 OpenAIRequestPolicies hook.
Public surface (Microsoft.Agents.AI.Foundry, [Experimental(MAAI001)]):
* chatOptions.WithClientHeader(name, value) and .WithClientHeaders(IEnumerable)
validate the x-client- prefix (case-insensitive), apply all-or-nothing on
bulk, and throw InvalidOperationException on foreign-typed slot collision
* myAgent.AsBuilder().UseClientHeaders().Build() opts a customer-built agent
into the pipeline; idempotent via agent.GetService<ClientHeadersAgent>()
* Foundry-built agents (FoundryAgent.Create*) pre-wire automatically
Internals:
* ClientHeadersAgent decorator snapshots the dict at scope-push time so
concurrent runs sharing a ChatOptions reference do not leak headers
* ClientHeadersScope is an AsyncLocal<IReadOnlyDictionary<string,string>?>
with LIFO push/dispose semantics
* ClientHeadersPolicy singleton stamps headers via Headers.Set so per-call
values overwrite any same-name header from earlier policies and so
duplicate registration is value-stable
* OpenAIRequestPoliciesReflection dedups against MEAI's private _entries
field and falls back to AddPolicy on any reflection failure; a CI test
asserts the field shape on every MEAI bump
Hosting cleanup:
* Deleted UserAgentResponsesClient and its dummy throwing pipeline
* HostedAgentUserAgentPolicy is now registered via OpenAIRequestPolicies
in FoundryHostingExtensions.TryApplyUserAgent
Tests:
* 19 new unit tests in ClientHeadersExtensionsTests.cs covering validation,
AsyncLocal isolation, snapshot semantics, end-to-end wire stamping, and
shared-chat-client dedup
* Updated OpenTelemetryAgentTests for MEAI 10.5.1 changes to web_search
serialization and the reduced tool definition payload when sensitive
data capture is disabled
Microsoft.Extensions.Compliance.Abstractions stays at 10.5.0 because no
10.5.1 release exists on nuget.org.
* Address PR review: pre-wire AsAIAgent path and dedup TryApplyUserAgent
* FoundryAgent: extract WireClientHeaders helper and call it from the
internal (AIProjectClient, ChatClientAgent) constructor used by
AzureAIProjectChatClientExtensions.AsAIAgent so those Foundry-built
agents also pre-wire the x-client header pipeline.
* Foundry.Hosting TryApplyUserAgent: dedup HostedAgentUserAgentPolicy
registration per OpenAIRequestPolicies instance via
ConditionalWeakTable so per-request resolution does not grow the
policy list unboundedly on singleton agents.
* Add tests covering AsAIAgent pre-wire and TryApplyUserAgent dedup
Backs the PR review fixes from a4c8f91 with regression tests:
* ClientHeadersExtensionsTests: AsAIAgent_FoundryAgent_HasPreWiredClientHeadersAgent
asserts the FoundryAgent built via AzureAIProjectChatClientExtensions.AsAIAgent
contains a ClientHeadersAgent in its delegating chain (catches future
regressions of the bypass).
* ClientHeadersExtensionsTests: FoundryAgent_PublicConstructor_HasPreWiredClientHeadersAgent
covers the public constructor path the same way.
* ClientHeadersExtensionsTests: UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
invokes UseClientHeaders 25 times on a shared chat client and asserts via
reflection that OpenAIRequestPolicies._entries length is exactly 1.
* HostedTryApplyUserAgentDedupTests: two tests asserting
FoundryHostingExtensions.TryApplyUserAgent stays at one entry per
OpenAIRequestPolicies instance after 50 calls on the same agent and across
distinct agents on different chat clients.
* Move tests next to their SUT
Removes the dedicated HostedTryApplyUserAgentDedupTests.cs test class.
Tests are co-located with the SUT they exercise:
* FoundryAgentTests.cs gains the Constructor_PreWiresClientHeadersAgent
and Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent
cases, since FoundryAgent is the SUT for the pre-wire behavior.
* HostedOutboundUserAgentTests.cs gains the two TryApplyUserAgent dedup
cases, since FoundryHostingExtensions.TryApplyUserAgent is the SUT
it already covers.
* ClientHeadersExtensionsTests.cs keeps only the
UseClientHeaders_RepeatedRegistrations_OnSameChatClient_OnlyRegistersOnce
case, which exercises the public ClientHeadersExtensions surface.
* Remove redundant WithCancellation on inner streaming call
ct is already passed to InnerAgent.RunStreamingAsync, so
.WithCancellation(ct) on the resulting IAsyncEnumerable is a no-op.
Caught by Sergey on PR review.
* Address PR review: surface downstream MEAI experimental ID
* Add AIOpenAIRequestPolicies = MEAIExperiments alias to
DiagnosticIds.Experiments (matches the existing AIResponseContinuations,
AIMcpServers, AIFunctionApprovals pattern).
* Mark public ClientHeadersExtensions with [Experimental(AIOpenAIRequestPolicies)]
instead of AgentsAIExperiments. Consumers now see the MEAI001 warning,
surfacing the dependency on MEAI's experimental OpenAIRequestPolicies hook.
* Mark internal OpenAIRequestPoliciesReflection with the same alias to
suppress warnings at the source rather than via project-wide NoWarn.
* Remove MEAI001 from Foundry csproj NoWarn (kept on Foundry.Hosting where
pre-PR usages remain).
* Clarify ClientHeadersScope XML doc: AsyncLocal flows values forward but
does NOT auto-restore on method return; explicit using/Dispose is what
gives stack-style LIFO semantics.
This commit is contained in:
+68
@@ -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;
|
||||
|
||||
-452
@@ -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()
|
||||
{
|
||||
|
||||
+1
@@ -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"]));
|
||||
|
||||
Reference in New Issue
Block a user