.NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path (#5899)

* .NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path

Bumps Azure.AI.Projects to 2.1.0-beta.2 with the matching transitive pins (Azure.Core 1.55.0, System.ClientModel 1.11.0).

Foundry agent endpoint plumbing:
* FoundryAgent now routes the agent-endpoint constructor through the new GetProjectResponsesClientForAgentEndpoint helper.
* Adds an internal FoundryAgent ctor that takes an existing AIProjectClient plus a parsed agent endpoint so the public extension does not need to construct a second project client.
* Adds public AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) extension. This is the path consumer samples are expected to use for hosted agents because version selection happens server-side.
* Trims the dangling "If you want to construct a FoundryAgent against a project endpoint..." sentence from ParseAgentEndpoint.

Unit tests:
* Four new tests in AzureAIProjectChatClientExtensionsTests cover the AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) overload. 263/263 pass.

Consumer samples (Using-Samples):
* SimpleAgent and SessionFilesClient now read AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_AGENT_NAME (both required, throw on missing), derive the agent endpoint with new Uri($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"), then call aiProjectClient.AsAIAgent(agentEndpoint, ...).
* SessionFilesClient README updated.

Contributor samples (responses/*):
* New HostedContributorRouteExtensions.MapDevTemporaryLocalAgentEndpoint() wildcard route extension so localhost contributor servers accept the per-agent OpenAI endpoint shape the production Hosted runtime exposes.
* All 11 contributor Program.cs files call MapDevTemporaryLocalAgentEndpoint() with a contributor-only warning comment.
* Hosted-Files and Hosted-AzureSearchRag were importing Hosted_Shared_Contributor_Setup but never calling AddDevTemporaryLocalContributorSetup(). Both now call it so HostedSessionIsolationKeyProvider resolves correctly in dev.
* Hosted-AzureSearchRag, Hosted-Files, Hosted-MemoryAgent csprojs drop stale VersionOverride="2.1.0-beta.1" pins.
* Hosted-AzureSearchRag and Hosted-Files csprojs add ProjectReference to Hosted_Shared_Contributor_Setup.
* Hosted-Observability/.dockerignore removed the out/ exclusion that was blocking COPY out/ . in Dockerfile.contributor.

Verified:
* Full solution-scoped build of changed projects: green.
* Scoped CI-parity dotnet format via WSL2 + Docker (mcr.microsoft.com/dotnet/sdk:10.0) over every changed csproj: clean.
* Foundry unit tests: 263/263.
* Contributor docker smoke for 8 hosted samples (publish + docker build + docker run + curl POST to the wildcard route): HTTP 200 / 500 with route matched.
* End-to-end smoke against the real Azure Foundry project with a fresh bearer token: Hosted-Files contributor container served HTTP 200, the agent invoked ListBundledFiles, and returned the expected file name.

* Address PR review: forward pipeline settings; add UTs

- CreateProjectClientOptions also carries RetryPolicy, NetworkTimeout, ClientLoggingOptions, MessageLoggingPolicy (was Transport+UserAgentApplicationId only).

- Make CreateProjectClientOptions internal so tests can verify the copy directly.

- Add AsAIAgent(Uri) UTs covering tools forwarding to inner ChatOptions and null tools handling.

- Add CreateProjectClientOptions UTs covering null caller and full pipeline-settings copy.
This commit is contained in:
Roger Barreto
2026-05-18 20:20:56 +00:00
committed by GitHub
parent dff23a9413
commit aad20c2b33
25 changed files with 469 additions and 198 deletions
@@ -20,7 +20,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Search.Documents" />
<PackageReference Include="Microsoft.Extensions.AI" />
@@ -1380,6 +1380,133 @@ public sealed class AzureAIProjectChatClientExtensionsTests
#endregion
#region AsAIAgent(AIProjectClient, Uri agentEndpoint) Tests
private const string TestAgentEndpointUrl = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when AIProjectClient is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullClient_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient? client = null;
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client!.AsAIAgent(new Uri(TestAgentEndpointUrl)));
Assert.Equal("aiProjectClient", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) throws ArgumentNullException when agentEndpoint is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullEndpoint_ThrowsArgumentNullException()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client.AsAIAgent((Uri)null!));
Assert.Equal("agentEndpoint", exception.ParamName);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) populates Name/Id from the parsed endpoint slug
/// and exposes the supplied <see cref="AIProjectClient"/> via <see cref="AIAgent.GetService{TService}(object?)"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_PopulatesNameAndIdFromSlugAndReusesProjectClient()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl));
// Assert
Assert.NotNull(agent);
Assert.IsType<FoundryAgent>(agent);
Assert.Equal("it-happy-path", agent.Name);
Assert.Equal("it-happy-path", agent.Id);
Assert.Same(client, agent.GetService<AIProjectClient>());
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) applies the supplied client factory exactly once.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.AsAIAgent(
new Uri(TestAgentEndpointUrl),
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) forwards the supplied tools to the inner
/// <see cref="ChatClientAgent"/>'s <see cref="ChatOptions.Tools"/>.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_ForwardsToolsToInnerChatOptions()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
var tool1 = AIFunctionFactory.Create(() => "result-1", "tool_1", "First test tool.");
var tool2 = AIFunctionFactory.Create(() => "result-2", "tool_2", "Second test tool.");
List<AITool> tools = [tool1, tool2];
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: tools);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.NotNull(chatOptions!.Tools);
Assert.Equal(2, chatOptions.Tools!.Count);
Assert.Same(tool1, chatOptions.Tools[0]);
Assert.Same(tool2, chatOptions.Tools[1]);
}
/// <summary>
/// Verify that AsAIAgent(Uri agentEndpoint) accepts a null tools argument without throwing
/// and produces an agent whose inner <see cref="ChatOptions.Tools"/> is null.
/// </summary>
[Fact]
public void AsAIAgent_WithAgentEndpoint_WithNullTools_DoesNotThrow()
{
// Arrange
AIProjectClient client = this.CreateTestAgentClient();
// Act
var agent = client.AsAIAgent(new Uri(TestAgentEndpointUrl), tools: null);
// Assert
Assert.NotNull(agent);
ChatOptions? chatOptions = GetAgentChatOptions(agent);
Assert.NotNull(chatOptions);
Assert.Null(chatOptions!.Tools);
}
#endregion
#region Helper Methods
/// <summary>
@@ -2,6 +2,7 @@
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -356,7 +357,7 @@ public class FoundryAgentTests
bool userAgentFound = false;
using HttpHandlerAssert httpHandler = new(request =>
{
if (request.Headers.TryGetValues("User-Agent", out System.Collections.Generic.IEnumerable<string>? values))
if (request.Headers.TryGetValues("User-Agent", out IEnumerable<string>? values))
{
foreach (string value in values)
{
@@ -431,23 +432,23 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNonNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Null(agent.GetService<AIProjectClient>());
Assert.NotNull(agent.GetService<AIProjectClient>());
}
[Fact]
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNull()
{
FoundryAgent agent = new(
s_testEndpoint,
@@ -455,7 +456,7 @@ public class FoundryAgentTests
model: "gpt-4o-mini",
instructions: "Test");
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
Assert.Null(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
@@ -665,21 +666,82 @@ public class FoundryAgentTests
}
[Fact]
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
public void AgentEndpointConstructor_PreservesUserAgentApplicationId()
{
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
// application-id stamp in the outbound request. Verify the value is propagated onto the
// project-level client's options via the public ProjectOpenAIClient surface.
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
Assert.NotNull(projectClient);
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
Assert.NotNull(agent);
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
}
[Fact]
public void CreateProjectClientOptions_NullCallerOptions_ReturnsNull()
{
Assert.Null(FoundryAgent.CreateProjectClientOptions(null));
}
[Fact]
public void CreateProjectClientOptions_CarriesPipelineSettingsAndUserAgent()
{
// Arrange
var transport = new FakePipelineTransport();
var retryPolicy = new FakeRetryPolicy();
var messageLoggingPolicy = new FakeMessageLoggingPolicy();
var clientLoggingOptions = new ClientLoggingOptions { EnableLogging = false };
var networkTimeout = TimeSpan.FromSeconds(42);
ProjectOpenAIClientOptions callerOptions = new()
{
UserAgentApplicationId = "my-app-id",
Transport = transport,
RetryPolicy = retryPolicy,
MessageLoggingPolicy = messageLoggingPolicy,
ClientLoggingOptions = clientLoggingOptions,
NetworkTimeout = networkTimeout,
};
// Act
AIProjectClientOptions? projectOptions = FoundryAgent.CreateProjectClientOptions(callerOptions);
// Assert: every settable pipeline behavior the caller configured is forwarded
// onto the project-level options bag, not silently dropped.
Assert.NotNull(projectOptions);
Assert.Equal("my-app-id", projectOptions!.UserAgentApplicationId);
Assert.Same(transport, projectOptions.Transport);
Assert.Same(retryPolicy, projectOptions.RetryPolicy);
Assert.Same(messageLoggingPolicy, projectOptions.MessageLoggingPolicy);
Assert.Same(clientLoggingOptions, projectOptions.ClientLoggingOptions);
Assert.Equal(networkTimeout, projectOptions.NetworkTimeout);
}
private sealed class FakeRetryPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakeMessageLoggingPolicy : PipelinePolicy
{
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNext(message, pipeline, currentIndex);
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
=> ProcessNextAsync(message, pipeline, currentIndex);
}
private sealed class FakePipelineTransport : PipelineTransport
{
protected override PipelineMessage CreateMessageCore() => throw new NotSupportedException();
protected override void ProcessCore(PipelineMessage message) => throw new NotSupportedException();
protected override ValueTask ProcessCoreAsync(PipelineMessage message) => throw new NotSupportedException();
}
#endregion
#region ParseAgentEndpoint tests
@@ -762,13 +824,13 @@ public class FoundryAgentTests
private readonly string _value;
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override void Process(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);