Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions dotnet/src/Microsoft.Agents.AI.Foundry/FoundryChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <c>microsoft.foundry</c> telemetry tagging,
/// <c>agent-framework-dotnet/{version}</c> 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.
/// <c>agent-framework-dotnet/{version}</c> User-Agent stamping, <c>x-ms-served-model</c>
/// 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.
/// </summary>
/// <remarks>
/// <para>
Expand Down Expand Up @@ -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);
}

/// <summary>
Expand All @@ -96,6 +97,7 @@ internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agent
this._baseChatOptions = baseChatOptions;
this.AgentName = agentReference.Name;
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}

/// <summary>
Expand Down Expand Up @@ -161,6 +163,7 @@ private FoundryChatClient(AgentEndpointInner inner)
this.AgentName = inner.AgentName;
this._metadata = new ChatClientMetadata("microsoft.foundry");
TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient);
TryRegisterServedModelPolicy(this.InnerClient);
}

/// <summary>
Expand Down Expand Up @@ -212,7 +215,25 @@ public override async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessag
? this.GetAgentEnabledChatOptions(options)
: options;

return await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false);
var box = new StrongBox<string?>(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;
}
}

/// <inheritdoc/>
Expand All @@ -222,9 +243,25 @@ public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseA
? this.GetAgentEnabledChatOptions(options)
: options;

await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false))
var box = new StrongBox<string?>(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;
}
}

Expand Down Expand Up @@ -628,6 +665,25 @@ private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerC
}
}

/// <summary>
/// Best-effort registration of <see cref="ServedModelPolicy"/> via the MEAI
/// <see cref="OpenAIRequestPolicies"/> hook. The policy captures the
/// <c>x-ms-served-model</c> response header from Azure OpenAI and writes it into
/// <see cref="ServedModelScope"/> so the <see cref="GetResponseAsync"/> and
/// <see cref="GetStreamingResponseAsync"/> overrides can overwrite
/// <see cref="ChatResponse.ModelId"/> with the actual model snapshot.
/// </summary>
private static void TryRegisterServedModelPolicy(IChatClient? innerClient)
{
if (innerClient?.GetService<OpenAIRequestPolicies>() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ServedModelPolicy.Instance,
PipelinePosition.PerCall);
}
}

/// <summary>Default OAuth scope for the Azure AI resource. Matches the scope used by <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is accepted by the Foundry control plane.</summary>
private const string AzureAiResourceScope = "https://ai.azure.com/.default";

Expand Down
67 changes: 67 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelPolicy.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <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();
}
}
}
}
35 changes: 35 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.Foundry/ServedModelScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Runtime.CompilerServices;
using System.Threading;

namespace Microsoft.Agents.AI.Foundry;

/// <summary>
/// AsyncLocal carrier that bridges the <c>x-ms-served-model</c> response header value from the
/// <see cref="ServedModelPolicy"/> running inside the SCM transport pipeline up to the
/// <see cref="FoundryChatClient"/> decorator.
/// </summary>
/// <remarks>
/// <para>
/// Because <see cref="AsyncLocal{T}"/> mutations inside a child <c>async</c> method do not propagate
/// back to the caller (copy-on-write semantics), this scope uses <see cref="StrongBox{T}"/> as an
/// indirection layer. The <see cref="FoundryChatClient"/> pushes a fresh box onto the scope
/// before calling the inner client; the <see cref="ServedModelPolicy"/> writes into the box's
/// <see cref="StrongBox{T}.Value"/> (a reference-type mutation visible to anyone holding the same box).
/// After the inner call returns, the client reads the box's value.
/// </para>
/// </remarks>
internal static class ServedModelScope
{
private static readonly AsyncLocal<StrongBox<string?>?> s_current = new();

/// <summary>
/// Gets or sets the per-async-flow served model box.
/// </summary>
public static StrongBox<string?>? Current
{
get => s_current.Value;
set => s_current.Value = value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Text.RegularExpressions;
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;

/// <summary>
/// Integration tests validating that the <c>x-ms-served-model</c> response header
/// returned by the Azure OpenAI Responses API is surfaced on <see cref="ChatResponse.ModelId"/>.
/// </summary>
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);

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
AssertServedModel(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);
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -555,28 +555,28 @@ 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<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
Assert.Equal(2, EntriesCount(policies!));
}

[Fact]
public void Register_AgentFrameworkUserAgentPolicy_IsDedupedAcrossMultipleClients_OnSharedInner()
{
// 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<ProjectsAgentVersion>(
BinaryData.FromString(TestDataUtil.GetAgentVersionResponseJson()))!;
Expand All @@ -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<OpenAIRequestPolicies>();
Assert.NotNull(policies);
Assert.Equal(1, EntriesCount(policies!));
Assert.Equal(2, EntriesCount(policies!));
Assert.Same(agentVersion, chatClient.GetService<ProjectsAgentVersion>());
Assert.NotNull(chatClient.GetService<AgentReference>());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>

<!-- FoundryEval tests require net8.0+ (MEAI.Evaluation does not support legacy TFMs) -->
<!-- Tests requiring net8.0+ (MEAI.Evaluation and some SCM pipeline APIs do not support legacy TFMs) -->
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<Compile Remove="FoundryEvalConverterTests.cs" />
<Compile Remove="FoundryEvalsTests.cs" />
<Compile Remove="ClientHeadersExtensionsTests.cs" />
<Compile Remove="ServedModelTestHelpers.cs" />
<Compile Remove="ServedModelScopeTests.cs" />
<Compile Remove="ServedModelPolicyTests.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading