mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Update FoundryAgent to address HostedAgents strict URL routing (#5677)
* .NET: Foundry agent-endpoint constructor uses ProjectOpenAIClient directly to fix hosted-agent URL routing
Fixes the experimental FoundryAgent(Uri agentEndpoint, AuthenticationTokenProvider, ...)
constructor so it actually works against Foundry hosted agents.
The previous implementation routed through AzureAIProjectChatClient, which
internally called aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClientForAgent(...).
For an agent-endpoint URL of the canonical shape
https://<host>/api/projects/<project>/agents/<agentName>/endpoint/protocols/openai
the chain produced
POST https://<host>/api/projects/<project>/openai/v1/responses
(project-level path, no /agents/ segment). The Foundry service rejects this with
HTTP 400 "Hosted agents can only be called through the agent endpoint:
.../agents/<agentName>/endpoint/protocols/openai/responses".
The constructor also extracted the agent name via
agentEndpoint.Segments[^1].TrimEnd('/'), which returns "openai" (the last segment),
not the agent name.
What changed
- Public ctor signature: clientOptions parameter type changed from
AIProjectClientOptions? to ProjectOpenAIClientOptions?. The constructor is
fundamentally building a ProjectOpenAIClient; accepting AIProjectClientOptions
was a leaky abstraction whose translation silently dropped any pipeline
policies the caller added via AddPolicy(...). With the direct type, caller
policies pass through to the per-agent traffic verbatim.
- Per-agent client construction: `new ProjectOpenAIClient(BearerTokenPolicy, ProjectOpenAIClientOptions)`
with Endpoint and AgentName set, then `GetProjectResponsesClient().AsIChatClient()`.
The SDK auto-appends ?api-version=v1 when AgentName is set.
- New private static ParseAgentEndpoint helper: single source of truth for both
agent-name extraction and project-root derivation. Tolerates trailing slash,
case variants on /agents/ and the suffix segment, strips query/fragment, and
throws ArgumentException with paramName=nameof(agentEndpoint) for malformed input.
- Project-level client (used by CreateConversationSessionAsync) is built fresh
from the derived project root with primitive properties copied
(RetryPolicy/NetworkTimeout/Transport/UserAgentApplicationId) plus MEAI UA.
- New GetService<ProjectOpenAIClient>() entry alongside the existing
GetService<AIProjectClient>() (the latter returns null in agent-endpoint mode
since no AIProjectClient is constructed on that path).
- Endpoint and AgentName on caller-supplied ProjectOpenAIClientOptions are
overridden by values derived from agentEndpoint.
Compatibility
- FoundryAgent is [Experimental(OPENAI001)]. No GA surface touched. The Foundry
project does not maintain PublicAPI.*.txt baselines so there is no shipped
baseline to update.
- The Microsoft.Agents.AI.Foundry csproj pins
Azure.AI.Projects to VersionOverride 2.1.0-beta.1 (matching what the IT and
hosting projects already use); the central pin in Directory.Packages.props
stays at 2.0.0.
- WireClientHeaders from PR #5652 is invoked on the agent-endpoint path so
per-call x-client-* headers behave identically across both ctors.
Tests
- 23 new unit tests in FoundryAgentTests.cs:
- 12 for the agent-endpoint constructor (URL routing for non-streaming and
streaming, conversations URL shape, MEAI UA stamping, caller-policy
passthrough on the per-agent pipeline, Endpoint/AgentName override
semantics, GetService matrix, ProjectOpenAIClient propagation,
UserAgentApplicationId propagation, null-arg validation, ID/Name slug)
- 9 for ParseAgentEndpoint (standard shape, trailing slash, casing,
sovereign-cloud host without /api/projects/ literal prefix, special chars
in agent name, query/fragment stripping, three negative cases)
- 2 null-arg tests for the public ctor
- All 250 Microsoft.Agents.AI.Foundry.UnitTests pass (was 221 baseline plus
29 from PR #5652 plus 23 new in this PR equals 273; pre-existing tests
collapsed by the rebase merge keep the total at 250).
- All 225 Microsoft.Agents.AI.Foundry.Hosting.UnitTests pass; no behavioral
change to the hosting layer.
- dotnet build clean across net8/9/10/netstandard2.0/net472 with
TreatWarningsAsErrors=true.
- dotnet format --verify-no-changes clean for the touched src and test projects.
* .NET: Bump central Azure.AI.Projects pin to 2.1.0-beta.1 and flip Microsoft.Agents.AI.Foundry to preview
Required to fix the NU1109 downgrade chain that broke CI on the agent-endpoint
constructor rewire (#5677). Microsoft.Agents.AI.Foundry now depends on
ProjectOpenAIClientOptions.AgentName and the (AuthenticationPolicy, options)
constructor that only exist in Azure.AI.Projects 2.1.0-beta.1.
Changes:
* Directory.Packages.props: Azure.AI.Projects 2.0.0 -> 2.1.0-beta.1.
* Microsoft.Agents.AI.Foundry.csproj: drop IsReleased=true so the package ships
as preview (matches the beta SDK we now depend on). Add a comment noting the
flip is temporary and should revert once Azure.AI.Projects ships a stable
2.1.0.
* Drop redundant VersionOverride="2.1.0-beta.1" from the 10 csprojs that had it
as a workaround; the central pin now suffices.
Verified:
* dotnet build agent-framework-dotnet.slnx --warnaserror clean across all TFMs.
* Microsoft.Agents.AI.Foundry.UnitTests 250/250 pass.
* Microsoft.Agents.AI.Foundry.Hosting.UnitTests 211/211 pass.
* dotnet format --verify-no-changes clean for the touched src and test projects.
This commit is contained in:
committed by
GitHub
Unverified
parent
226c004b53
commit
eb709d8fc9
@@ -25,7 +25,7 @@
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.53.0" />
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
@@ -38,7 +39,28 @@ namespace Microsoft.Agents.AI.Foundry;
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FoundryAgent : DelegatingAIAgent
|
||||
{
|
||||
private readonly AIProjectClient _aiProjectClient;
|
||||
/// <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";
|
||||
|
||||
/// <summary>
|
||||
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
|
||||
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
|
||||
/// does not build a full <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly AIProjectClient? _aiProjectClient;
|
||||
|
||||
/// <summary>
|
||||
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
|
||||
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
|
||||
/// In agent-endpoint mode this is built directly from the project root derived from the
|
||||
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
|
||||
/// <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
private readonly ProjectOpenAIClient _projectOpenAIClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
|
||||
@@ -72,30 +94,49 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
out var aiProjectClient))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific endpoint.
|
||||
/// </summary>
|
||||
/// <param name="agentEndpoint">The agent-specific endpoint URI (must contain the agent name in the path).</param>
|
||||
/// <param name="agentEndpoint">
|
||||
/// The agent-specific endpoint URI. Must be of the shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>.
|
||||
/// </param>
|
||||
/// <param name="credential">The authentication credential.</param>
|
||||
/// <param name="clientOptions">Optional configuration options for the <see cref="AIProjectClient"/>.</param>
|
||||
/// <param name="clientOptions">
|
||||
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
|
||||
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
|
||||
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
|
||||
/// </list>
|
||||
/// </param>
|
||||
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
|
||||
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="agentEndpoint"/> or <paramref name="credential"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
|
||||
/// <remarks>
|
||||
/// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
|
||||
/// caller already has the per-agent endpoint URL. It populates <see cref="ChatClientAgentOptions.Id"/>
|
||||
/// and <see cref="ChatClientAgentOptions.Name"/> from the agent name parsed out of the endpoint
|
||||
/// path; <c>Description</c>, <c>Instructions</c>, <c>Temperature</c>, and <c>TopP</c> are not
|
||||
/// populated. Callers that need those fields hydrated from server-side state should use
|
||||
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c> or
|
||||
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentRecord)</c> instead.
|
||||
/// </remarks>
|
||||
public FoundryAgent(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
AIProjectClientOptions? clientOptions = null,
|
||||
ProjectOpenAIClientOptions? clientOptions = null,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
: base(CreateInnerAgentFromEndpoint(
|
||||
CreateProjectClient(agentEndpoint, credential, clientOptions),
|
||||
agentEndpoint, tools, clientFactory, services,
|
||||
out var aiProjectClient))
|
||||
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
|
||||
{
|
||||
this._aiProjectClient = aiProjectClient;
|
||||
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -105,6 +146,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
|
||||
{
|
||||
this._aiProjectClient = Throw.IfNull(aiProjectClient);
|
||||
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
|
||||
}
|
||||
|
||||
#region Convenience methods
|
||||
@@ -137,9 +179,7 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
|
||||
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversationsClient = this._aiProjectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetProjectConversationsClient();
|
||||
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
|
||||
|
||||
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
|
||||
|
||||
@@ -161,6 +201,11 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
return this._aiProjectClient;
|
||||
}
|
||||
|
||||
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
|
||||
{
|
||||
return this._projectOpenAIClient;
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
@@ -238,47 +283,181 @@ public sealed class FoundryAgent : DelegatingAIAgent
|
||||
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
|
||||
policies,
|
||||
ClientHeadersPolicy.Instance,
|
||||
System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
PipelinePosition.PerCall);
|
||||
}
|
||||
|
||||
return new ClientHeadersAgent(innerAgent);
|
||||
}
|
||||
|
||||
private static AIAgent CreateInnerAgentFromEndpoint(
|
||||
AIProjectClient aiProjectClient,
|
||||
/// <summary>
|
||||
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
|
||||
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
|
||||
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
|
||||
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
|
||||
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
|
||||
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
|
||||
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
|
||||
/// client with <c>Endpoint</c> and
|
||||
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
|
||||
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
|
||||
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
|
||||
/// </summary>
|
||||
private static AIAgent CreateInnerAgentFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
out AIProjectClient outClient)
|
||||
IServiceProvider? services)
|
||||
{
|
||||
outClient = aiProjectClient;
|
||||
Throw.IfNull(agentEndpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
|
||||
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Name = agentReference.Name,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
|
||||
perAgentOptions.Endpoint = agentEndpoint;
|
||||
perAgentOptions.AgentName = agentName;
|
||||
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
|
||||
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
|
||||
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
|
||||
|
||||
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
ChatClientAgentOptions agentOptions = new()
|
||||
{
|
||||
Id = agentName,
|
||||
Name = agentName,
|
||||
ChatOptions = new() { Tools = tools },
|
||||
};
|
||||
|
||||
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
|
||||
/// constructor by deriving the project root from the supplied agent endpoint and constructing
|
||||
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
|
||||
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
|
||||
/// conversations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
|
||||
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
|
||||
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
|
||||
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
|
||||
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
|
||||
/// user-agent policy is appended last.
|
||||
/// </remarks>
|
||||
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
|
||||
Uri agentEndpoint,
|
||||
AuthenticationTokenProvider credential,
|
||||
ProjectOpenAIClientOptions? clientOptions)
|
||||
{
|
||||
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
|
||||
|
||||
var projectOptions = new ProjectOpenAIClientOptions();
|
||||
if (clientOptions is not null)
|
||||
{
|
||||
if (clientOptions.RetryPolicy is not null)
|
||||
{
|
||||
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
|
||||
}
|
||||
|
||||
if (clientOptions.NetworkTimeout is not null)
|
||||
{
|
||||
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
|
||||
}
|
||||
|
||||
if (clientOptions.Transport is not null)
|
||||
{
|
||||
projectOptions.Transport = clientOptions.Transport;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
|
||||
{
|
||||
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
|
||||
}
|
||||
}
|
||||
|
||||
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
|
||||
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an agent endpoint URI of shape
|
||||
/// <c>https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai</c>
|
||||
/// and returns the agent name and the derived project-root URI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Single source of truth for both agent-name extraction and project-root derivation.
|
||||
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
|
||||
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
|
||||
/// do not match the expected shape.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
|
||||
/// suffix other than <c>/endpoint/protocols/openai</c>.
|
||||
/// </exception>
|
||||
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
|
||||
{
|
||||
Throw.IfNull(agentEndpoint);
|
||||
|
||||
const string AgentsSegment = "/agents/";
|
||||
const string ExpectedSuffix = "/endpoint/protocols/openai";
|
||||
|
||||
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
|
||||
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
|
||||
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var afterAgents = path.Substring(idx + AgentsSegment.Length);
|
||||
var nextSlash = afterAgents.IndexOf('/');
|
||||
if (nextSlash <= 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var agentName = afterAgents.Substring(0, nextSlash);
|
||||
var suffix = afterAgents.Substring(nextSlash);
|
||||
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
|
||||
nameof(agentEndpoint));
|
||||
}
|
||||
|
||||
var rootPath = path.Substring(0, idx);
|
||||
var projectRoot = new UriBuilder(agentEndpoint)
|
||||
{
|
||||
Path = rootPath,
|
||||
Query = string.Empty,
|
||||
Fragment = string.Empty,
|
||||
}.Uri;
|
||||
|
||||
return (agentName, projectRoot);
|
||||
}
|
||||
|
||||
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
|
||||
{
|
||||
Throw.IfNull(endpoint);
|
||||
Throw.IfNull(credential);
|
||||
|
||||
clientOptions ??= new AIProjectClientOptions();
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
|
||||
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
|
||||
return new AIProjectClient(endpoint, credential, clientOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<!-- Preview while we depend on Azure.AI.Projects 2.1.0-beta.1 for hosted-agent routing
|
||||
(ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and
|
||||
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
|
||||
ships a stable 2.1.0. -->
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.AgentServer.Responses" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.Extensions.OpenAI;
|
||||
using Azure.AI.Projects;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -184,7 +185,7 @@ public class FoundryAgentTests
|
||||
|
||||
// Act: this AsAIAgent path constructs FoundryAgent via its internal
|
||||
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
|
||||
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
|
||||
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
|
||||
@@ -398,4 +399,379 @@ public class FoundryAgentTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Agent-endpoint constructor tests
|
||||
|
||||
private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
|
||||
private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint);
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException()
|
||||
{
|
||||
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
|
||||
{
|
||||
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
|
||||
new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!));
|
||||
Assert.Equal("credential", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Equal("it-happy-path", agent.Name);
|
||||
Assert.Equal("it-happy-path", agent.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
|
||||
{
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
|
||||
|
||||
Assert.Null(agent.GetService<AIProjectClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
|
||||
{
|
||||
FoundryAgent agent = new(
|
||||
s_testEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
model: "gpt-4o-mini",
|
||||
instructions: "Test");
|
||||
|
||||
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_AppliesClientFactoryOnce()
|
||||
{
|
||||
int count = 0;
|
||||
FoundryAgent agent = new(
|
||||
s_testAgentEndpoint,
|
||||
new FakeAuthenticationTokenProvider(),
|
||||
clientFactory: c => { count++; return c; });
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.NotNull(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
string path = capturedUri!.AbsolutePath;
|
||||
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
bool sawStreamTrue = false;
|
||||
using HttpHandlerAssert handler = new(async req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
if (req.Content is not null)
|
||||
{
|
||||
string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (body.Contains("\"stream\":true", StringComparison.Ordinal))
|
||||
{
|
||||
sawStreamTrue = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal SSE response; xUnit assertion only cares about the URL/body shape.
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
try
|
||||
{
|
||||
await foreach (var _ in agent.RunStreamingAsync("Hello"))
|
||||
{
|
||||
// drain
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// SSE parse errors are acceptable; we only assert the request shape.
|
||||
}
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync()
|
||||
{
|
||||
Uri? capturedUri = null;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
capturedUri = req.RequestUri;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
try
|
||||
{
|
||||
_ = await agent.CreateConversationSessionAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing.
|
||||
}
|
||||
|
||||
Assert.NotNull(capturedUri);
|
||||
string path = capturedUri!.AbsolutePath;
|
||||
Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync()
|
||||
{
|
||||
bool meaiSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("User-Agent", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
meaiSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
|
||||
{
|
||||
// Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies
|
||||
// (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a
|
||||
// tag-stamping policy executes on each outbound per-agent request.
|
||||
bool tagSeen = false;
|
||||
using HttpHandlerAssert handler = new(req =>
|
||||
{
|
||||
if (req.Headers.TryGetValues("X-Test-Tag", out var values))
|
||||
{
|
||||
foreach (string v in values)
|
||||
{
|
||||
if (v == "tag-1")
|
||||
{
|
||||
tagSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
|
||||
};
|
||||
});
|
||||
#pragma warning disable CA5399
|
||||
using HttpClient http = new(handler);
|
||||
#pragma warning restore CA5399
|
||||
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
|
||||
opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall);
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
await agent.RunAsync("Hello");
|
||||
|
||||
Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName()
|
||||
{
|
||||
// The caller may set Endpoint/AgentName on the options bag; we must override both with
|
||||
// values derived from agentEndpoint so the URL routing is correct regardless.
|
||||
ProjectOpenAIClientOptions opts = new()
|
||||
{
|
||||
Endpoint = new Uri("https://wrong.example.com/openai/v1"),
|
||||
AgentName = "wrong-agent",
|
||||
};
|
||||
|
||||
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
|
||||
|
||||
Assert.Equal("it-happy-path", agent.Name);
|
||||
Assert.Equal(s_testAgentEndpoint, opts.Endpoint);
|
||||
Assert.Equal("it-happy-path", opts.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
|
||||
{
|
||||
// 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.Equal("my-app-id", opts.UserAgentApplicationId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ParseAgentEndpoint tests
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_StandardShape_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_TrailingSlash_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses()
|
||||
{
|
||||
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_SpecialCharsInName_Parses()
|
||||
{
|
||||
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai"));
|
||||
Assert.Equal("it-happy_path-1", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_QueryAndFragmentStripped()
|
||||
{
|
||||
var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag"));
|
||||
Assert.Equal(string.Empty, root.Query);
|
||||
Assert.Equal(string.Empty, root.Fragment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses()
|
||||
{
|
||||
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai"));
|
||||
Assert.Equal("a1", name);
|
||||
Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_MissingAgentsSegment_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_WrongSuffix_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseAgentEndpoint_EmptyAgentName_Throws()
|
||||
{
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
|
||||
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai")));
|
||||
Assert.Equal("agentEndpoint", ex.ParamName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class HeaderStampPolicy : PipelinePolicy
|
||||
{
|
||||
private readonly string _name;
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
message.Request.Headers.Set(this._name, this._value);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user