diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj index 8aa91a3406b..788f6a3f3ff 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/AGUIServer.csproj @@ -9,8 +9,8 @@ - + diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index e3de957c1e7..6f1ed5ba3ef 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ClientModel.Primitives; using System.ComponentModel; using AGUIServer; -using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; -using OpenAI.Chat; +using OpenAI; +using OpenAI.Responses; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); @@ -19,35 +20,15 @@ const string AgentName = "AGUIAssistant"; -// Create the AI agent with tools +// Create a Responses-backed OpenAI client that sends a bearer token to the Azure endpoint. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var agent = new AzureOpenAIClient( - new Uri(endpoint), - new DefaultAzureCredential()) - .GetChatClient(deploymentName) - .AsAIAgent( - name: AgentName, - tools: [ - AIFunctionFactory.Create( - () => DateTimeOffset.UtcNow, - name: "get_current_time", - description: "Get the current UTC time." - ), - AIFunctionFactory.Create( - ([Description("The weather forecast request")]ServerWeatherForecastRequest request) => { - return new ServerWeatherForecastResponse() - { - Summary = "Sunny", - TemperatureC = 25, - Date = request.Date - }; - }, - name: "get_server_weather_forecast", - description: "Gets the forecast for a specific location and date", - AGUIServerSerializerContext.Default.Options) - ]); +IChatClient chatClient = new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(model: deploymentName); // WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production, // make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user @@ -57,7 +38,26 @@ // Register the agent with the host and configure it to use an in-memory session store // so that conversation state is maintained across requests. In production, you may want to use a persistent session store. builder - .AddAIAgent(AgentName, (_, _) => agent) + .AddAIAgent(AgentName, "You are a helpful assistant.", chatClient) + .WithAITools( + new HostedWebSearchTool(), + AIFunctionFactory.Create( + () => DateTimeOffset.UtcNow, + name: "get_current_time", + description: "Get the current UTC time."), + AIFunctionFactory.Create( + ([Description("The weather forecast request")] ServerWeatherForecastRequest request) => + { + return new ServerWeatherForecastResponse() + { + Summary = "Sunny", + TemperatureC = 25, + Date = request.Date + }; + }, + name: "get_server_weather_forecast", + description: "Gets the forecast for a specific location and date", + AGUIServerSerializerContext.Default.Options)) .WithInMemorySessionStore(); WebApplication app = builder.Build(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md index 0501ec54694..30adeb1eef2 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/README.md +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/README.md @@ -18,11 +18,18 @@ The demonstration has two components: Configure the required Azure OpenAI environment variables: ```powershell -$env:AZURE_OPENAI_ENDPOINT="<>" +$env:AZURE_OPENAI_ENDPOINT="https://.openai.azure.com/openai/v1/" $env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" ``` -> **Note:** This sample uses `DefaultAzureCredential` for authentication. Make sure you're authenticated with Azure (e.g., via `az login`, Visual Studio, or environment variables). +> [!NOTE] +> Include `/openai/v1/` in the endpoint. The OpenAI SDK uses `DefaultAzureCredential` to obtain a bearer token. Make sure you're authenticated with Azure, for example through `az login`, Visual Studio, or environment variables. + +> [!NOTE] +> This sample calls Azure OpenAI inference directly through the resource endpoint. It does not require a Microsoft Foundry project. A project-scoped application would instead use a Foundry project endpoint with `Azure.AI.Projects` and the Agent Framework Foundry provider. + +> [!NOTE] +> The server uses the Azure OpenAI Responses API because hosted web search is a Responses API tool. It sets `store` to `false` so Agent Framework persists chat history in the configured session store instead of depending on service-retained responses. Web search uses Grounding with Bing and may incur additional charges; review the [web search documentation and data usage terms](https://learn.microsoft.com/azure/foundry/openai/how-to/web-search) before using it. ## Running the Sample @@ -117,13 +124,18 @@ User (:q or quit to exit): :q The `AGUIServer` uses the `MapAGUIServer` extension method to expose an agent through the AG-UI protocol: ```csharp -AIAgent agent = new OpenAIClient(apiKey) - .GetChatClient(model) - .AsAIAgent( - instructions: "You are a helpful assistant.", - name: "AGUIAssistant"); - -app.MapAGUIServer("/", agent); +IChatClient chatClient = new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = new Uri(endpoint) }) + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(model: deploymentName); + +builder + .AddAIAgent("AGUIAssistant", "You are a helpful assistant.", chatClient) + .WithAITool(new HostedWebSearchTool()) + .WithInMemorySessionStore(); + +app.MapAGUIServer("AGUIAssistant", "/"); ``` This automatically handles: diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..587af5ff339 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/AgentHostingServiceCollectionExtensionsTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using OpenAI; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +public sealed class AgentHostingServiceCollectionExtensionsTests +{ + [Fact] + public async Task HostedWebSearchTool_WithResponsesClient_UsesResponsesWireFormatAsync() + { + // Arrange + using var handler = new RecordingHandler(); +#pragma warning disable CA5399 + using var httpClient = new HttpClient(handler); +#pragma warning restore CA5399 + using IChatClient chatClient = new ResponsesClient( + new ApiKeyCredential("test-key"), + new OpenAIClientOptions + { + Endpoint = new Uri("https://example.test/v1"), + Transport = new HttpClientPipelineTransport(httpClient) + }) + .AsIChatClientWithStoredOutputDisabled(model: "test-model"); + + var services = new ServiceCollection(); + services + .AddAIAgent("test-agent", "You are a helpful assistant.", chatClient) + .WithAITool(new HostedWebSearchTool()); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + AIAgent agent = serviceProvider.GetRequiredKeyedService("test-agent"); + + // Act + await agent.RunAsync("What happened in the news today?"); + + // Assert + using JsonDocument request = JsonDocument.Parse(Assert.IsType(handler.RequestBody)); + Assert.False(request.RootElement.GetProperty("store").GetBoolean()); + Assert.Contains( + request.RootElement.GetProperty("include").EnumerateArray(), + property => property.GetString() == "reasoning.encrypted_content"); + Assert.False(request.RootElement.TryGetProperty("web_search_options", out _)); + JsonElement webSearchTool = Assert.Single(request.RootElement.GetProperty("tools").EnumerateArray()); + Assert.Equal("web_search", webSearchTool.GetProperty("type").GetString()); + Assert.Equal("/v1/responses", handler.RequestUri?.AbsolutePath); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + + public string? RequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.RequestUri = request.RequestUri; + this.RequestBody = await request.Content!.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "id": "resp_1", + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "test-model", + "output": [], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + } + } + """, + Encoding.UTF8, + "application/json"), + RequestMessage = request + }; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj index 17d9742436c..355e271d767 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj @@ -19,6 +19,7 @@ +