From af90e251fbf618597fdc0f480f1100263cf0e312 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 20 May 2026 21:50:28 +0100 Subject: [PATCH 1/5] .NET: Surface x-ms-served-model header as ChatResponse.ModelId for Foundry agents Mirrors Python PR #5910. Adds an internal SCM PipelinePolicy that reads the x-ms-served-model HTTP response header on Azure OpenAI Responses calls and writes it into an AsyncLocal box. A DelegatingChatClient sits between OpenTelemetry and the MEAI OpenAIResponsesChatClient and overwrites ChatResponse.ModelId with the served snapshot so OTel spans report the actual model rather than the deployment alias. Wired through all AsAIAgent paths in Microsoft.Agents.AI.Foundry. --- .../ServedModelChatClient.cs | 70 +++ .../ServedModelPolicy.cs | 67 +++ .../ServedModelScope.cs | 35 ++ .../ResponsesAgentServedModelTests.cs | 69 +++ ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 1 + .../ServedModelTests.cs | 420 ++++++++++++++++++ 6 files changed, 662 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs create mode 100644 dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs new file mode 100644 index 0000000000..367df4725a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Delegating chat client that overwrites and +/// with the actual served model name captured by +/// from the x-ms-served-model response header. +/// +/// +/// +/// Before each inner call, this client pushes a fresh onto +/// so the (running inside the +/// SCM pipeline) can write the header value into it. After the inner call returns, the client +/// reads the box and overwrites . When the box is empty +/// (header absent on non-Azure endpoints), the original model name is preserved unchanged. +/// +/// +internal sealed class ServedModelChatClient : DelegatingChatClient +{ + public ServedModelChatClient(IChatClient innerClient) + : base(innerClient) + { + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var box = new StrongBox(null); + ServedModelScope.Current = box; + + var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + + if (box.Value is { } servedModel) + { + response.ModelId = servedModel; + } + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var box = new StrongBox(null); + ServedModelScope.Current = box; + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + if (box.Value is { } servedModel) + { + update.ModelId = servedModel; + } + + yield return update; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs new file mode 100644 index 0000000000..0da9d6ac50 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// Pipeline policy that captures the x-ms-served-model response header from Azure OpenAI +/// and stores it in for consumption by . +/// +/// +/// +/// Azure OpenAI Responses API returns the deployment alias in response.model but the actual +/// model snapshot (e.g. gpt-5-nano-2025-08-07) in the x-ms-served-model response header. +/// This policy extracts the header after the HTTP roundtrip so the +/// can overwrite ChatResponse.ModelId with the true model name. +/// +/// +/// Registered once per OpenAIRequestPolicies instance via the MEAI 10.5.1 extension hook. +/// When the header is absent (non-Azure endpoints), the scope is not set and the downstream +/// preserves the original model name. +/// +/// +internal sealed class ServedModelPolicy : PipelinePolicy +{ + /// The Azure OpenAI response header that carries the actual served model name. + internal const string ServedModelHeader = "x-ms-served-model"; + + public static ServedModelPolicy Instance { get; } = new ServedModelPolicy(); + + private ServedModelPolicy() + { + } + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + ProcessNext(message, pipeline, currentIndex); + CaptureServedModel(message); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + CaptureServedModel(message); + } + + private static void CaptureServedModel(PipelineMessage message) + { + if (message.Response is null) + { + return; + } + + if (message.Response.Headers.TryGetValue(ServedModelHeader, out string? servedModel) + && !string.IsNullOrWhiteSpace(servedModel)) + { + // Write into the box (reference-type mutation) so the value is visible to the + // ServedModelChatClient that pushed the box before calling the inner client. + if (ServedModelScope.Current is { } box) + { + box.Value = servedModel.Trim(); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs new file mode 100644 index 0000000000..936a88dedf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// AsyncLocal carrier that bridges the x-ms-served-model response header value from the +/// running inside the SCM transport pipeline up to the +/// decorator. +/// +/// +/// +/// Because mutations inside a child async method do not propagate +/// back to the caller (copy-on-write semantics), this scope uses as an +/// indirection layer. The pushes a fresh box onto the scope +/// before calling the inner client; the writes into the box's +/// (a reference-type mutation visible to anyone holding the same box). +/// After the inner call returns, the client reads the box's value. +/// +/// +internal static class ServedModelScope +{ + private static readonly AsyncLocal?> s_current = new(); + + /// + /// Gets or sets the per-async-flow served model box. + /// + public static StrongBox? Current + { + get => s_current.Value; + set => s_current.Value = value; + } +} diff --git a/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs new file mode 100644 index 0000000000..aa79db73d8 --- /dev/null +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Projects; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace Foundry.IntegrationTests; + +/// +/// Integration tests validating that the x-ms-served-model response header +/// returned by the Azure OpenAI Responses API is surfaced on . +/// +public class ResponsesAgentServedModelTests +{ + private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); + + private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName); + + private readonly AIProjectClient _client = new(Endpoint, TestAzureCliCredentials.CreateAzureCliCredential()); + + [Fact] + public async Task GetResponseAsync_ReturnsServedModelSnapshotOnModelIdAsync() + { + // Arrange + ChatClientAgent agent = this._client.AsAIAgent( + model: DeploymentName, + instructions: "You are a helpful assistant. Reply with a single short word.", + name: "ServedModelTest"); + + IChatClient chatClient = agent.ChatClient; + + // Act + ChatResponse response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Say hi.")], + new ChatOptions { ModelId = DeploymentName }); + + // Assert + Assert.NotNull(response.ModelId); + Assert.False(string.IsNullOrWhiteSpace(response.ModelId)); + + // The served model is a dated snapshot (e.g. "gpt-5-nano-2025-08-07") that differs + // from the deployment alias. We assert it is not equal to the alias to confirm the + // x-ms-served-model header was picked up by the policy and propagated to ModelId. + Assert.NotEqual(DeploymentName, response.ModelId); + } + + [Fact] + public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync() + { + // Arrange + ChatClientAgent agent = this._client.AsAIAgent( + model: DeploymentName, + instructions: "You are a helpful assistant. Reply with a single short word.", + name: "ServedModelTestRun"); + + // Act + AgentResponse agentResponse = await agent.RunAsync("Say hi."); + + // Assert + ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse; + Assert.NotNull(chatResponse); + Assert.False(string.IsNullOrWhiteSpace(chatResponse!.ModelId)); + Assert.NotEqual(DeploymentName, chatResponse.ModelId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index b17efa64f9..1d45326559 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs new file mode 100644 index 0000000000..d3fda27450 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs @@ -0,0 +1,420 @@ +// 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.Runtime.CompilerServices; +using System.Text; +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; + +/// +/// Tests for the x-ms-served-model response header pipeline: +/// AsyncLocal carrier, +/// pipeline policy, +/// and delegating client. +/// +public sealed class ServedModelTests +{ + // =========================================================================================== + // ServedModelScope tests + // =========================================================================================== + + [Fact] + public void Scope_DefaultIsNull() + { + Assert.Null(ServedModelScope.Current); + } + + [Fact] + public void Scope_SetAndGet_ReturnsBox() + { + var previous = ServedModelScope.Current; + try + { + var box = new StrongBox("gpt-5-nano-2025-08-07"); + ServedModelScope.Current = box; + Assert.Same(box, ServedModelScope.Current); + Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value); + } + finally + { + ServedModelScope.Current = previous; + } + } + + // =========================================================================================== + // ServedModelPolicy tests (via real SCM pipeline + mock HTTP handler) + // =========================================================================================== + + [Fact] + public void Policy_IsSingleton() + { + Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance); + } + + [Fact] + public async Task Policy_HeaderPresent_SetsScopeAsync() + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act: drive a request through the pipeline. The policy fires during the HTTP roundtrip. + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: the scope was populated during the call, but we need a way to observe it. + // The end-to-end test validates this via ServedModelChatClient. Here we confirm the + // scope is set by wrapping with ServedModelChatClient and checking the result. + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task Policy_HeaderAbsent_ScopeRemainsNull_ModelIdUnchangedAsync() + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: null); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: ModelId is the deployment alias from the JSON body ("fake"). + Assert.Equal("fake", response.ModelId); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Policy_EmptyOrWhitespaceHeader_ModelIdUnchangedAsync(string headerValue) + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: headerValue); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake". + Assert.Equal("fake", response.ModelId); + } + + [Fact] + public async Task Policy_HeaderWithWhitespace_TrimsValueAsync() + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 "); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: the whitespace is trimmed. + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + // =========================================================================================== + // ServedModelChatClient tests (non-streaming) + // =========================================================================================== + + [Fact] + public async Task GetResponseAsync_PolicySetsBox_OverwritesModelIdAsync() + { + // Arrange: fake inner client that simulates the policy writing into the box during the call. + var inner = new FakeChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07"); + var client = new ServedModelChatClient(inner); + + // Act + var response = await client.GetResponseAsync([]); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task GetResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() + { + // Arrange: fake inner client that does NOT write to the box (simulates absent header). + var inner = new FakeChatClient("deployment-alias"); + var client = new ServedModelChatClient(inner); + + // Act + var response = await client.GetResponseAsync([]); + + // Assert + Assert.Equal("deployment-alias", response.ModelId); + } + + // =========================================================================================== + // ServedModelChatClient tests (streaming) + // =========================================================================================== + + [Fact] + public async Task GetStreamingResponseAsync_PolicySetsBox_OverwritesModelIdOnAllUpdatesAsync() + { + // Arrange: fake inner client that simulates the policy writing into the box. + var inner = new FakeStreamingChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07", updateCount: 3); + var client = new ServedModelChatClient(inner); + + // Act + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([])) + { + updates.Add(update); + } + + // Assert + Assert.Equal(3, updates.Count); + Assert.All(updates, u => Assert.Equal("gpt-5-nano-2025-08-07", u.ModelId)); + } + + [Fact] + public async Task GetStreamingResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() + { + // Arrange: fake inner client that does NOT write to the box. + var inner = new FakeStreamingChatClient("deployment-alias", updateCount: 2); + var client = new ServedModelChatClient(inner); + + // Act + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([])) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + Assert.All(updates, u => Assert.Equal("deployment-alias", u.ModelId)); + } + + // =========================================================================================== + // End-to-end tests (policy + client together via real OpenAI SCM pipeline) + // =========================================================================================== + + [Fact] + public async Task EndToEnd_PolicyAndClient_ModelIdReflectsServedModelAsync() + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task EndToEnd_PolicyAndClient_NoHeader_ModelIdUnchangedAsync() + { + // Arrange + using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: null); + IChatClient chatClient = CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("fake", response.ModelId); + } + + // =========================================================================================== + // Helpers + // =========================================================================================== + + 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} + } + """; + + /// + /// Creates a chat client backed by a real OpenAI ResponsesClient with the + /// registered and wrapped by . + /// + private static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler) + { +#pragma warning disable CA5399 + 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(); + chatClient = FoundryAgent.WireServedModel(chatClient); + + return chatClient; + } + + /// + /// An that returns a fixed response body and optionally + /// includes the x-ms-served-model response header. + /// + private sealed class ServedModelHandler : HttpClientHandler + { + private readonly string _body; + private readonly string? _servedModel; + + public ServedModelHandler(string body, string? servedModel) + { + this._body = body; + this._servedModel = servedModel; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + + if (this._servedModel is not null) + { + resp.Headers.Add("x-ms-served-model", this._servedModel); + } + + return Task.FromResult(resp); + } + } + + /// + /// A minimal that returns a with the given model ID. + /// + private sealed class FakeChatClient : IChatClient + { + private readonly string _modelId; + + public FakeChatClient(string modelId) + { + this._modelId = modelId; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A minimal that yields s with a given model ID. + /// + private sealed class FakeStreamingChatClient : IChatClient + { + private readonly string _modelId; + private readonly int _updateCount; + + public FakeStreamingChatClient(string modelId, int updateCount) + { + this._modelId = modelId; + this._updateCount = updateCount; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + for (int i = 0; i < this._updateCount; i++) + { + await Task.Yield(); + yield return new ChatResponseUpdate { ModelId = this._modelId }; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A fake that simulates the by writing + /// into the box during . + /// + private sealed class FakeChatClientWithPolicySimulation : IChatClient + { + private readonly string _modelId; + private readonly string _servedModel; + + public FakeChatClientWithPolicySimulation(string modelId, string servedModel) + { + this._modelId = modelId; + this._servedModel = servedModel; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + // Simulate what ServedModelPolicy does: write into the box. + if (ServedModelScope.Current is { } box) + { + box.Value = this._servedModel; + } + + return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A fake streaming that simulates the + /// by writing into the box before yielding updates. + /// + private sealed class FakeStreamingChatClientWithPolicySimulation : IChatClient + { + private readonly string _modelId; + private readonly string _servedModel; + private readonly int _updateCount; + + public FakeStreamingChatClientWithPolicySimulation(string modelId, string servedModel, int updateCount) + { + this._modelId = modelId; + this._servedModel = servedModel; + this._updateCount = updateCount; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Simulate what ServedModelPolicy does on the initial HTTP response. + if (ServedModelScope.Current is { } box) + { + box.Value = this._servedModel; + } + + for (int i = 0; i < this._updateCount; i++) + { + await Task.Yield(); + yield return new ChatResponseUpdate { ModelId = this._modelId }; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} From 6de5be97859964e5b51c4841b4f8965b5f80293c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 20 May 2026 22:00:46 +0100 Subject: [PATCH 2/5] .NET: Fix line endings and BOM on ResponsesAgentServedModelTests --- .../Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs index aa79db73d8..8240de437d 100644 --- a/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Threading.Tasks; From 29d39c2dc2cb9b58891bd0a02f25d8defcac321a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 20 May 2026 22:08:52 +0100 Subject: [PATCH 3/5] .NET: Address Copilot review on Foundry served-model PR - Restore previous ServedModelScope in finally to avoid AsyncLocal leak into caller execution context. - Make served-model integration test assertion robust to deployment names that already match the snapshot pattern. - Broaden UnitTests csproj comment to cover all conditional removals (net8.0+ requirement). --- .../ServedModelChatClient.cs | 36 +++++++++++++------ .../ResponsesAgentServedModelTests.cs | 34 +++++++++++++----- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 2 +- 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs index 367df4725a..8db45f2bb2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs @@ -36,16 +36,24 @@ public override async Task GetResponseAsync( CancellationToken cancellationToken = default) { var box = new StrongBox(null); + var previous = ServedModelScope.Current; ServedModelScope.Current = box; - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + try + { + var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + + if (box.Value is { } servedModel) + { + response.ModelId = servedModel; + } - if (box.Value is { } servedModel) + return response; + } + finally { - response.ModelId = servedModel; + ServedModelScope.Current = previous; } - - return response; } /// @@ -55,16 +63,24 @@ public override async IAsyncEnumerable GetStreamingResponseA [EnumeratorCancellation] CancellationToken cancellationToken = default) { var box = new StrongBox(null); + var previous = ServedModelScope.Current; ServedModelScope.Current = box; - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + try { - if (box.Value is { } servedModel) + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) { - update.ModelId = servedModel; - } + if (box.Value is { } servedModel) + { + update.ModelId = servedModel; + } - yield return update; + yield return update; + } + } + finally + { + ServedModelScope.Current = previous; } } } diff --git a/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs index 8240de437d..97e0fd671d 100644 --- a/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs +++ b/dotnet/tests/Foundry.IntegrationTests/ResponsesAgentServedModelTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.RegularExpressions; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; using Azure.AI.Projects; @@ -16,6 +17,9 @@ namespace Foundry.IntegrationTests; /// public class ResponsesAgentServedModelTests { + // Matches a dated served-model snapshot, e.g. "gpt-5-nano-2025-08-07". + private static readonly Regex s_snapshotRegex = new(@"-\d{4}-\d{2}-\d{2}$", RegexOptions.Compiled); + private static Uri Endpoint => new(TestConfiguration.GetRequiredValue(TestSettings.AzureAIProjectEndpoint)); private static string DeploymentName => TestConfiguration.GetRequiredValue(TestSettings.AzureAIModelDeploymentName); @@ -39,13 +43,7 @@ [new ChatMessage(ChatRole.User, "Say hi.")], new ChatOptions { ModelId = DeploymentName }); // Assert - Assert.NotNull(response.ModelId); - Assert.False(string.IsNullOrWhiteSpace(response.ModelId)); - - // The served model is a dated snapshot (e.g. "gpt-5-nano-2025-08-07") that differs - // from the deployment alias. We assert it is not equal to the alias to confirm the - // x-ms-served-model header was picked up by the policy and propagated to ModelId. - Assert.NotEqual(DeploymentName, response.ModelId); + AssertServedModel(response.ModelId); } [Fact] @@ -63,7 +61,25 @@ public async Task RunAsync_AgentResponseRawRepresentationCarriesServedModelAsync // Assert ChatResponse? chatResponse = agentResponse.RawRepresentation as ChatResponse; Assert.NotNull(chatResponse); - Assert.False(string.IsNullOrWhiteSpace(chatResponse!.ModelId)); - Assert.NotEqual(DeploymentName, chatResponse.ModelId); + AssertServedModel(chatResponse!.ModelId); + } + + private static void AssertServedModel(string? modelId) + { + Assert.False(string.IsNullOrWhiteSpace(modelId), "ChatResponse.ModelId must be populated."); + + // Primary invariant: the served-model value must look like a dated snapshot + // (e.g. "gpt-5-nano-2025-08-07"). This is what the x-ms-served-model header carries. + // Only when the configured deployment name itself already matches the snapshot pattern + // do we fall back to permitting equality with the deployment alias. + bool aliasIsSnapshot = s_snapshotRegex.IsMatch(DeploymentName); + + if (aliasIsSnapshot) + { + return; + } + + Assert.Matches(s_snapshotRegex, modelId!); + Assert.NotEqual(DeploymentName, modelId); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 1d45326559..400aa316db 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -14,7 +14,7 @@ - + From 64350e609331577d50c89f0d1e8c7e16af7fb236 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 20 May 2026 22:15:39 +0100 Subject: [PATCH 4/5] .NET: Split ServedModelTests into per-SUT files with regions Split the combined ServedModelTests.cs into one test class per SUT: - ServedModelScopeTests.cs (AsyncLocal carrier) - ServedModelPolicyTests.cs (SCM pipeline policy) - ServedModelChatClientTests.cs (delegating client, with regions for Non-streaming / Streaming / End-to-end) Shared helpers and fake clients moved into ServedModelTestHelpers.cs. Csproj net8.0+ exclusion list updated accordingly. --- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 5 +- .../ServedModelChatClientTests.cs | 124 ++++++ .../ServedModelPolicyTests.cs | 84 ++++ .../ServedModelScopeTests.cs | 40 ++ .../ServedModelTestHelpers.cs | 212 +++++++++ .../ServedModelTests.cs | 420 ------------------ 6 files changed, 464 insertions(+), 421 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelPolicyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelScopeTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 400aa316db..7b43c32f64 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -19,7 +19,10 @@ - + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs new file mode 100644 index 0000000000..d4f029324f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for : the +/// that pushes a fresh box onto before each inner call and +/// overwrites / +/// with the value captured by . +/// +public sealed class ServedModelChatClientTests +{ + #region Non-streaming + + [Fact] + public async Task GetResponseAsync_PolicySetsBox_OverwritesModelIdAsync() + { + // Arrange: fake inner client that simulates the policy writing into the box during the call. + var inner = new ServedModelTestHelpers.FakeChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07"); + var client = new ServedModelChatClient(inner); + + // Act + var response = await client.GetResponseAsync([]); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task GetResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() + { + // Arrange: fake inner client that does NOT write to the box (simulates absent header). + var inner = new ServedModelTestHelpers.FakeChatClient("deployment-alias"); + var client = new ServedModelChatClient(inner); + + // Act + var response = await client.GetResponseAsync([]); + + // Assert + Assert.Equal("deployment-alias", response.ModelId); + } + + #endregion + + #region Streaming + + [Fact] + public async Task GetStreamingResponseAsync_PolicySetsBox_OverwritesModelIdOnAllUpdatesAsync() + { + // Arrange: fake inner client that simulates the policy writing into the box. + var inner = new ServedModelTestHelpers.FakeStreamingChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07", updateCount: 3); + var client = new ServedModelChatClient(inner); + + // Act + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([])) + { + updates.Add(update); + } + + // Assert + Assert.Equal(3, updates.Count); + Assert.All(updates, u => Assert.Equal("gpt-5-nano-2025-08-07", u.ModelId)); + } + + [Fact] + public async Task GetStreamingResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() + { + // Arrange: fake inner client that does NOT write to the box. + var inner = new ServedModelTestHelpers.FakeStreamingChatClient("deployment-alias", updateCount: 2); + var client = new ServedModelChatClient(inner); + + // Act + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([])) + { + updates.Add(update); + } + + // Assert + Assert.Equal(2, updates.Count); + Assert.All(updates, u => Assert.Equal("deployment-alias", u.ModelId)); + } + + #endregion + + #region End-to-end (policy + client together via real OpenAI SCM pipeline) + + [Fact] + public async Task EndToEnd_PolicyAndClient_ModelIdReflectsServedModelAsync() + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task EndToEnd_PolicyAndClient_NoHeader_ModelIdUnchangedAsync() + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("fake", response.ModelId); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelPolicyTests.cs new file mode 100644 index 0000000000..09c51d1843 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelPolicyTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for : the SCM pipeline policy that reads the +/// x-ms-served-model response header and writes it into the active +/// box. +/// +/// +/// Tests drive the policy through a real OpenAI ResponsesClient SCM pipeline against a mock +/// HTTP handler so the policy executes in its production configuration. +/// +public sealed class ServedModelPolicyTests +{ + [Fact] + public void Instance_IsSingleton() + { + Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance); + } + + [Fact] + public async Task ProcessAsync_HeaderPresent_SetsModelIdOnResponseAsync() + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } + + [Fact] + public async Task ProcessAsync_HeaderAbsent_PreservesModelIdFromBodyAsync() + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: ModelId is the deployment alias from the JSON body ("fake"). + Assert.Equal("fake", response.ModelId); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task ProcessAsync_EmptyOrWhitespaceHeader_PreservesModelIdFromBodyAsync(string headerValue) + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: headerValue); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake". + Assert.Equal("fake", response.ModelId); + } + + [Fact] + public async Task ProcessAsync_HeaderWithSurroundingWhitespace_TrimsValueAsync() + { + // Arrange + using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 "); + IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); + + // Act + var response = await chatClient.GetResponseAsync("hi"); + + // Assert + Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelScopeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelScopeTests.cs new file mode 100644 index 0000000000..7dcaa445c5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelScopeTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for : the AsyncLocal carrier that bridges the +/// served-model value from the SCM pipeline policy up to the delegating chat client. +/// +public sealed class ServedModelScopeTests +{ + [Fact] + public void Current_DefaultIsNull() + { + Assert.Null(ServedModelScope.Current); + } + + [Fact] + public void Current_SetAndGet_ReturnsBox() + { + // Arrange + var previous = ServedModelScope.Current; + + try + { + // Act + var box = new StrongBox("gpt-5-nano-2025-08-07"); + ServedModelScope.Current = box; + + // Assert + Assert.Same(box, ServedModelScope.Current); + Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value); + } + finally + { + ServedModelScope.Current = previous; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs new file mode 100644 index 0000000000..c022668a73 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs @@ -0,0 +1,212 @@ +// 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.Runtime.CompilerServices; +using System.Text; +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; + +/// +/// Shared helpers and fake clients used by the served-model test suite +/// (, , +/// ). +/// +internal static class ServedModelTestHelpers +{ + public 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} + } + """; + + /// + /// Creates a chat client backed by a real OpenAI ResponsesClient with the + /// registered and wrapped by . + /// + public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler) + { +#pragma warning disable CA5399 + 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(); + chatClient = FoundryAgent.WireServedModel(chatClient); + + return chatClient; + } + + /// + /// An that returns a fixed response body and optionally + /// includes the x-ms-served-model response header. + /// + public sealed class ServedModelHandler : HttpClientHandler + { + private readonly string _body; + private readonly string? _servedModel; + + public ServedModelHandler(string body, string? servedModel) + { + this._body = body; + this._servedModel = servedModel; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this._body, Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + + if (this._servedModel is not null) + { + resp.Headers.Add("x-ms-served-model", this._servedModel); + } + + return Task.FromResult(resp); + } + } + + /// + /// A minimal that returns a with the given model ID. + /// + public sealed class FakeChatClient : IChatClient + { + private readonly string _modelId; + + public FakeChatClient(string modelId) + { + this._modelId = modelId; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A minimal that yields s with a given model ID. + /// + public sealed class FakeStreamingChatClient : IChatClient + { + private readonly string _modelId; + private readonly int _updateCount; + + public FakeStreamingChatClient(string modelId, int updateCount) + { + this._modelId = modelId; + this._updateCount = updateCount; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + for (int i = 0; i < this._updateCount; i++) + { + await Task.Yield(); + yield return new ChatResponseUpdate { ModelId = this._modelId }; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A fake that simulates the by writing + /// into the box during . + /// + public sealed class FakeChatClientWithPolicySimulation : IChatClient + { + private readonly string _modelId; + private readonly string _servedModel; + + public FakeChatClientWithPolicySimulation(string modelId, string servedModel) + { + this._modelId = modelId; + this._servedModel = servedModel; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + // Simulate what ServedModelPolicy does: write into the box. + if (ServedModelScope.Current is { } box) + { + box.Value = this._servedModel; + } + + return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); + } + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } + + /// + /// A fake streaming that simulates the + /// by writing into the box before yielding updates. + /// + public sealed class FakeStreamingChatClientWithPolicySimulation : IChatClient + { + private readonly string _modelId; + private readonly string _servedModel; + private readonly int _updateCount; + + public FakeStreamingChatClientWithPolicySimulation(string modelId, string servedModel, int updateCount) + { + this._modelId = modelId; + this._servedModel = servedModel; + this._updateCount = updateCount; + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Simulate what ServedModelPolicy does on the initial HTTP response. + if (ServedModelScope.Current is { } box) + { + box.Value = this._servedModel; + } + + for (int i = 0; i < this._updateCount; i++) + { + await Task.Yield(); + yield return new ChatResponseUpdate { ModelId = this._modelId }; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs deleted file mode 100644 index d3fda27450..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTests.cs +++ /dev/null @@ -1,420 +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.Runtime.CompilerServices; -using System.Text; -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; - -/// -/// Tests for the x-ms-served-model response header pipeline: -/// AsyncLocal carrier, -/// pipeline policy, -/// and delegating client. -/// -public sealed class ServedModelTests -{ - // =========================================================================================== - // ServedModelScope tests - // =========================================================================================== - - [Fact] - public void Scope_DefaultIsNull() - { - Assert.Null(ServedModelScope.Current); - } - - [Fact] - public void Scope_SetAndGet_ReturnsBox() - { - var previous = ServedModelScope.Current; - try - { - var box = new StrongBox("gpt-5-nano-2025-08-07"); - ServedModelScope.Current = box; - Assert.Same(box, ServedModelScope.Current); - Assert.Equal("gpt-5-nano-2025-08-07", ServedModelScope.Current!.Value); - } - finally - { - ServedModelScope.Current = previous; - } - } - - // =========================================================================================== - // ServedModelPolicy tests (via real SCM pipeline + mock HTTP handler) - // =========================================================================================== - - [Fact] - public void Policy_IsSingleton() - { - Assert.Same(ServedModelPolicy.Instance, ServedModelPolicy.Instance); - } - - [Fact] - public async Task Policy_HeaderPresent_SetsScopeAsync() - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act: drive a request through the pipeline. The policy fires during the HTTP roundtrip. - var response = await chatClient.GetResponseAsync("hi"); - - // Assert: the scope was populated during the call, but we need a way to observe it. - // The end-to-end test validates this via ServedModelChatClient. Here we confirm the - // scope is set by wrapping with ServedModelChatClient and checking the result. - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - [Fact] - public async Task Policy_HeaderAbsent_ScopeRemainsNull_ModelIdUnchangedAsync() - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: null); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert: ModelId is the deployment alias from the JSON body ("fake"). - Assert.Equal("fake", response.ModelId); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - public async Task Policy_EmptyOrWhitespaceHeader_ModelIdUnchangedAsync(string headerValue) - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: headerValue); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert: empty/whitespace header is rejected by the policy, ModelId stays as "fake". - Assert.Equal("fake", response.ModelId); - } - - [Fact] - public async Task Policy_HeaderWithWhitespace_TrimsValueAsync() - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: " gpt-5-nano-2025-08-07 "); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert: the whitespace is trimmed. - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - // =========================================================================================== - // ServedModelChatClient tests (non-streaming) - // =========================================================================================== - - [Fact] - public async Task GetResponseAsync_PolicySetsBox_OverwritesModelIdAsync() - { - // Arrange: fake inner client that simulates the policy writing into the box during the call. - var inner = new FakeChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07"); - var client = new ServedModelChatClient(inner); - - // Act - var response = await client.GetResponseAsync([]); - - // Assert - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - [Fact] - public async Task GetResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() - { - // Arrange: fake inner client that does NOT write to the box (simulates absent header). - var inner = new FakeChatClient("deployment-alias"); - var client = new ServedModelChatClient(inner); - - // Act - var response = await client.GetResponseAsync([]); - - // Assert - Assert.Equal("deployment-alias", response.ModelId); - } - - // =========================================================================================== - // ServedModelChatClient tests (streaming) - // =========================================================================================== - - [Fact] - public async Task GetStreamingResponseAsync_PolicySetsBox_OverwritesModelIdOnAllUpdatesAsync() - { - // Arrange: fake inner client that simulates the policy writing into the box. - var inner = new FakeStreamingChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07", updateCount: 3); - var client = new ServedModelChatClient(inner); - - // Act - var updates = new List(); - await foreach (var update in client.GetStreamingResponseAsync([])) - { - updates.Add(update); - } - - // Assert - Assert.Equal(3, updates.Count); - Assert.All(updates, u => Assert.Equal("gpt-5-nano-2025-08-07", u.ModelId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() - { - // Arrange: fake inner client that does NOT write to the box. - var inner = new FakeStreamingChatClient("deployment-alias", updateCount: 2); - var client = new ServedModelChatClient(inner); - - // Act - var updates = new List(); - await foreach (var update in client.GetStreamingResponseAsync([])) - { - updates.Add(update); - } - - // Assert - Assert.Equal(2, updates.Count); - Assert.All(updates, u => Assert.Equal("deployment-alias", u.ModelId)); - } - - // =========================================================================================== - // End-to-end tests (policy + client together via real OpenAI SCM pipeline) - // =========================================================================================== - - [Fact] - public async Task EndToEnd_PolicyAndClient_ModelIdReflectsServedModelAsync() - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - [Fact] - public async Task EndToEnd_PolicyAndClient_NoHeader_ModelIdUnchangedAsync() - { - // Arrange - using var handler = new ServedModelHandler(MinimalResponseJson(), servedModel: null); - IChatClient chatClient = CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert - Assert.Equal("fake", response.ModelId); - } - - // =========================================================================================== - // Helpers - // =========================================================================================== - - 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} - } - """; - - /// - /// Creates a chat client backed by a real OpenAI ResponsesClient with the - /// registered and wrapped by . - /// - private static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler) - { -#pragma warning disable CA5399 - 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(); - chatClient = FoundryAgent.WireServedModel(chatClient); - - return chatClient; - } - - /// - /// An that returns a fixed response body and optionally - /// includes the x-ms-served-model response header. - /// - private sealed class ServedModelHandler : HttpClientHandler - { - private readonly string _body; - private readonly string? _servedModel; - - public ServedModelHandler(string body, string? servedModel) - { - this._body = body; - this._servedModel = servedModel; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var resp = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(this._body, Encoding.UTF8, "application/json"), - RequestMessage = request, - }; - - if (this._servedModel is not null) - { - resp.Headers.Add("x-ms-served-model", this._servedModel); - } - - return Task.FromResult(resp); - } - } - - /// - /// A minimal that returns a with the given model ID. - /// - private sealed class FakeChatClient : IChatClient - { - private readonly string _modelId; - - public FakeChatClient(string modelId) - { - this._modelId = modelId; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A minimal that yields s with a given model ID. - /// - private sealed class FakeStreamingChatClient : IChatClient - { - private readonly string _modelId; - private readonly int _updateCount; - - public FakeStreamingChatClient(string modelId, int updateCount) - { - this._modelId = modelId; - this._updateCount = updateCount; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - for (int i = 0; i < this._updateCount; i++) - { - await Task.Yield(); - yield return new ChatResponseUpdate { ModelId = this._modelId }; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A fake that simulates the by writing - /// into the box during . - /// - private sealed class FakeChatClientWithPolicySimulation : IChatClient - { - private readonly string _modelId; - private readonly string _servedModel; - - public FakeChatClientWithPolicySimulation(string modelId, string servedModel) - { - this._modelId = modelId; - this._servedModel = servedModel; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - // Simulate what ServedModelPolicy does: write into the box. - if (ServedModelScope.Current is { } box) - { - box.Value = this._servedModel; - } - - return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); - } - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A fake streaming that simulates the - /// by writing into the box before yielding updates. - /// - private sealed class FakeStreamingChatClientWithPolicySimulation : IChatClient - { - private readonly string _modelId; - private readonly string _servedModel; - private readonly int _updateCount; - - public FakeStreamingChatClientWithPolicySimulation(string modelId, string servedModel, int updateCount) - { - this._modelId = modelId; - this._servedModel = servedModel; - this._updateCount = updateCount; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Simulate what ServedModelPolicy does on the initial HTTP response. - if (ServedModelScope.Current is { } box) - { - box.Value = this._servedModel; - } - - for (int i = 0; i < this._updateCount; i++) - { - await Task.Yield(); - yield return new ChatResponseUpdate { ModelId = this._modelId }; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} From 6c2f8e48916df58abe6d365e7cb5c52d38f494c1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 21 May 2026 12:02:38 +0100 Subject: [PATCH 5/5] .NET: Consolidate served-model logic into FoundryChatClient Move x-ms-served-model header capture from the standalone ServedModelChatClient decorator directly into FoundryChatClient, eliminating a separate wrapper that had to be applied at every Foundry entry point via WireServedModel(). - Register ServedModelPolicy in FoundryChatClient constructors (alongside the existing AgentFrameworkUserAgentPolicy registration) - Add StrongBox push/read logic to FoundryChatClient.GetResponseAsync and GetStreamingResponseAsync - Delete ServedModelChatClient.cs and its unit tests - Remove WireServedModel() from FoundryAgent and AIProjectClientExtensions - Update ServedModelPolicy/Scope XML docs to reference FoundryChatClient - Simplify ServedModelTestHelpers to use FoundryChatClient directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FoundryChatClient.cs | 68 +++++++- .../ServedModelChatClient.cs | 86 ---------- .../ServedModelPolicy.cs | 10 +- .../ServedModelScope.cs | 4 +- .../FoundryChatClientTests.cs | 14 +- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 1 - .../ServedModelChatClientTests.cs | 124 -------------- .../ServedModelTestHelpers.cs | 152 ++---------------- 8 files changed, 86 insertions(+), 373 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs index 6d7144af18..e4f7701ff6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs @@ -27,9 +27,9 @@ namespace Microsoft.Agents.AI.Foundry; /// Foundry chat-client decorator that unifies the three Foundry chat-client construction /// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes /// Foundry-specific concerns: microsoft.foundry telemetry tagging, -/// agent-framework-dotnet/{version} User-Agent stamping, and (for Prompt Agents) -/// per-request payload mutation that injects the agent reference and strips per-request -/// overrides that the server owns. +/// agent-framework-dotnet/{version} User-Agent stamping, x-ms-served-model +/// response-header capture, and (for Prompt Agents) per-request payload mutation that injects +/// the agent reference and strips per-request overrides that the server owns. /// /// /// @@ -78,6 +78,7 @@ internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId) this._aiProjectClient = aiProjectClient; this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId); TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + TryRegisterServedModelPolicy(this.InnerClient); } /// @@ -96,6 +97,7 @@ internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agent this._baseChatOptions = baseChatOptions; this.AgentName = agentReference.Name; TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + TryRegisterServedModelPolicy(this.InnerClient); } /// @@ -161,6 +163,7 @@ private FoundryChatClient(AgentEndpointInner inner) this.AgentName = inner.AgentName; this._metadata = new ChatClientMetadata("microsoft.foundry"); TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); + TryRegisterServedModelPolicy(this.InnerClient); } /// @@ -212,7 +215,25 @@ public override async Task GetResponseAsync(IEnumerable(null); + var previous = ServedModelScope.Current; + ServedModelScope.Current = box; + + try + { + var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false); + + if (box.Value is { } servedModel) + { + response.ModelId = servedModel; + } + + return response; + } + finally + { + ServedModelScope.Current = previous; + } } /// @@ -222,9 +243,25 @@ public override async IAsyncEnumerable GetStreamingResponseA ? this.GetAgentEnabledChatOptions(options) : options; - await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false)) + var box = new StrongBox(null); + var previous = ServedModelScope.Current; + ServedModelScope.Current = box; + + try + { + await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false)) + { + if (box.Value is { } servedModel) + { + chunk.ModelId = servedModel; + } + + yield return chunk; + } + } + finally { - yield return chunk; + ServedModelScope.Current = previous; } } @@ -628,6 +665,25 @@ private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerC } } + /// + /// Best-effort registration of via the MEAI + /// hook. The policy captures the + /// x-ms-served-model response header from Azure OpenAI and writes it into + /// so the and + /// overrides can overwrite + /// with the actual model snapshot. + /// + private static void TryRegisterServedModelPolicy(IChatClient? innerClient) + { + if (innerClient?.GetService() is { } policies) + { + OpenAIRequestPoliciesReflection.AddPolicyIfMissing( + policies, + ServedModelPolicy.Instance, + PipelinePosition.PerCall); + } + } + /// Default OAuth scope for the Azure AI resource. Matches the scope used by Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is accepted by the Foundry control plane. private const string AzureAiResourceScope = "https://ai.azure.com/.default"; diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs deleted file mode 100644 index 8db45f2bb2..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelChatClient.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Agents.AI.Foundry; - -/// -/// Delegating chat client that overwrites and -/// with the actual served model name captured by -/// from the x-ms-served-model response header. -/// -/// -/// -/// Before each inner call, this client pushes a fresh onto -/// so the (running inside the -/// SCM pipeline) can write the header value into it. After the inner call returns, the client -/// reads the box and overwrites . When the box is empty -/// (header absent on non-Azure endpoints), the original model name is preserved unchanged. -/// -/// -internal sealed class ServedModelChatClient : DelegatingChatClient -{ - public ServedModelChatClient(IChatClient innerClient) - : base(innerClient) - { - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var box = new StrongBox(null); - var previous = ServedModelScope.Current; - ServedModelScope.Current = box; - - try - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - - if (box.Value is { } servedModel) - { - response.ModelId = servedModel; - } - - return response; - } - finally - { - ServedModelScope.Current = previous; - } - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var box = new StrongBox(null); - var previous = ServedModelScope.Current; - ServedModelScope.Current = box; - - try - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - if (box.Value is { } servedModel) - { - update.ModelId = servedModel; - } - - yield return update; - } - } - finally - { - ServedModelScope.Current = previous; - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs index 0da9d6ac50..27e98b7b7d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs @@ -8,19 +8,19 @@ namespace Microsoft.Agents.AI.Foundry; /// /// Pipeline policy that captures the x-ms-served-model response header from Azure OpenAI -/// and stores it in for consumption by . +/// and stores it in for consumption by . /// /// /// /// Azure OpenAI Responses API returns the deployment alias in response.model but the actual /// model snapshot (e.g. gpt-5-nano-2025-08-07) in the x-ms-served-model response header. -/// This policy extracts the header after the HTTP roundtrip so the +/// This policy extracts the header after the HTTP roundtrip so the /// can overwrite ChatResponse.ModelId with the true model name. /// /// /// Registered once per OpenAIRequestPolicies instance via the MEAI 10.5.1 extension hook. -/// When the header is absent (non-Azure endpoints), the scope is not set and the downstream -/// preserves the original model name. +/// When the header is absent (non-Azure endpoints), the scope is not set and the +/// preserves the original model name. /// /// internal sealed class ServedModelPolicy : PipelinePolicy @@ -57,7 +57,7 @@ private static void CaptureServedModel(PipelineMessage message) && !string.IsNullOrWhiteSpace(servedModel)) { // Write into the box (reference-type mutation) so the value is visible to the - // ServedModelChatClient that pushed the box before calling the inner client. + // FoundryChatClient that pushed the box before calling the inner client. if (ServedModelScope.Current is { } box) { box.Value = servedModel.Trim(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs index 936a88dedf..45fb8d9406 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs @@ -8,13 +8,13 @@ namespace Microsoft.Agents.AI.Foundry; /// /// AsyncLocal carrier that bridges the x-ms-served-model response header value from the /// running inside the SCM transport pipeline up to the -/// decorator. +/// decorator. /// /// /// /// Because mutations inside a child async method do not propagate /// back to the caller (copy-on-write semantics), this scope uses as an -/// indirection layer. The pushes a fresh box onto the scope +/// indirection layer. The pushes a fresh box onto the scope /// before calling the inner client; the writes into the box's /// (a reference-type mutation visible to anyone holding the same box). /// After the inner call returns, the client reads the box's value. diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs index f075d80857..3bace55df2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryChatClientTests.cs @@ -555,20 +555,20 @@ public void ParseAgentEndpoint_ThrowsOnNullUri() #endregion - #region AgentFrameworkUserAgentPolicy registration + dedup + #region AgentFrameworkUserAgentPolicy + ServedModelPolicy registration + dedup [Fact] public void Register_AgentFrameworkUserAgentPolicy_OnUnderlyingOpenAIRequestPolicies() { // Arrange + Act: constructing a FoundryChatClient should register the - // AgentFrameworkUserAgentPolicy on the inner chat client's OpenAIRequestPolicies. + // AgentFrameworkUserAgentPolicy and ServedModelPolicy on the inner chat client's OpenAIRequestPolicies. var chatClient = new FoundryChatClient(CreateProjectClient(), "gpt-4o-mini"); // Assert: the inner chat client (MEAI's OpenAIResponsesChatClient) exposes - // OpenAIRequestPolicies via GetService, and our policy is present in its entries. + // OpenAIRequestPolicies via GetService, and both policies are present in its entries. var policies = chatClient.GetService(); Assert.NotNull(policies); - Assert.Equal(1, EntriesCount(policies!)); + Assert.Equal(2, EntriesCount(policies!)); } [Fact] @@ -576,7 +576,7 @@ public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClient { // Arrange: construct via the ProjectsAgentVersion mode-2 variant, which chains via // :this(...) into the AgentReference ctor. If the policy registration code were - // inadvertently called twice along the chain, we would see 2 entries. + // inadvertently called twice along the chain, we would see more than 2 entries. var projectClient = CreateProjectClient(); var agentVersion = ModelReaderWriter.Read( BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!; @@ -585,10 +585,10 @@ public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClient var chatClient = new FoundryChatClient(projectClient, agentVersion, baseChatOptions: null); // Assert: even though the version variant funnels through the AgentReference ctor - // via :this(...), the policy is registered exactly once on the inner pipeline. + // via :this(...), each policy is registered exactly once on the inner pipeline. var policies = chatClient.GetService(); Assert.NotNull(policies); - Assert.Equal(1, EntriesCount(policies!)); + Assert.Equal(2, EntriesCount(policies!)); Assert.Same(agentVersion, chatClient.GetService()); Assert.NotNull(chatClient.GetService()); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 7b43c32f64..713c55aaa6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -22,7 +22,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs deleted file mode 100644 index d4f029324f..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelChatClientTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -#pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001 - -namespace Microsoft.Agents.AI.Foundry.UnitTests; - -/// -/// Unit tests for : the -/// that pushes a fresh box onto before each inner call and -/// overwrites / -/// with the value captured by . -/// -public sealed class ServedModelChatClientTests -{ - #region Non-streaming - - [Fact] - public async Task GetResponseAsync_PolicySetsBox_OverwritesModelIdAsync() - { - // Arrange: fake inner client that simulates the policy writing into the box during the call. - var inner = new ServedModelTestHelpers.FakeChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07"); - var client = new ServedModelChatClient(inner); - - // Act - var response = await client.GetResponseAsync([]); - - // Assert - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - [Fact] - public async Task GetResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() - { - // Arrange: fake inner client that does NOT write to the box (simulates absent header). - var inner = new ServedModelTestHelpers.FakeChatClient("deployment-alias"); - var client = new ServedModelChatClient(inner); - - // Act - var response = await client.GetResponseAsync([]); - - // Assert - Assert.Equal("deployment-alias", response.ModelId); - } - - #endregion - - #region Streaming - - [Fact] - public async Task GetStreamingResponseAsync_PolicySetsBox_OverwritesModelIdOnAllUpdatesAsync() - { - // Arrange: fake inner client that simulates the policy writing into the box. - var inner = new ServedModelTestHelpers.FakeStreamingChatClientWithPolicySimulation("deployment-alias", "gpt-5-nano-2025-08-07", updateCount: 3); - var client = new ServedModelChatClient(inner); - - // Act - var updates = new List(); - await foreach (var update in client.GetStreamingResponseAsync([])) - { - updates.Add(update); - } - - // Assert - Assert.Equal(3, updates.Count); - Assert.All(updates, u => Assert.Equal("gpt-5-nano-2025-08-07", u.ModelId)); - } - - [Fact] - public async Task GetStreamingResponseAsync_PolicyDoesNotSetBox_PreservesOriginalModelIdAsync() - { - // Arrange: fake inner client that does NOT write to the box. - var inner = new ServedModelTestHelpers.FakeStreamingChatClient("deployment-alias", updateCount: 2); - var client = new ServedModelChatClient(inner); - - // Act - var updates = new List(); - await foreach (var update in client.GetStreamingResponseAsync([])) - { - updates.Add(update); - } - - // Assert - Assert.Equal(2, updates.Count); - Assert.All(updates, u => Assert.Equal("deployment-alias", u.ModelId)); - } - - #endregion - - #region End-to-end (policy + client together via real OpenAI SCM pipeline) - - [Fact] - public async Task EndToEnd_PolicyAndClient_ModelIdReflectsServedModelAsync() - { - // Arrange - using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: "gpt-5-nano-2025-08-07"); - IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert - Assert.Equal("gpt-5-nano-2025-08-07", response.ModelId); - } - - [Fact] - public async Task EndToEnd_PolicyAndClient_NoHeader_ModelIdUnchangedAsync() - { - // Arrange - using var handler = new ServedModelTestHelpers.ServedModelHandler(ServedModelTestHelpers.MinimalResponseJson(), servedModel: null); - IChatClient chatClient = ServedModelTestHelpers.CreateChatClientWithPolicy(handler); - - // Act - var response = await chatClient.GetResponseAsync("hi"); - - // Assert - Assert.Equal("fake", response.ModelId); - } - - #endregion -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs index c022668a73..c20ad15346 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/ServedModelTestHelpers.cs @@ -1,17 +1,14 @@ // 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.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Projects; using Microsoft.Extensions.AI; -using OpenAI; #pragma warning disable OPENAI001, MEAI001, MAAI001, SCME0001 @@ -19,8 +16,7 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests; /// /// Shared helpers and fake clients used by the served-model test suite -/// (, , -/// ). +/// (, ). /// internal static class ServedModelTestHelpers { @@ -32,22 +28,22 @@ public static string MinimalResponseJson() => """ """; /// - /// Creates a chat client backed by a real OpenAI ResponsesClient with the - /// registered and wrapped by . + /// Creates a backed by a real OpenAI Responses pipeline + /// routed through the supplied . The + /// is registered automatically by the constructor. /// public static IChatClient CreateChatClientWithPolicy(HttpMessageHandler handler) { #pragma warning disable CA5399 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(); - chatClient = FoundryAgent.WireServedModel(chatClient); + var projectClient = new AIProjectClient( + new Uri("https://test.openai.azure.com/"), + new FakeAuthenticationTokenProvider(), + new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(http) }); - return chatClient; + return new FoundryChatClient(projectClient, "fake"); } /// @@ -81,132 +77,4 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(resp); } } - - /// - /// A minimal that returns a with the given model ID. - /// - public sealed class FakeChatClient : IChatClient - { - private readonly string _modelId; - - public FakeChatClient(string modelId) - { - this._modelId = modelId; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A minimal that yields s with a given model ID. - /// - public sealed class FakeStreamingChatClient : IChatClient - { - private readonly string _modelId; - private readonly int _updateCount; - - public FakeStreamingChatClient(string modelId, int updateCount) - { - this._modelId = modelId; - this._updateCount = updateCount; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - for (int i = 0; i < this._updateCount; i++) - { - await Task.Yield(); - yield return new ChatResponseUpdate { ModelId = this._modelId }; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A fake that simulates the by writing - /// into the box during . - /// - public sealed class FakeChatClientWithPolicySimulation : IChatClient - { - private readonly string _modelId; - private readonly string _servedModel; - - public FakeChatClientWithPolicySimulation(string modelId, string servedModel) - { - this._modelId = modelId; - this._servedModel = servedModel; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - // Simulate what ServedModelPolicy does: write into the box. - if (ServedModelScope.Current is { } box) - { - box.Value = this._servedModel; - } - - return Task.FromResult(new ChatResponse([new ChatMessage(ChatRole.Assistant, "hi")]) { ModelId = this._modelId }); - } - - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } - - /// - /// A fake streaming that simulates the - /// by writing into the box before yielding updates. - /// - public sealed class FakeStreamingChatClientWithPolicySimulation : IChatClient - { - private readonly string _modelId; - private readonly string _servedModel; - private readonly int _updateCount; - - public FakeStreamingChatClientWithPolicySimulation(string modelId, string servedModel, int updateCount) - { - this._modelId = modelId; - this._servedModel = servedModel; - this._updateCount = updateCount; - } - - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - // Simulate what ServedModelPolicy does on the initial HTTP response. - if (ServedModelScope.Current is { } box) - { - box.Value = this._servedModel; - } - - for (int i = 0; i < this._updateCount; i++) - { - await Task.Yield(); - yield return new ChatResponseUpdate { ModelId = this._modelId }; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } }