Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs
T
afd2739e38 .NET: Surface x-ms-served-model header as ChatResponse.ModelId for Foundry agents (#5979)
* .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.

* .NET: Fix line endings and BOM on ResponsesAgentServedModelTests

* .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).

* .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.

* .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>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 21:26:42 +00:00

68 lines
2.5 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Foundry;
/// <summary>
/// Pipeline policy that captures the <c>x-ms-served-model</c> response header from Azure OpenAI
/// and stores it in <see cref="ServedModelScope"/> for consumption by <see cref="FoundryChatClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// Azure OpenAI Responses API returns the deployment alias in <c>response.model</c> but the actual
/// model snapshot (e.g. <c>gpt-5-nano-2025-08-07</c>) in the <c>x-ms-served-model</c> response header.
/// This policy extracts the header after the HTTP roundtrip so the <see cref="FoundryChatClient"/>
/// can overwrite <c>ChatResponse.ModelId</c> with the true model name.
/// </para>
/// <para>
/// Registered once per <c>OpenAIRequestPolicies</c> instance via the MEAI 10.5.1 extension hook.
/// When the header is absent (non-Azure endpoints), the scope is not set and the
/// <see cref="FoundryChatClient"/> preserves the original model name.
/// </para>
/// </remarks>
internal sealed class ServedModelPolicy : PipelinePolicy
{
/// <summary>The Azure OpenAI response header that carries the actual served model name.</summary>
internal const string ServedModelHeader = "x-ms-served-model";
public static ServedModelPolicy Instance { get; } = new ServedModelPolicy();
private ServedModelPolicy()
{
}
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
ProcessNext(message, pipeline, currentIndex);
CaptureServedModel(message);
}
public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> 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
// FoundryChatClient that pushed the box before calling the inner client.
if (ServedModelScope.Current is { } box)
{
box.Value = servedModel.Trim();
}
}
}
}