diff --git a/README.md b/README.md index e0d648bf..79c605cb 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,17 @@ The AI agent definition would likely be deployed from your application's pipelin | :information: | As a result, the agent becomes a nested Azure resource visible in the Azure control plane. Publishing the chat agent automatically created a dedicated agent identity blueprint and agent identity. Both are bound to the Azure Foundry application resource. This distinct identity represents the chat agent's system authority for accessing its own resources. Reassigning RBAC permissions was required so the new agent identity get permissions to access the conversation, vector store and storage resources. At this deployment time, it was a great moment to reassess only the permissions the agent needs for its tool actions. | | :-------: | :------------------------- | + **Understanding RBAC for published agent invocation** + + Invoking a published agent through the Responses API requires the calling identity to hold the **Azure AI User** role at two scopes: the **Foundry project** and the **agent application** resource. The project-scope assignment grants the `Microsoft.MachineLearningServices/workspaces/agents/action` permission, which authorizes the caller to interact with the agent runtime. The application-scope assignment restricts which specific published agent the caller can reach. + + This two-level model gives you fine-grained control over agent access: + + - **Onboarding access to a single agent.** Assign Azure AI User at the project level *and* at that agent's application resource. The project-level assignment is a one-time prerequisite; the application-level assignment is what gates access to the specific agent. + - **Onboarding access to a second agent.** The project-level assignment is already in place. You only need to add an application-level assignment on the new agent's application resource. + - **Removing access to a single agent.** Remove the application-level assignment for that agent. The caller retains access to any other agents where it still has an application-level assignment. + - **Removing access to all agents.** Remove the project-level assignment. Without it, no application-level assignment is sufficient to invoke any agent in the project. + 1. Verify the agent deployment is running *This step verify the Foundry AI Agent Service deployment is runnning by invoking the agent application's responses endpoint.* @@ -334,27 +345,44 @@ For this deployment guide, you'll continue using your jump box to simulate part Invoke-WebRequest -Uri https://github.com/Azure-Samples/microsoft-foundry-baseline/raw/refs/heads/main/website/chatui.zip -OutFile chatui.zip ``` +1. *(Recommended)* Verify the integrity of the downloaded zip file before uploading. + + From your **local workstation** (bash): + + ```bash + sha256sum website/chatui.zip + ``` + + From the **jump box** (PowerShell): + + ```powershell + (Get-FileHash chatui.zip -Algorithm SHA256).Hash + ``` + + Both outputs should match the same SHA-256 hash. + 1. Upload the web application to Azure Storage, where the web app will load the code from. ```powershell az storage blob upload -f chatui.zip --account-name "stwebapp${BASE_NAME}" --auth-mode login -c deploy -n chatui.zip ``` -1. Update the app configuration to use the agent you deployed. +1. Update the app configuration to use the published agent application endpoint. ```powershell - az webapp config appsettings set -n "app-${BASE_NAME}" -g $RESOURCE_GROUP --settings AIAgentId="${AGENT_ID}" + az webapp config appsettings set -n "app-${BASE_NAME}" -g $RESOURCE_GROUP --settings AgentBaseUrl="${AGENT_BASE_URL}" ``` -1. Restart the web app to load the site code and its updated configuation. +1. Stop and start the web app to load the site code, its updated configuration, and acquire a fresh authentication token. ```powershell - az webapp restart --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP + az webapp stop --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP + az webapp start --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP ``` ### 5. Try it out! Test the deployed application that calls into the Foundry Agent Service -This section will help you to validate that the workload is exposed correctly and responding to HTTP requests. This will validate that traffic is flowing through Application Gateway, into your Web App, and from your Web App, into the Foundry agent API endpoint, which hosts the agent and its chat history. The agent will interface with Bing for grounding data and an OpenAI model for generative responses. +This section will help you to validate that the workload is exposed correctly and responding to HTTP requests. This will validate that traffic is flowing through Application Gateway, into your Web App, and from your Web App, into the published agent application endpoint. The agent will interface with Bing for grounding data and an OpenAI model for generative responses. | :computer: | Unless otherwise noted, the following steps are all performed from your original workstation, not from the jump box. | | :--------: | :------------------------- | diff --git a/infra-as-code/bicep/ai-foundry-appdeploy.bicep b/infra-as-code/bicep/ai-foundry-appdeploy.bicep index 83859ef9..3e8fc7ac 100644 --- a/infra-as-code/bicep/ai-foundry-appdeploy.bicep +++ b/infra-as-code/bicep/ai-foundry-appdeploy.bicep @@ -11,6 +11,17 @@ param agentVersion string = '1' // ---- Existing resources ---- +@description('Built-in Role: [Azure AI User](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)') +resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' + scope: subscription() +} + +@description('Existing App Service managed identity. Needs Azure AI User role on the Foundry project and Agent Application to invoke the published agent.') +resource appServiceManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = { + name: 'id-app-${baseName}' +} + // Storage Blob Data Owner Role resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' @@ -204,5 +215,27 @@ module agentContainersWriterSqlAssignment './modules/cosmosdbSqlRoleAssignment.b } } +@description('Grant the App Service managed identity Azure AI User role on the Foundry project. Required for Microsoft.MachineLearningServices/workspaces/agents/action when invoking a published agent.') +resource azureAiUserProjectRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: foundry::project + name: guid(foundry::project.id, appServiceManagedIdentity.id, azureAiUserRole.id) + properties: { + roleDefinitionId: azureAiUserRole.id + principalType: 'ServicePrincipal' + principalId: appServiceManagedIdentity.properties.principalId + } +} + +@description('Grant the App Service managed identity Azure AI User role on the Agent Application for least-privilege invocation via the Responses API.') +resource azureAiUserAppRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: foundry::project::application + name: guid(foundry::project::application.id, appServiceManagedIdentity.id, azureAiUserRole.id) + properties: { + roleDefinitionId: azureAiUserRole.id + principalType: 'ServicePrincipal' + principalId: appServiceManagedIdentity.properties.principalId + } +} + // ---- Outputs ---- output agentApplicationBaseUrl string = foundry::project::application.properties.baseUrl diff --git a/infra-as-code/bicep/main.bicep b/infra-as-code/bicep/main.bicep index c8605412..67200262 100644 --- a/infra-as-code/bicep/main.bicep +++ b/infra-as-code/bicep/main.bicep @@ -225,8 +225,6 @@ module deployWebApp 'web-app.bicep' = { privateEndpointsSubnetName: deployVirtualNetwork.outputs.privateEndpointsSubnetName existingWebAppDeploymentStorageAccountName: deployWebAppStorage.outputs.appDeployStorageName existingWebApplicationInsightsResourceName: deployApplicationInsights.outputs.applicationInsightsName - existingFoundryResourceName: deployFoundry.outputs.foundryName - existingFoundryProjectName: deployFoundryProject.outputs.aiAgentProjectName } } diff --git a/infra-as-code/bicep/web-app.bicep b/infra-as-code/bicep/web-app.bicep index d9196143..0b49b2ea 100644 --- a/infra-as-code/bicep/web-app.bicep +++ b/infra-as-code/bicep/web-app.bicep @@ -41,13 +41,6 @@ param existingWebAppDeploymentStorageAccountName string @minLength(1) param existingWebApplicationInsightsResourceName string -@description('The name of the existing Microsoft Foundry instance that the Azure Web App code will be calling for Foundry Agent Service agents.') -@minLength(2) -param existingFoundryResourceName string - -@description('The name of the existing Foundry project name.') -@minLength(2) -param existingFoundryProjectName string // variables var appName = 'app-${baseName}' @@ -85,32 +78,10 @@ resource blobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' scope: subscription() } -@description('Built-in Role: [Azure AI User](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)') -resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { - name: '53ca6127-db72-4b80-b1b0-d745d6d5456d' - scope: subscription() -} - -// If your web app/API code is going to be creating agents dynamically, you will need to assign a role such as this to App Service managed identity. -/*@description('Built-in Role: [Azure AI Project Manager](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)') -resource azureAiProjectManagerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { - name: 'eadc314b-1a2d-4efa-be10-5d325db5065e' - scope: subscription() -}*/ - resource appServiceExistingPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { name: 'privatelink.azurewebsites.net' } -@description('Existing Foundry account. This account is where the agents hosted in Foundry Agent Service will be deployed. The web app code calls to these agents.') -resource foundry 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = { - name: existingFoundryResourceName - - resource project 'projects' existing = { - name: existingFoundryProjectName - } -} - // ---- New resources ---- @description('Managed Identity for App Service') @@ -130,28 +101,6 @@ resource blobDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2 } } -@description('Grant the App Service managed identity Azure AI user role permission so it can call into the Foundry-hosted agent.') -resource azureAiUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: foundry - name: guid(foundry.id, appServiceManagedIdentity.id, azureAiUserRole.id) - properties: { - roleDefinitionId: azureAiUserRole.id - principalType: 'ServicePrincipal' - principalId: appServiceManagedIdentity.properties.principalId - } -} - -/*@description('Grant the App Service managed identity Azure AI manager role permission so it create the Foundry-hosted agent. Only needed if your code creates agents directly.') -resource azureAiManagerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - scope: foundry - name: guid(foundry.id, appServiceManagedIdentity.id, azureAiProjectManagerRole.id) - properties: { - roleDefinitionId: azureAiProjectManagerRole.id - principalType: 'ServicePrincipal' - principalId: appServiceManagedIdentity.properties.principalId - } -}*/ - @description('Linux, PremiumV3 App Service Plan to host the chat web application.') resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { name: 'asp-${appName}${uniqueString(subscription().subscriptionId)}' @@ -219,8 +168,8 @@ resource webApp 'Microsoft.Web/sites@2024-04-01' = { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString AZURE_CLIENT_ID: appServiceManagedIdentity.properties.clientId ApplicationInsightsAgent_EXTENSION_VERSION: '~3' - AIProjectEndpoint: foundry::project.properties.endpoints['AI Foundry API'] - AIAgentId: 'Not yet set' // Will be set once the agent is created + AgentBaseUrl: 'Not yet set' // Will be set via CLI after agent is published as an application + AgentModelDeploymentName: 'agent-model' XDT_MicrosoftApplicationInsights_Mode: 'Recommended' } } diff --git a/website/chatui.zip b/website/chatui.zip old mode 100755 new mode 100644 index cca1f243..d0b6d243 Binary files a/website/chatui.zip and b/website/chatui.zip differ diff --git a/website/chatui/Configuration/ChatApiOptions.cs b/website/chatui/Configuration/ChatApiOptions.cs index 4aea563d..e5c5f318 100644 --- a/website/chatui/Configuration/ChatApiOptions.cs +++ b/website/chatui/Configuration/ChatApiOptions.cs @@ -5,8 +5,9 @@ namespace chatui.Configuration; public class ChatApiOptions { [Url] - public string AIProjectEndpoint { get; init; } = default!; + public string AgentBaseUrl { get; init; } = default!; - [Required] - public string AIAgentId { get; init; } = default!; + public string AgentApiVersion { get; init; } = "2025-11-15-preview"; + + public string AgentModelDeploymentName { get; init; } = "agent-model"; } \ No newline at end of file diff --git a/website/chatui/Controllers/ChatController.cs b/website/chatui/Controllers/ChatController.cs index 70c4e8d2..d291c44f 100755 --- a/website/chatui/Controllers/ChatController.cs +++ b/website/chatui/Controllers/ChatController.cs @@ -1,62 +1,44 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; using OpenAI.Responses; using chatui.Configuration; namespace chatui.Controllers; +#pragma warning disable OPENAI001 // Responses API is in preview + [ApiController] [Route("[controller]/[action]")] public class ChatController( - AIProjectClient projectClient, + ResponsesClient responsesClient, IOptionsMonitor options, ILogger logger) : ControllerBase { - private readonly AIProjectClient _projectClient = projectClient; + private readonly ResponsesClient _responsesClient = responsesClient; private readonly IOptionsMonitor _options = options; private readonly ILogger _logger = logger; - // TODO: [security] Do not trust client to provide conversationId. Instead map current user to their active conversationId in your application's own state store. - // Without this security control in place, a user can inject messages into another user's conversation. - [HttpPost("{conversationId}")] - public async Task Completions([FromRoute] string conversationId, [FromBody] string message) + [HttpPost] + public async Task Responses([FromBody] ResponsesRequest request) { - if (string.IsNullOrWhiteSpace(message)) - throw new ArgumentException("Message cannot be null, empty, or whitespace.", nameof(message)); - _logger.LogDebug("Prompt received {Prompt}", message); - - // MessageResponseItem is currently intended for evaluation purposes and therefore requires explicit suppression of compiler diagnostics. - #pragma warning disable OPENAI001 - MessageResponseItem userMessageResponseItem = ResponseItem.CreateUserMessageItem( - [ResponseContentPart.CreateInputTextPart(message)]); - - var _config = _options.CurrentValue; - AgentRecord agentRecord = await _projectClient.Agents.GetAgentAsync(_config.AIAgentId); - var agent = agentRecord.Versions.Latest; - - ProjectResponsesClient responsesClient - = _projectClient.OpenAI.GetProjectResponsesClientForAgent(agent, conversationId); + if (request.Messages is not { Length: > 0 }) + throw new ArgumentException("At least one message is required."); + _logger.LogDebug("Prompt received {Prompt}", request.Messages[^1].Content); - var agentResponseItem = await responsesClient.CreateResponseAsync([userMessageResponseItem]); + var items = request.Messages.Select(m => m.Role switch + { + "user" => ResponseItem.CreateUserMessageItem(m.Content), + "assistant" => ResponseItem.CreateAssistantMessageItem(m.Content), + _ => throw new ArgumentException($"Unsupported message role: {m.Role}") + }).ToList(); - var fullText = agentResponseItem.Value.GetOutputText(); + var response = await _responsesClient.CreateResponseAsync(model: _options.CurrentValue.AgentModelDeploymentName, items); + var fullText = response.Value.GetOutputText(); return Ok(new { data = fullText }); } +} - [HttpPost] - public async Task Conversations() - { - // TODO [performance efficiency] Delay creating a conversation until the first user message arrives. - ProjectConversationCreationOptions conversationOptions = new(); - - ProjectConversation conversation - = await _projectClient.OpenAI.Conversations.CreateProjectConversationAsync( - conversationOptions); - - return Ok(new { id = conversation.Id }); - } -} \ No newline at end of file +public record ChatMessage(string Role, string Content); +public record ResponsesRequest(ChatMessage[] Messages); \ No newline at end of file diff --git a/website/chatui/Program.cs b/website/chatui/Program.cs index e502b6b1..f18cd2e9 100644 --- a/website/chatui/Program.cs +++ b/website/chatui/Program.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; -using Azure.AI.Projects; using Azure.Identity; +using OpenAI; +using System.ClientModel; using chatui.Configuration; var builder = WebApplication.CreateBuilder(args); @@ -10,12 +11,19 @@ .ValidateDataAnnotations() .ValidateOnStart(); -builder.Services.AddSingleton((provider) => +builder.Services.AddSingleton(provider => { var config = provider.GetRequiredService>().Value; - AIProjectClient projectClient = new(new Uri(config.AIProjectEndpoint), new DefaultAzureCredential()); + var baseUrl = new Uri($"{config.AgentBaseUrl.TrimEnd('/')}/protocols/openai?api-version={config.AgentApiVersion}"); - return projectClient; + // TODO: Token is fetched once at startup and will expire. Replace with a + // delegating handler or token-refresh wrapper for production use. + var token = new DefaultAzureCredential() + .GetToken(new Azure.Core.TokenRequestContext(["https://ai.azure.com/.default"])); + + #pragma warning disable OPENAI001 // Responses API is in preview + return new OpenAIClient(new ApiKeyCredential(token.Token), new OpenAIClientOptions { Endpoint = baseUrl }) + .GetResponsesClient(); }); builder.Services.AddControllersWithViews(); diff --git a/website/chatui/Views/Home/Index.cshtml b/website/chatui/Views/Home/Index.cshtml index 3110fcd1..848d4b9a 100644 --- a/website/chatui/Views/Home/Index.cshtml +++ b/website/chatui/Views/Home/Index.cshtml @@ -180,8 +180,7 @@ const chatContainer = document.querySelector(".msger-chat"); const messageInput = chatForm?.elements?.message; - const { id } = await createThread(); - const threadId = id; + const messages = []; addChatMessage(BOT_NAME, "left", "How can I help you today?"); @@ -193,41 +192,30 @@ messageInput.value = ""; + messages.push({ role: "user", content: prompt }); addChatMessage(PERSON_NAME, "right", prompt); try { - const { data } = await sendPrompt(prompt); + const { data } = await sendMessages(messages); + messages.push({ role: "assistant", content: data }); addChatMessage(BOT_NAME, "left", data); } catch (error) { + messages.pop(); addChatMessage(BOT_NAME, "left", `Sorry, something went wrong.`); console.error(error); } }); - async function sendPrompt(prompt) { - const response = await fetch(`/chat/completions/${threadId}`, { + async function sendMessages(messages) { + const response = await fetch("/chat/responses", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(prompt) + body: JSON.stringify({ messages }) }); if (!response.ok) { const errorMessage = await response.text().catch(() => response.statusText); - throw new Error(`Error sending prompt: ${errorMessage}`); - } - - return response.json(); - } - - async function createThread() { - const response = await fetch("/chat/conversations", { - method: "POST", - headers: { "Content-Type": "application/json" } - }); - - if (!response.ok) { - const errorMessage = await response.text().catch(() => response.statusText); - throw new Error(`Error creating session: ${errorMessage}`); + throw new Error(`Error sending messages: ${errorMessage}`); } return response.json(); diff --git a/website/chatui/appsettings.json b/website/chatui/appsettings.json index c39267ba..6578fb11 100644 --- a/website/chatui/appsettings.json +++ b/website/chatui/appsettings.json @@ -12,6 +12,5 @@ } }, "AllowedHosts": "*", - "AIProjectEndpoint": "https://.services.ai.azure.com/api/projects/", - "AIAgentId": "" + "AgentBaseUrl": "https://.services.ai.azure.com/api/projects//applications/" } \ No newline at end of file diff --git a/website/chatui/chatui.csproj b/website/chatui/chatui.csproj index 88d00d62..d08e6da0 100644 --- a/website/chatui/chatui.csproj +++ b/website/chatui/chatui.csproj @@ -9,7 +9,7 @@ - +