diff --git a/.config/feature-flags.json b/.config/feature-flags.json new file mode 100644 index 0000000..1ff60c6 --- /dev/null +++ b/.config/feature-flags.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": "2.0.0", + "feature_management": { + "feature_flags": [ + ] + } +} \ No newline at end of file diff --git a/.config/feature-flags.json.sample b/.config/feature-flags.json.sample new file mode 100644 index 0000000..5aa1bd7 --- /dev/null +++ b/.config/feature-flags.json.sample @@ -0,0 +1,40 @@ +{ + "schemaVersion": "2.0.0", + "feature_management": { + "feature_flags": [ + { + "id": "my-agent", + "enabled": true, + "variants": [ + { + "name": "agent_v1", + "configuration_value": "" + }, + { + "name": "agent_v2", + "configuration_value": "" + } + ], + "allocation": { + "percentile": [ + { + "variant": "agent_v1", + "from": 0, + "to": 50 + }, + { + "variant": "agent_v2", + "from": 50, + "to": 100 + } + ], + "default_when_enabled": "agent_v1", + "default_when_disabled": "agent_v1" + }, + "telemetry": { + "enabled": true + } + } + ] + } +} \ No newline at end of file diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index c95fbaa..df15152 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -18,7 +18,9 @@ permissions: jobs: build: + name: Deploy runs-on: ubuntu-latest + if: ${{ vars.AZURE_CLIENT_ID != '' }} env: AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} @@ -74,3 +76,28 @@ jobs: - name: Deploy Application run: azd deploy --no-prompt + + update-experiments: + name: Update Experiments + needs: build + runs-on: ubuntu-latest + env: + APP_CONFIGURATION_FILE: .config/feature-flags.json + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Azure login using Federated Credentials + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + enable-AzPSSession: true + + - name: Deploy App Config feature flags + uses: azure/app-configuration-deploy-feature-flags@v1-beta + with: + path: ${{ env.APP_CONFIGURATION_FILE }} + app-configuration-endpoint: ${{ vars.APP_CONFIGURATION_ENDPOINT }} + strict: false diff --git a/.github/workflows/experiment-validate.yml b/.github/workflows/experiment-validate.yml new file mode 100644 index 0000000..249433d --- /dev/null +++ b/.github/workflows/experiment-validate.yml @@ -0,0 +1,30 @@ +name: Validate Experiments +on: + workflow_dispatch: + +# GitHub Actions workflow to deploy to Azure using azd +# To configure required secrets for connecting to Azure, simply run `azd pipeline config` + +# Set up permissions for deploying with secretless Azure federated credentials +# https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication +permissions: + id-token: write + contents: read + +env: + APP_CONFIGURATION_FILE: .config/feature-flags.json + +jobs: + validate-feature-flags: + name: Validate Feature Flags + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate App Config feature flags + uses: azure/app-configuration-deploy-feature-flags@v1-beta + with: + path: ${{ env.APP_CONFIGURATION_FILE }} + operation: validate + \ No newline at end of file diff --git a/.github/workflows/template-validation.yml b/.github/workflows/template-validation.yml index 486a273..ce72857 100644 --- a/.github/workflows/template-validation.yml +++ b/.github/workflows/template-validation.yml @@ -52,5 +52,6 @@ jobs: AZURE_AI_EMBED_MODEL_FORMAT: ${{ vars.AZURE_AI_EMBED_MODEL_FORMAT }} AZURE_AI_EMBED_MODEL_VERSION: ${{ vars.AZURE_AI_EMBED_MODEL_VERSION }} AZURE_EXISTING_AIPROJECT_CONNECTION_STRING: ${{ vars.AZURE_EXISTING_AIPROJECT_CONNECTION_STRING }} + APP_CONFIGURATION_ENDPOINT: ${{ vars.APP_CONFIGURATION_ENDPOINT }} - name: print result run: cat ${{ steps.validation.outputs.resultFile }} diff --git a/.gitignore b/.gitignore index 63257df..9757137 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ ENV/ env.bak/ venv.bak/ .azure +.vscode/ \ No newline at end of file diff --git a/azure.yaml b/azure.yaml index 91e8ebc..8e82ee6 100644 --- a/azure.yaml +++ b/azure.yaml @@ -50,4 +50,5 @@ pipeline: - AZURE_AI_EMBED_MODEL_VERSION - AZURE_AI_EMBED_DIMENSIONS - AZURE_AI_SEARCH_INDEX_NAME - - AZURE_EXISTING_AIPROJECT_CONNECTION_STRING \ No newline at end of file + - AZURE_EXISTING_AIPROJECT_CONNECTION_STRING + - APP_CONFIGURATION_ENDPOINT \ No newline at end of file diff --git a/infra/api.bicep b/infra/api.bicep index febe429..d214376 100644 --- a/infra/api.bicep +++ b/infra/api.bicep @@ -14,6 +14,7 @@ param searchServiceEndpoint string param agentName string param agentID string param projectName string +param appConfigurationEndpoint string resource apiIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -65,6 +66,10 @@ var env = [ name: 'AZURE_AI_SEARCH_ENDPOINT' value: searchServiceEndpoint } + { + name: 'APP_CONFIGURATION_ENDPOINT' + value: appConfigurationEndpoint + } ] diff --git a/infra/core/config/configstore.bicep b/infra/core/config/configstore.bicep index 96818f1..fe14c32 100644 --- a/infra/core/config/configstore.bicep +++ b/infra/core/config/configstore.bicep @@ -6,6 +6,9 @@ param name string @description('The Azure region/location for the Azure App Configuration store') param location string = resourceGroup().location +@description('The SKU for the Azure App Configuration store') +param sku string + @description('Custom tags to apply to the Azure App Configuration store') param tags object = {} @@ -15,16 +18,29 @@ param keyValueNames array = [] @description('Specifies the values of the key-value resources.') param keyValueValues array = [] -@description('The principal ID to grant access to the Azure App Configuration store') -param principalId string +@description('The Application Insights ID linked to the Azure App Configuration store') +param appInsightsName string -resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { +resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-09-01-preview' = { name: name location: location sku: { - name: 'standard' + name: sku } tags: tags + properties: { + encryption: {} + disableLocalAuth: true + enablePurgeProtection: false + experimentation:{} + dataPlaneProxy:{ + authenticationMode: 'Pass-through' + privateLinkDelegation: 'Disabled' + } + telemetry: { + resourceId: appInsights.id + } + } } resource configStoreKeyValue 'Microsoft.AppConfiguration/configurationStores/keyValues@2023-03-01' = [for (item, i) in keyValueNames: { @@ -36,13 +52,9 @@ resource configStoreKeyValue 'Microsoft.AppConfiguration/configurationStores/key } }] -module configStoreAccess '../security/configstore-access.bicep' = { - name: 'app-configuration-access' - params: { - configStoreName: name - principalId: principalId - } - dependsOn: [configStore] +resource appInsights 'Microsoft.Insights/components@2020-02-02-preview' existing = { + name: appInsightsName } output endpoint string = configStore.properties.endpoint +output name string = configStore.name diff --git a/infra/core/security/configstore-access.bicep b/infra/core/security/configstore-access.bicep deleted file mode 100644 index de72b94..0000000 --- a/infra/core/security/configstore-access.bicep +++ /dev/null @@ -1,21 +0,0 @@ -@description('Name of Azure App Configuration store') -param configStoreName string - -@description('The principal ID of the service principal to assign the role to') -param principalId string - -resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' existing = { - name: configStoreName -} - -var configStoreDataReaderRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '516239f1-63e1-4d78-a4de-a74fb236a071') - -resource configStoreDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(subscription().id, resourceGroup().id, principalId, configStoreDataReaderRole) - scope: configStore - properties: { - roleDefinitionId: configStoreDataReaderRole - principalId: principalId - principalType: 'ServicePrincipal' - } -} diff --git a/infra/main.bicep b/infra/main.bicep index 1892ad4..b551d38 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -110,12 +110,25 @@ param embedDeploymentSku string = 'Standard' // https://learn.microsoft.com/azure/ai-services/openai/quotas-limits param embedDeploymentCapacity int = 30 +@description('Whether to use Azure Application Insights') param useApplicationInsights bool = true + +@description('Whether to use Azure App Configuration') +param useAppConfiguration bool = true + +@description('Sku for the App Configuration') + // Recommend to upgrade for production use. See pricing plan: + // https://azure.microsoft.com/en-us/pricing/details/app-configuration/ + param appConfigurationSku string = 'free' + @description('Do we want to use the Azure AI Search') param useSearchService bool = false +@description('Id of the user or app to assign application roles') +param principalId string = '' + @description('Random seed to be used during generation of new resources suffixes.') -param seed string = newGuid() +param seed string = '' //newGuid() // why do we need this? var abbrs = loadJsonContent('./abbreviations.json') var resourceToken = toLower(uniqueString(subscription().id, environmentName, location, seed)) @@ -264,6 +277,19 @@ module containerApps 'core/host/container-apps.bicep' = { } } +// App Configuration +module configStore 'core/config/configstore.bicep' = if (useApplicationInsights && useAppConfiguration) { + name: 'config-store' + scope: rg + params: { + location: location + sku: appConfigurationSku + name: '${abbrs.appConfigurationStores}${resourceToken}' + tags: tags + appInsightsName: ai.outputs.applicationInsightsName + } +} + // API app module api 'api.bicep' = { name: 'api' @@ -284,6 +310,25 @@ module api 'api.bicep' = { agentName: agentName agentID: agentID projectName: projectName + appConfigurationEndpoint: configStore.outputs.endpoint + } +} + +module userRoleConfigStoreDataOwner 'core/security/role.bicep' = { + name: 'user-role-config-store-data-owner' + scope: rg + params: { + principalId: principalId + roleDefinitionId: '5ae67dd6-50cb-40e7-96ff-dc2bfa4b606b' + } +} + +module backendRoleConfigStoreDataOwner 'core/security/role.bicep' = { + name: 'backend-role-config-store-data-reader' + scope: rg + params: { + principalId: api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID + roleDefinitionId: '516239f1-63e1-4d78-a4de-a74fb236a071' } } diff --git a/infra/main.parameters.json b/infra/main.parameters.json index 84567d0..879492a 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -50,6 +50,12 @@ "useSearchService": { "value": "${USE_AZURE_AI_SEARCH_SERVICE=false}" }, + "useAppConfiguration": { + "value": "${USE_APP_CONFIGURATION=true}" + }, + "appConfigurationSku": { + "value": "${APP_CONFIGURATION_SKU=free}" + }, "agentName": { "value": "${AZURE_AI_AGENT_NAME=agent-template-assistant}" }, diff --git a/scripts/write_env.ps1 b/scripts/write_env.ps1 index e808223..b86e3ce 100644 --- a/scripts/write_env.ps1 +++ b/scripts/write_env.ps1 @@ -16,6 +16,7 @@ $azureAIEmbedDimensions = azd env get-value AZURE_AI_EMBED_DIMENSIONS $azureAISearchIndexName = azd env get-value AZURE_AI_SEARCH_INDEX_NAME $azureAISearchEndpoint = azd env get-value AZURE_AI_SEARCH_ENDPOINT $serviceAPIUri = azd env get-value SERVICE_API_URI +$appConfigurationEndpoint = azd env get-value APP_CONFIGURATION_ENDPOINT Add-Content -Path $envFilePath -Value "AZURE_EXISTING_AIPROJECT_CONNECTION_STRING=$azureAiProjectConnectionString" Add-Content -Path $envFilePath -Value "AZURE_AI_AGENT_DEPLOYMENT_NAME=$azureAiagentDeploymentName" @@ -28,6 +29,7 @@ Add-Content -Path $envFilePath -Value "AZURE_AI_SEARCH_INDEX_NAME=$azureAISearch Add-Content -Path $envFilePath -Value "AZURE_AI_SEARCH_ENDPOINT=$azureAISearchEndpoint" Add-Content -Path $envFilePath -Value "AZURE_AI_AGENT_NAME=$azureAiAgentName" Add-Content -Path $envFilePath -Value "AZURE_TENANT_ID=$azureTenantId" +Add-Content -Path $envFilePath -Value "APP_CONFIGURATION_ENDPOINT=$appConfigurationEndpoint" Write-Host "Web app URL:" Write-Host $serviceAPIUri -ForegroundColor Cyan \ No newline at end of file diff --git a/scripts/write_env.sh b/scripts/write_env.sh index 1499ed0..25035cf 100755 --- a/scripts/write_env.sh +++ b/scripts/write_env.sh @@ -17,6 +17,7 @@ echo "AZURE_AI_SEARCH_INDEX_NAME=$(azd env get-value AZURE_AI_SEARCH_INDEX_NAME) echo "AZURE_AI_SEARCH_ENDPOINT=$(azd env get-value AZURE_AI_SEARCH_ENDPOINT)" >> $ENV_FILE_PATH echo "AZURE_AI_AGENT_NAME=$(azd env get-value AZURE_AI_AGENT_NAME)" >> $ENV_FILE_PATH echo "AZURE_TENANT_ID=$(azd env get-value AZURE_TENANT_ID)" >> $ENV_FILE_PATH +echo "APP_CONFIGURATION_ENDPOINT=$(azd env get-value APP_CONFIGURATION_ENDPOINT)" >> $ENV_FILE_PATH echo "Web app URL:" echo -e "\033[0;36m $(azd env get-value SERVICE_API_URI)" diff --git a/src/.env.sample b/src/.env.sample index 94ae896..ac8a183 100644 --- a/src/.env.sample +++ b/src/.env.sample @@ -12,3 +12,6 @@ AZURE_AI_SEARCH_INDEX_NAME="" # required for index search. Example: "index_samp # Highly recommended. Example: "asst_AbCdEfGhIjKlMnOpQrStUvWxYz". If not specified, the agent name will be used to find the agent ID. Agent ID can be found by following https://learn.microsoft.com/en-us/azure/ai-services/agents/quickstart?pivots=ai-foundry-portal AZURE_AI_AGENT_ID="" +# Recommended. Enable tracing for debugging and testing feature flag capabilities. +ENABLE_AZURE_MONITOR_TRACING=false +AZURE_TRACING_GEN_AI_CONTENT_RECORDING_ENABLED=false \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py index 6c5f2ad..8cbf807 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -50,8 +50,31 @@ async def lifespan(app: fastapi.FastAPI): else: from azure.monitor.opentelemetry import configure_azure_monitor configure_azure_monitor(connection_string=application_insights_connection_string) - # Do not instrument the code yet, before trace fix is available. - #ai_client.telemetry.enable() + ai_client.telemetry.enable() + logger.info("Configured Application Insights for tracing.") + + app_config_endpoint = os.getenv("APP_CONFIGURATION_ENDPOINT") + if app_config_endpoint: + try: + from azure.appconfiguration.provider.aio import load + from featuremanagement.aio import FeatureManager + from featuremanagement.azuremonitor import publish_telemetry + app_config = await load( + endpoint=app_config_endpoint, + credential=DefaultAzureCredential(), + feature_flag_enabled=True, + feature_flag_refresh_enabled=True, + refresh_interval=300 # no refresh within 5 mins to avoid throttling + ) + feature_manager = FeatureManager(app_config, on_feature_evaluated=publish_telemetry) + app.state.app_config = app_config + app.state.feature_manager = feature_manager + logger.info("Configured App Configuration with feature flag support.") + except ModuleNotFoundError: + logger.warning("Required libraries for App Configuration not installed.") + logger.warning("Please make sure azure-appconfiguration-provider and FeatureManagement are installed.") + except Exception as e: + logger.warning("Failed to setup App Configuration", exc_info=True) if agent_id: try: diff --git a/src/api/routes.py b/src/api/routes.py index ad5551b..c578501 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -27,6 +27,9 @@ RunStep ) +from azure.appconfiguration.provider import AzureAppConfigurationProvider +from featuremanagement.aio import FeatureManager + # Create a logger for this module logger = logging.getLogger("azureaiapp") @@ -40,14 +43,17 @@ # Create a new FastAPI router router = fastapi.APIRouter() - def get_ai_client(request: Request) -> AIProjectClient: return request.app.state.ai_client - def get_agent(request: Request) -> Agent: return request.app.state.agent +def get_feature_manager(request: Request) -> FeatureManager: + return getattr(request.app.state, "feature_manager", None) + +def get_app_config(request: Request) -> AzureAppConfigurationProvider: + return getattr(request.app.state, "app_config", None) def serialize_sse_event(data: Dict) -> str: return f"data: {json.dumps(data)}\n\n" @@ -135,6 +141,32 @@ async def index(request: Request): return templates.TemplateResponse("index.html", {"request": request}) +async def get_agent_with_feature_flag( + feature_manager: FeatureManager, + ai_client: AIProjectClient, + thread_id: str, + default_agent: Agent = Depends(get_agent), + ) -> Optional[Agent]: + if thread_id and feature_manager: + # Fetch the variant for the feature flag "my-agent" using thread_id as targeting context + try: + agent_variant = await feature_manager.get_variant("my-agent", thread_id) + except Exception as e: + logger.error(f"Error fetching feature flag for thread_id={thread_id}: {e}") + agent_variant = None + + # Retrieve the agent variant if available + if agent_variant and agent_variant.configuration: + try: + assigned_agent = await ai_client.agents.get_agent(agent_variant.configuration) + logger.info(f"Using variant={agent_variant.name} with agent Id={agent_variant.configuration} for thread_id={thread_id}") + return assigned_agent + except Exception as e: + logger.error(f"Error retrieving agent variant with Id={agent_variant.configuration} from AI project. Fallback to default agent: {e}") + + # No agent variant found, fallback to default agent + return default_agent + async def get_result(thread_id: str, agent_id: str, ai_client : AIProjectClient) -> AsyncGenerator[str, None]: logger.info(f"get_result invoked for thread_id={thread_id} and agent_id={agent_id}") try: @@ -160,14 +192,23 @@ async def get_result(thread_id: str, agent_id: str, ai_client : AIProjectClient) @router.get("/chat/history") async def history( request: Request, + default_agent: Agent = Depends(get_agent), ai_client : AIProjectClient = Depends(get_ai_client), - agent : Agent = Depends(get_agent), + feature_manager: FeatureManager = Depends(get_feature_manager), + app_config: AzureAppConfigurationProvider = Depends(get_app_config), ): + # Refresh config if configured + if app_config: + await app_config.refresh() + # Retrieve the thread ID from the cookies (if available). thread_id = request.cookies.get('thread_id') agent_id = request.cookies.get('agent_id') - # Attempt to get an existing thread. If not found, create a new one. + # Attempt to get agent from feature flag and fallback to default agent if not found. + agent = await get_agent_with_feature_flag(feature_manager, ai_client, thread_id, default_agent) + + # Attempt to get an existing thread. If not found or agent has changed, create a new one. try: if thread_id and agent_id == agent.id: logger.info(f"Retrieving thread with ID {thread_id}") @@ -180,7 +221,6 @@ async def history( raise HTTPException(status_code=400, detail=f"Error handling thread: {e}") thread_id = thread.id - agent_id = agent.id # Create a new message from the user's input. try: @@ -210,12 +250,21 @@ async def history( async def chat( request: Request, ai_client : AIProjectClient = Depends(get_ai_client), - agent : Agent = Depends(get_agent), + default_agent: Agent = Depends(get_agent), + feature_manager: FeatureManager = Depends(get_feature_manager), + app_config: AzureAppConfigurationProvider = Depends(get_app_config), ): + # Refresh config if configured + if app_config: + await app_config.refresh() + # Retrieve the thread ID from the cookies (if available). thread_id = request.cookies.get('thread_id') agent_id = request.cookies.get('agent_id') + # Attempt to get agent from feature flag and fallback to default agent if not found. + agent = await get_agent_with_feature_flag(feature_manager, ai_client, thread_id, default_agent) + # Attempt to get an existing thread. If not found, create a new one. try: if thread_id and agent_id == agent.id: @@ -230,7 +279,7 @@ async def chat( thread_id = thread.id agent_id = agent.id - + # Parse the JSON from the request. try: user_message = await request.json() @@ -266,6 +315,7 @@ async def chat( # Update cookies to persist the thread and agent IDs. response.set_cookie("thread_id", thread_id) response.set_cookie("agent_id", agent_id) + return response diff --git a/src/api/static/ChatClient.js b/src/api/static/ChatClient.js index 5e018aa..40b7306 100644 --- a/src/api/static/ChatClient.js +++ b/src/api/static/ChatClient.js @@ -119,7 +119,7 @@ class ChatClient { data.error.message || "An error occurred.", false ); - return; + return; } // Check the data type to decide how to update the UI diff --git a/src/api/static/ChatUI.js b/src/api/static/ChatUI.js index 035c2ea..a251e4d 100644 --- a/src/api/static/ChatUI.js +++ b/src/api/static/ChatUI.js @@ -52,6 +52,12 @@ class ChatUI { this.deleteAllCookies(); } + getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + } + deleteAllCookies() { document.cookie.split(';').forEach(cookie => { const eqPos = cookie.indexOf('='); @@ -154,7 +160,7 @@ class ChatUI { this.scrollToBottom(); } - appendAssistantMessage(messageDiv, accumulatedContent, isStreaming, annotations) { + appendAssistantMessage(messageDiv, accumulatedContent, isStreaming, annotations, agentId) { const md = window.markdownit({ html: true, linkify: true, @@ -173,6 +179,9 @@ class ChatUI { // Set the innerHTML of the message text div to the HTML content messageDiv.innerHTML = htmlContent; + var tooltip = "Agent Id: " + this.getCookie("agent_id") + "\n" + tooltip += "Thread Id: " + this.getCookie("thread_id") + "\n" + messageDiv.setAttribute("title", tooltip); // Use requestAnimationFrame to ensure the DOM has updated before scrolling // Only scroll if not streaming @@ -221,7 +230,7 @@ class ChatUI { if (!messageDiv) { console.error("Message content div not found in the template."); } - + return messageDiv; } diff --git a/src/requirements.txt b/src/requirements.txt index 163f075..7b2ffd9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -3,8 +3,13 @@ uvicorn[standard]==0.29.0 gunicorn==22.0.0 azure-identity==1.19.0 aiohttp==3.11.1 -azure-ai-projects==1.0.0b7 +azure-ai-projects==1.0.0b8 azure-core-tracing-opentelemetry azure-monitor-opentelemetry azure-search-documents opentelemetry-sdk + +azure-appconfiguration-provider==2.1.0b1 +FeatureManagement==2.0.0b3 +azure-monitor-opentelemetry-exporter +azure-monitor-events-extension \ No newline at end of file