diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a07bde035b..1a4c5635a5 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj index 0db6ba9fe6..a2787b4130 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj index 8871ea5242..366894856c 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj index 359e8a35bb..270a3f7391 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj index 31dafe2280..0029f66e39 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Observability/HostedObservability.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj index ed24c32eea..a0138c710a 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj index 458518c67b..e54d2f1b37 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj index 75d012413d..57c5852455 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -11,7 +11,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 209edd9a82..71d9af8f71 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworksCore) @@ -31,7 +31,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs index db66b30a88..6174e1b149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgent.cs @@ -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; + /// + /// 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"; + + /// + /// The cached 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 . + /// + private readonly AIProjectClient? _aiProjectClient; + + /// + /// Project-scoped . Always non-null. Used for project-level + /// operations such as . + /// 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 + /// . + /// + private readonly ProjectOpenAIClient _projectOpenAIClient; /// /// Initializes a new instance of the class using the direct Responses API path. @@ -72,30 +94,49 @@ public FoundryAgent( out var aiProjectClient)) { this._aiProjectClient = aiProjectClient; + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } /// /// Initializes a new instance of the class from an agent-specific endpoint. /// - /// The agent-specific endpoint URI (must contain the agent name in the path). + /// + /// The agent-specific endpoint URI. Must be of the shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. + /// /// The authentication credential. - /// Optional configuration options for the . + /// + /// Optional configuration for the underlying . When supplied: + /// + /// The instance is passed through to the per-agent client; pipeline policies added via AddPolicy(...) on it execute on the per-agent traffic. + /// Endpoint and are owned by this constructor and are overwritten with values derived from ; any caller value is replaced. + /// For the project-level conversations client a separate fresh options bag is built that copies only , , , and UserAgentApplicationId; pipeline policies added via AddPolicy(...) do not propagate to the conversations pipeline. + /// + /// /// Optional tools to use when interacting with the agent. /// Provides a way to customize the creation of the underlying . /// Optional service provider for resolving dependencies required by AI functions. + /// or is null. + /// does not match the expected agent-endpoint shape. + /// + /// This is the lightweight constructor for invoking an existing Foundry hosted agent when the + /// caller already has the per-agent endpoint URL. It populates + /// and from the agent name parsed out of the endpoint + /// path; Description, Instructions, Temperature, and TopP are not + /// populated. Callers that need those fields hydrated from server-side state should use + /// AIProjectClient.AsAIAgent(ProjectsAgentVersion) or + /// AIProjectClient.AsAIAgent(ProjectsAgentRecord) instead. + /// public FoundryAgent( Uri agentEndpoint, AuthenticationTokenProvider credential, - AIProjectClientOptions? clientOptions = null, + ProjectOpenAIClientOptions? clientOptions = null, IList? tools = null, Func? 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); } /// @@ -105,6 +146,7 @@ internal FoundryAgent(AIProjectClient aiProjectClient, ChatClientAgent innerAgen : base(WireClientHeaders(Throw.IfNull(innerAgent))) { this._aiProjectClient = Throw.IfNull(aiProjectClient); + this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient(); } #region Convenience methods @@ -137,9 +179,7 @@ public ValueTask CreateSessionAsync(string conversationId, Cancell /// A linked to the newly created server-side conversation. public async Task 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 @@ private ChatClientAgent GetInnerChatClientAgent() => return this._aiProjectClient; } + if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient)) + { + return this._projectOpenAIClient; + } + return base.GetService(serviceType, serviceKey); } @@ -238,38 +283,172 @@ private static AIAgent WireClientHeaders(ChatClientAgent innerAgent) OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, ClientHeadersPolicy.Instance, - System.ClientModel.Primitives.PipelinePosition.PerCall); + PipelinePosition.PerCall); } return new ClientHeadersAgent(innerAgent); } - private static AIAgent CreateInnerAgentFromEndpoint( - AIProjectClient aiProjectClient, + /// + /// Builds the inner for the agent-endpoint constructor by + /// constructing a per-agent via the + /// ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions) + /// constructor with 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 api-version query string. + /// Caller-supplied are passed through to the per-agent + /// client with Endpoint and + /// overridden by values derived from + /// ; any policies the caller added via AddPolicy + /// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last. + /// + private static AIAgent CreateInnerAgentFromAgentEndpoint( Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions, IList? tools, Func? clientFactory, - IServiceProvider? services, - out AIProjectClient outClient) + IServiceProvider? services) { - outClient = aiProjectClient; + Throw.IfNull(agentEndpoint); + Throw.IfNull(credential); + + var (agentName, _) = ParseAgentEndpoint(agentEndpoint); + + var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); + perAgentOptions.Endpoint = agentEndpoint; + perAgentOptions.AgentName = agentName; + perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); - AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/'); + 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() { - Name = agentReference.Name, + Id = agentName, + Name = agentName, ChatOptions = new() { Tools = tools }, }; - IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions); + return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); + } - if (clientFactory is not null) + /// + /// Builds the project-scoped for the agent-endpoint + /// constructor by deriving the project root from the supplied agent endpoint and constructing + /// a fresh client without so the SDK + /// appends the standard /openai/v1 suffix expected for project-level surfaces such as + /// conversations. + /// + /// + /// Only the four observable primitive properties (, + /// , , + /// and UserAgentApplicationId) are copied from the caller's options bag. Pipeline + /// policies added via AddPolicy on the caller bag do not propagate because + /// does not publicly enumerate its policies. The MEAI + /// user-agent policy is appended last. + /// + private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint( + Uri agentEndpoint, + AuthenticationTokenProvider credential, + ProjectOpenAIClientOptions? clientOptions) + { + var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint); + + var projectOptions = new ProjectOpenAIClientOptions(); + if (clientOptions is not null) { - chatClient = clientFactory(chatClient); + 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; + } } - return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services)); + projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall); + + return new ProjectOpenAIClient(projectRoot, credential, projectOptions); + } + + /// + /// Parses an agent endpoint URI of shape + /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai + /// and returns the agent name and the derived project-root URI. + /// + /// + /// Single source of truth for both agent-name extraction and project-root derivation. + /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and + /// strips query string and fragment. Throws for inputs that + /// do not match the expected shape. + /// + /// + /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a + /// suffix other than /endpoint/protocols/openai. + /// + 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:///.../projects//agents//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 '{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) @@ -278,7 +457,7 @@ private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationT 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); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 71e7df373f..06e22b8c18 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -1,7 +1,10 @@ - true + true $(NoWarn);OPENAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj index fa28a32494..4a139df1b6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs index 45d09689ff..01184031e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryAgentTests.cs @@ -7,6 +7,7 @@ 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 void Constructor_FromAsAIAgentExtension_PreWiresClientHeadersAgent() // 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()); @@ -398,4 +399,379 @@ public async Task Constructor_UserAgentHeaderAddedToRequestsAsync() } #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(() => + new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider())); + Assert.Equal("agentEndpoint", ex.ParamName); + } + + [Fact] + public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException() + { + ArgumentNullException ex = Assert.Throws(() => + 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()); + } + + [Fact] + public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull() + { + FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider()); + + Assert.Null(agent.GetService()); + } + + [Fact] + public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull() + { + FoundryAgent agent = new( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + model: "gpt-4o-mini", + instructions: "Test"); + + Assert.NotNull(agent.GetService()); + } + + [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(); + 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(() => + 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(() => + 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(() => + 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 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 pipeline, int currentIndex) + { + message.Request.Headers.Set(this._name, this._value); + return ProcessNextAsync(message, pipeline, currentIndex); + } + } } 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 3b7176711a..b17efa64f9 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 @@ -1,4 +1,4 @@ - + false @@ -7,7 +7,7 @@ - +