├── src ├── copilot_sdk_flow │ ├── __init__.py │ ├── agent_arch │ │ ├── __init__.py │ │ ├── extensions │ │ │ ├── __init__.py │ │ │ ├── data │ │ │ │ └── order_data.db │ │ │ ├── query_order_data.py │ │ │ ├── query_order_data.json │ │ │ └── manager.py │ │ ├── messages.py │ │ ├── config.py │ │ ├── aoai.py │ │ ├── sessions.py │ │ └── orchestrator.py │ ├── Dockerfile │ ├── requirements.txt │ ├── flow.flex.yaml │ ├── entry.py │ └── chat.py ├── data │ └── ground_truth_sample.jsonl ├── requirements.txt ├── provision.yaml ├── create_assistant.py ├── check_quota.py ├── evaluate.py ├── README.md ├── deploy.py └── provision.py ├── infra ├── hooks │ ├── predeploy.sh │ ├── predeploy.ps1 │ ├── postprovision.sh │ └── postprovision.ps1 ├── core │ ├── monitor │ │ ├── loganalytics.bicep │ │ ├── applicationinsights.bicep │ │ └── monitoring.bicep │ ├── security │ │ ├── keyvault-access.bicep │ │ ├── role.bicep │ │ ├── keyvault.bicep │ │ ├── aks-managed-cluster-access.bicep │ │ ├── registry-access.bicep │ │ ├── configstore-access.bicep │ │ └── keyvault-secret.bicep │ ├── ai │ │ ├── project.bicep │ │ ├── cognitiveservices.bicep │ │ ├── hub.bicep │ │ └── hub-dependencies.bicep │ ├── search │ │ └── search-services.bicep │ ├── host │ │ ├── ml-online-endpoint.bicep │ │ ├── ai-environment.bicep │ │ └── container-registry.bicep │ └── storage │ │ └── storage-account.bicep ├── main.bicepparam ├── abbreviations.json └── main.bicep ├── images └── architecture-diagram-assistant-promptflow.png ├── CHANGELOG.md ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md ├── workflows │ └── azure-dev-validation.yaml └── PULL_REQUEST_TEMPLATE.md ├── azure.yaml ├── LICENSE ├── LICENSE.md ├── SECURITY.md ├── .gitignore ├── CONTRIBUTING.md ├── docs └── README.md └── README.md /src/copilot_sdk_flow/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /infra/hooks/predeploy.sh: -------------------------------------------------------------------------------- 1 | echo "Running python script to deploy the code into the endpoint" 2 | python ./src/deploy.py 3 | -------------------------------------------------------------------------------- /infra/hooks/predeploy.ps1: -------------------------------------------------------------------------------- 1 | Write-Host "Running python script to deploy the code into the endpoint" 2 | python ./src/deploy.py 3 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/azureml/promptflow/promptflow-runtime:latest 2 | COPY ./requirements.txt . 3 | RUN pip install -r requirements.txt 4 | -------------------------------------------------------------------------------- /images/architecture-diagram-assistant-promptflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/assistant-data-openai-python-promptflow/HEAD/images/architecture-diagram-assistant-promptflow.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/extensions/data/order_data.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/assistant-data-openai-python-promptflow/HEAD/src/copilot_sdk_flow/agent_arch/extensions/data/order_data.db -------------------------------------------------------------------------------- /src/data/ground_truth_sample.jsonl: -------------------------------------------------------------------------------- 1 | {"chat_input": "average sales in january 2023", "ground_truth": "The average sales in January 2023 were approximately $173.38."} 2 | {"chat_input": "which month has peak sales in 2023", "ground_truth": "The month with the peak sales in 2023 is December. The total sales for December in 2023 is $1,406,044.58."} 3 | -------------------------------------------------------------------------------- /infra/hooks/postprovision.sh: -------------------------------------------------------------------------------- 1 | echo "Copy .env file from ./.azure/$AZURE_ENV_NAME/.env to root" 2 | cp ./.azure/$AZURE_ENV_NAME/.env ./.env 3 | 4 | echo "Installing dependencies from 'src/requirements.txt'" 5 | python -m pip install -r ./src/requirements.txt 6 | 7 | echo "Create an assistant and add its identifier as env var in ./.env" 8 | python ./src/create_assistant.py --export-env ./.env 9 | 10 | echo "Script execution completed successfully." 11 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/requirements.txt: -------------------------------------------------------------------------------- 1 | # those are the dependencies required only by chat.py 2 | 3 | # openai SDK 4 | openai==1.13.3 5 | 6 | # promptflow packages 7 | promptflow[azure]==1.10.1 8 | promptflow-tracing==1.10.1 9 | promptflow-tools==1.4.0 10 | promptflow-evals==0.2.0.dev0 11 | 12 | # azure dependencies (for authentication) 13 | azure-core==1.30.1 14 | azure-identity==1.16.0 15 | 16 | # utilities 17 | pydantic>=2.6 18 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /infra/hooks/postprovision.ps1: -------------------------------------------------------------------------------- 1 | Write-Host "Copy .env file from ./.azure/$Env:AZURE_ENV_NAME/.env to root" 2 | Copy-Item -Path ./.azure/$Env:AZURE_ENV_NAME/.env -Destination ./.env -Force 3 | 4 | Write-Host "Installing dependencies from 'src/requirements.txt'" 5 | python -m pip install -r ./src/requirements.txt 6 | 7 | Write-Host "Create an assistant and add its identifier as env var in ./.env" 8 | python ./src/create_assistant.py --export-env ./.env 9 | 10 | Write-Host "Script execution completed successfully." 11 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/flow.flex.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | chat_history: 3 | type: list 4 | is_chat_history: true 5 | default: [] 6 | chat_input: 7 | type: string 8 | stream: 9 | type: bool 10 | default: false 11 | context: 12 | type: string 13 | default: "" 14 | outputs: 15 | context: 16 | type: string 17 | reply: 18 | type: string 19 | is_chat_output: true 20 | entry: entry:flow_entry_copilot_assistants 21 | environment: 22 | python_requirements_txt: requirements.txt 23 | -------------------------------------------------------------------------------- /infra/core/monitor/loganalytics.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a Log Analytics workspace.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { 7 | name: name 8 | location: location 9 | tags: tags 10 | properties: any({ 11 | retentionInDays: 30 12 | features: { 13 | searchVersion: 1 14 | } 15 | sku: { 16 | name: 'PerGB2018' 17 | } 18 | }) 19 | } 20 | 21 | output id string = logAnalytics.id 22 | output name string = logAnalytics.name 23 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | # Use these dependencies for local development 2 | # including all scripts for provisioning and deploying 3 | 4 | # openai SDK 5 | openai==1.13.3 6 | 7 | # promptflow packages 8 | promptflow[azure]==1.10.1 9 | promptflow-tracing==1.10.1 10 | promptflow-tools==1.4.0 11 | promptflow-evals==0.2.0.dev0 12 | 13 | # azure dependencies 14 | azure-core==1.30.1 15 | azure-identity==1.16.0 16 | azure-mgmt-resource==23.0.1 17 | azure-mgmt-search==9.1.0 18 | azure-mgmt-cognitiveservices==13.5.0 19 | azure-ai-ml==1.16.0 20 | azure-storage-file-share>=12.10.0 21 | 22 | # utilities 23 | omegaconf-argparse==1.0.1 24 | omegaconf==2.3.0 25 | pydantic>=2.6 26 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns an Azure Key Vault access policy.' 2 | param name string = 'add' 3 | 4 | param keyVaultName string 5 | param permissions object = { secrets: [ 'get', 'list' ] } 6 | param principalId string 7 | 8 | resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { 9 | parent: keyVault 10 | name: name 11 | properties: { 12 | accessPolicies: [ { 13 | objectId: principalId 14 | tenantId: subscription().tenantId 15 | permissions: permissions 16 | } ] 17 | } 18 | } 19 | 20 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 21 | name: keyVaultName 22 | } 23 | -------------------------------------------------------------------------------- /infra/core/security/role.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a role assignment for a service principal.' 2 | param principalId string 3 | 4 | @allowed([ 5 | 'Device' 6 | 'ForeignGroup' 7 | 'Group' 8 | 'ServicePrincipal' 9 | 'User' 10 | ]) 11 | param principalType string = 'ServicePrincipal' 12 | param roleDefinitionId string 13 | 14 | resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 15 | name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) 16 | properties: { 17 | principalId: principalId 18 | principalType: principalType 19 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infra/core/security/keyvault.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Key Vault.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param principalId string = '' 7 | 8 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 9 | name: name 10 | location: location 11 | tags: tags 12 | properties: { 13 | tenantId: subscription().tenantId 14 | sku: { family: 'A', name: 'standard' } 15 | accessPolicies: !empty(principalId) ? [ 16 | { 17 | objectId: principalId 18 | permissions: { secrets: [ 'get', 'list' ] } 19 | tenantId: subscription().tenantId 20 | } 21 | ] : [] 22 | } 23 | } 24 | 25 | output endpoint string = keyVault.properties.vaultUri 26 | output id string = keyVault.id 27 | output name string = keyVault.name 28 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/messages.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from promptflow.contracts.multimedia import Image 3 | from typing import Any 4 | 5 | 6 | class ExtensionCallMessage(BaseModel): 7 | name: str 8 | args: Any 9 | 10 | 11 | class ExtensionReturnMessage(BaseModel): 12 | name: str 13 | content: str 14 | 15 | 16 | class TextResponse(BaseModel): 17 | role: str 18 | content: str 19 | 20 | 21 | class FileResponse(BaseModel): 22 | name: str 23 | url: str 24 | 25 | 26 | class ImageResponse(BaseModel): 27 | content: str 28 | 29 | @classmethod 30 | def from_bytes(cls, content: bytes): 31 | return ImageResponse(content=Image(content).to_base64(with_type=True)) 32 | 33 | 34 | class StepNotification(BaseModel): 35 | type: str 36 | content: str 37 | -------------------------------------------------------------------------------- /infra/core/security/aks-managed-cluster-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns RBAC role to the specified AKS cluster and principal.' 2 | param clusterName string 3 | param principalId string 4 | 5 | var aksClusterAdminRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b') 6 | 7 | resource aksRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 8 | scope: aksCluster // Use when specifying a scope that is different than the deployment scope 9 | name: guid(subscription().id, resourceGroup().id, principalId, aksClusterAdminRole) 10 | properties: { 11 | roleDefinitionId: aksClusterAdminRole 12 | principalType: 'User' 13 | principalId: principalId 14 | } 15 | } 16 | 17 | resource aksCluster 'Microsoft.ContainerService/managedClusters@2023-10-02-preview' existing = { 18 | name: clusterName 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/security/registry-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' 2 | param containerRegistryName string 3 | param principalId string 4 | 5 | var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') 6 | 7 | resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 8 | scope: containerRegistry // Use when specifying a scope that is different than the deployment scope 9 | name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) 10 | properties: { 11 | roleDefinitionId: acrPullRole 12 | principalType: 'ServicePrincipal' 13 | principalId: principalId 14 | } 15 | } 16 | 17 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { 18 | name: containerRegistryName 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/security/configstore-access.bicep: -------------------------------------------------------------------------------- 1 | @description('Name of Azure App Configuration store') 2 | param configStoreName string 3 | 4 | @description('The principal ID of the service principal to assign the role to') 5 | param principalId string 6 | 7 | resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' existing = { 8 | name: configStoreName 9 | } 10 | 11 | var configStoreDataReaderRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '516239f1-63e1-4d78-a4de-a74fb236a071') 12 | 13 | resource configStoreDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 14 | name: guid(subscription().id, resourceGroup().id, principalId, configStoreDataReaderRole) 15 | scope: configStore 16 | properties: { 17 | roleDefinitionId: configStoreDataReaderRole 18 | principalId: principalId 19 | principalType: 'ServicePrincipal' 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-secret.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates or updates a secret in an Azure Key Vault.' 2 | param name string 3 | param tags object = {} 4 | param keyVaultName string 5 | param contentType string = 'string' 6 | @description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') 7 | @secure() 8 | param secretValue string 9 | 10 | param enabled bool = true 11 | param exp int = 0 12 | param nbf int = 0 13 | 14 | resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 15 | name: name 16 | tags: tags 17 | parent: keyVault 18 | properties: { 19 | attributes: { 20 | enabled: enabled 21 | exp: exp 22 | nbf: nbf 23 | } 24 | contentType: contentType 25 | value: secretValue 26 | } 27 | } 28 | 29 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 30 | name: keyVaultName 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | name: assistant-data-openai-python-promptflow 2 | metadata: 3 | template: assistant-data-openai-python-promptflow@0.0.1 4 | hooks: 5 | postprovision: 6 | windows: # Run referenced script that uses environment variables (script shown below) 7 | shell: pwsh 8 | continueOnError: false 9 | interactive: true 10 | run: infra/hooks/postprovision.ps1 11 | posix: # Run referenced script that uses environment variables (script shown below) 12 | shell: sh 13 | continueOnError: false 14 | interactive: true 15 | run: infra/hooks/postprovision.sh 16 | predeploy: 17 | windows: # Run referenced script that uses environment variables (script shown below) 18 | shell: pwsh 19 | continueOnError: false 20 | interactive: true 21 | run: infra/hooks/predeploy.ps1 22 | posix: # Run referenced script that uses environment variables (script shown below) 23 | shell: sh 24 | continueOnError: false 25 | interactive: true 26 | run: infra/hooks/predeploy.sh 27 | infra: 28 | provider: "bicep" -------------------------------------------------------------------------------- /.github/workflows/azure-dev-validation.yaml: -------------------------------------------------------------------------------- 1 | name: Validate AZD template 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths: 6 | - "infra/**" 7 | pull_request: 8 | branches: [ main ] 9 | paths: 10 | - "infra/**" 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Build Bicep for linting 21 | uses: azure/CLI@v1 22 | with: 23 | inlineScript: az config set bicep.use_binary_from_path=false && az bicep build -f infra/main.bicep --stdout 24 | 25 | - name: Run Microsoft Security DevOps Analysis 26 | uses: microsoft/security-devops-action@1 27 | id: msdo 28 | continue-on-error: true 29 | with: 30 | tools: templateanalyzer 31 | 32 | - name: Upload alerts to Security tab 33 | uses: github/codeql-action/upload-sarif@v2 34 | if: github.repository == 'Azure-Samples/chat-rag-openai-csharp-prompty' 35 | with: 36 | sarif_file: ${{ steps.msdo.outputs.sarifFile }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Azure Samples 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/extensions/query_order_data.py: -------------------------------------------------------------------------------- 1 | """This module contains an extension to query a local SQLite database for our demo.""" 2 | 3 | import os 4 | from promptflow.tracing import trace 5 | 6 | import sqlite3 7 | import pandas as pd 8 | import asyncio 9 | 10 | _DB_CONN = sqlite3.connect( 11 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "order_data.db"), 12 | check_same_thread=False, 13 | ) 14 | 15 | 16 | @trace 17 | async def query_order_data(sql_query: str) -> str: 18 | """Run a SQL query against table `order_data` and return the results in JSON format.""" 19 | global _DB_CONN 20 | try: 21 | df = pd.read_sql(sql_query, _DB_CONN) 22 | except Exception as e: 23 | return f"Error: {e}" 24 | 25 | return df.to_json(orient="records") 26 | 27 | 28 | async def main(): 29 | """for local testing""" 30 | query = "SELECT AVG(Sum_of_Order_Value_USD) AS Avg_Sales FROM order_data WHERE Month = 1" 31 | result = await query_order_data(query) 32 | print(result) 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(main()) 37 | -------------------------------------------------------------------------------- /infra/core/monitor/applicationinsights.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' 2 | param name string 3 | param dashboardName string = '' 4 | param location string = resourceGroup().location 5 | param tags object = {} 6 | param logAnalyticsWorkspaceId string 7 | 8 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { 9 | name: name 10 | location: location 11 | tags: tags 12 | kind: 'web' 13 | properties: { 14 | Application_Type: 'web' 15 | WorkspaceResourceId: logAnalyticsWorkspaceId 16 | } 17 | } 18 | 19 | module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { 20 | name: 'application-insights-dashboard' 21 | params: { 22 | name: dashboardName 23 | location: location 24 | applicationInsightsName: applicationInsights.name 25 | } 26 | } 27 | 28 | output connectionString string = applicationInsights.properties.ConnectionString 29 | output id string = applicationInsights.id 30 | output instrumentationKey string = applicationInsights.properties.InstrumentationKey 31 | output name string = applicationInsights.name 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /infra/core/monitor/monitoring.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.' 2 | param logAnalyticsName string 3 | param applicationInsightsName string 4 | param applicationInsightsDashboardName string = '' 5 | param location string = resourceGroup().location 6 | param tags object = {} 7 | 8 | module logAnalytics 'loganalytics.bicep' = { 9 | name: 'loganalytics' 10 | params: { 11 | name: logAnalyticsName 12 | location: location 13 | tags: tags 14 | } 15 | } 16 | 17 | module applicationInsights 'applicationinsights.bicep' = { 18 | name: 'applicationinsights' 19 | params: { 20 | name: applicationInsightsName 21 | location: location 22 | tags: tags 23 | dashboardName: applicationInsightsDashboardName 24 | logAnalyticsWorkspaceId: logAnalytics.outputs.id 25 | } 26 | } 27 | 28 | output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString 29 | output applicationInsightsId string = applicationInsights.outputs.id 30 | output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey 31 | output applicationInsightsName string = applicationInsights.outputs.name 32 | output logAnalyticsWorkspaceId string = logAnalytics.outputs.id 33 | output logAnalyticsWorkspaceName string = logAnalytics.outputs.name 34 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | from typing import Dict 5 | from pydantic import BaseModel 6 | 7 | 8 | class Configuration(BaseModel): 9 | AZURE_OPENAI_ENDPOINT: str 10 | AZURE_OPENAI_ASSISTANT_ID: str 11 | ORCHESTRATOR_MAX_WAITING_TIME: int = 60 12 | AZURE_OPENAI_API_KEY: Optional[str] = None 13 | AZURE_OPENAI_API_VERSION: Optional[str] = "2024-02-15-preview" 14 | 15 | @classmethod 16 | def from_env_and_context(cls, context: Dict[str, str]): 17 | # verify required env vars 18 | required_env_vars = [ 19 | "AZURE_OPENAI_ENDPOINT", 20 | "AZURE_OPENAI_ASSISTANT_ID", 21 | ] 22 | missing_env_vars = [] 23 | for env_var in required_env_vars: 24 | if env_var not in os.environ: 25 | missing_env_vars.append(env_var) 26 | assert ( 27 | not missing_env_vars 28 | ), f"Missing environment variables: {missing_env_vars}" 29 | 30 | return cls( 31 | AZURE_OPENAI_ENDPOINT=os.environ["AZURE_OPENAI_ENDPOINT"], 32 | AZURE_OPENAI_ASSISTANT_ID=context.get("AZURE_OPENAI_ASSISTANT_ID") 33 | or os.environ["AZURE_OPENAI_ASSISTANT_ID"], 34 | ORCHESTRATOR_MAX_WAITING_TIME=int( 35 | context.get("ORCHESTRATOR_MAX_WAITING_TIME") 36 | or os.getenv("ORCHESTRATOR_MAX_WAITING_TIME") 37 | or "60" 38 | ), 39 | AZURE_OPENAI_API_KEY=os.getenv("AZURE_OPENAI_API_KEY"), 40 | AZURE_OPENAI_API_VERSION=os.getenv( 41 | "AZURE_OPENAI_API_VERSION", "2024-02-15-preview" 42 | ), 43 | ) 44 | -------------------------------------------------------------------------------- /infra/core/ai/project.bicep: -------------------------------------------------------------------------------- 1 | @description('The AI Studio Hub Resource name') 2 | param name string 3 | @description('The display name of the AI Studio Hub Resource') 4 | param displayName string = name 5 | @description('The name of the AI Studio Hub Resource where this project should be created') 6 | param hubName string 7 | 8 | @description('The SKU name to use for the AI Studio Hub Resource') 9 | param skuName string = 'Basic' 10 | @description('The SKU tier to use for the AI Studio Hub Resource') 11 | @allowed(['Basic', 'Free', 'Premium', 'Standard']) 12 | param skuTier string = 'Basic' 13 | @description('The public network access setting to use for the AI Studio Hub Resource') 14 | @allowed(['Enabled','Disabled']) 15 | param publicNetworkAccess string = 'Enabled' 16 | 17 | param location string = resourceGroup().location 18 | param tags object = {} 19 | 20 | resource project 'Microsoft.MachineLearningServices/workspaces@2024-04-01' = { 21 | name: name 22 | location: location 23 | tags: tags 24 | sku: { 25 | name: skuName 26 | tier: skuTier 27 | } 28 | kind: 'Project' 29 | identity: { 30 | type: 'SystemAssigned' 31 | } 32 | properties: { 33 | friendlyName: displayName 34 | hbiWorkspace: false 35 | v1LegacyMode: false 36 | publicNetworkAccess: publicNetworkAccess 37 | discoveryUrl: 'https://${location}.api.azureml.ms/discovery' 38 | // most properties are not allowed for a project workspace: "Project workspace shouldn't define ..." 39 | hubResourceId: hub.id 40 | } 41 | } 42 | 43 | resource hub 'Microsoft.MachineLearningServices/workspaces@2024-01-01-preview' existing = { 44 | name: hubName 45 | } 46 | 47 | output id string = project.id 48 | output name string = project.name 49 | output principalId string = project.identity.principalId 50 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/extensions/query_order_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query_order_data", 3 | "description": "Run a SQL query against table `order_data` and return the results in JSON format.\nOrder data is stored in a SQLite table with properties:\n\n# Number_of_Orders INTEGER \"the number of orders processed\"\n# Sum_of_Order_Value_USD REAL \"the total value of the orders processed in USD\"\n# Sum_of_Number_of_Items REAL \"the sum of items in the orders processed\"\n# Number_of_Orders_with_Discount INTEGER \"the number of orders that received a discount\"\n# Sum_of_Discount_Percentage REAL \"the sum of discount percentage -- useful to calculate average discounts given\"\n# Sum_of_Shipping_Cost_USD REAL \"the sum of shipping cost for the processed orders\"\n# Number_of_Orders_Returned INTEGER \"the number of orders returned by the customers\"\n# Number_of_Orders_Cancelled INTEGER \"the number or orders cancelled by the customers before they were sent out\"\n# Sum_of_Time_to_Fulfillment REAL \"the sum of time to fulfillment\"\n# Number_of_Orders_Repeat_Customers INTEGER \"number of orders that were placed by repeat customers\"\n# Year INTEGER\n# Month INTEGER\n# Day INTEGER\n# Date TIMESTAMP\n# Day_of_Week INTEGER in 0 based format, Monday is 0, Tuesday is 1, etc.\n# main_category TEXT\n# sub_category TEXT\n# product_type TEXT\n\nIn this table all numbers are already aggregated, so all queries will be some type of aggregation with group by.", 4 | "parameters": { 5 | "type": "object", 6 | "properties": { 7 | "sql_query": { 8 | "type": "string", 9 | "description": "The SQL query to run against the sales database" 10 | } 11 | }, 12 | "required": ["sql_query"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /infra/main.bicepparam: -------------------------------------------------------------------------------- 1 | using './main.bicep' 2 | 3 | param environmentName = readEnvironmentVariable('AZURE_ENV_NAME', 'MY_ENV') 4 | param resourceGroupName = readEnvironmentVariable('AZURE_RESOURCE_GROUP', '') 5 | param location = readEnvironmentVariable('AZURE_LOCATION', 'eastus2') 6 | param principalId = readEnvironmentVariable('AZURE_PRINCIPAL_ID', '') 7 | param principalType = readEnvironmentVariable('AZURE_PRINCIPAL_TYPE', 'User') 8 | 9 | param aiHubName = readEnvironmentVariable('AZUREAI_HUB_NAME', '') 10 | param aiProjectName = readEnvironmentVariable('AZUREAI_PROJECT_NAME', '') 11 | param endpointName = readEnvironmentVariable('AZUREAI_ENDPOINT_NAME', '') 12 | 13 | param openAiName = readEnvironmentVariable('AZURE_OPENAI_NAME', '') 14 | param openAiChatDeploymentName = readEnvironmentVariable('AZURE_OPENAI_CHAT_DEPLOYMENT', 'chat-35-turbo') 15 | param openAiChatDeploymentVersion = readEnvironmentVariable('AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION', '1106') 16 | param openAiEvaluationDeploymentName = readEnvironmentVariable('AZURE_OPENAI_EVALUATION_DEPLOYMENT', 'evaluation-35-turbo') 17 | param openAiEvaluationDeploymentVersion = readEnvironmentVariable('AZURE_OPENAI_EVALUATION_DEPLOYMENT_VERSION', '0301') 18 | 19 | param appInsightsName = readEnvironmentVariable('AZURE_APP_INSIGHTS_NAME', '') 20 | param containerRegistryName = readEnvironmentVariable('AZURE_CONTAINER_REGISTRY_NAME', '') 21 | param keyVaultName = readEnvironmentVariable('AZURE_KEYVAULT_NAME', '') 22 | param storageAccountName = readEnvironmentVariable('AZURE_STORAGE_ACCOUNT_NAME', '') 23 | param logAnalyticsWorkspaceName = readEnvironmentVariable('AZURE_LOG_ANALYTICS_WORKSPACE_NAME', '') 24 | 25 | param useContainerRegistry = bool(readEnvironmentVariable('USE_CONTAINER_REGISTRY', 'true')) 26 | param useAppInsights = bool(readEnvironmentVariable('USE_APP_INSIGHTS', 'true')) 27 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/extensions/manager.py: -------------------------------------------------------------------------------- 1 | """In the context of our demo, Extensions are functions that can be invoked by the assistant. 2 | The ExtensionsManager class manages those extensions for the Orchestrator.""" 3 | 4 | import os 5 | import inspect 6 | import json 7 | from promptflow.tracing import trace 8 | from typing import Any 9 | import asyncio 10 | import inspect 11 | 12 | 13 | class Extension: 14 | """Represents an extension that can be invoked by the assistant.""" 15 | 16 | def __init__(self, name, function): 17 | self.name = name 18 | self.function = function 19 | 20 | @trace 21 | def invoke(self, **extension_args) -> Any: 22 | """Invokes the extension with the provided arguments. 23 | 24 | Args: 25 | **extension_args: The arguments to pass to the extension. 26 | 27 | Returns: 28 | Any: The response from the extension. 29 | """ 30 | # test if the function is async 31 | if inspect.iscoroutinefunction(self.function): 32 | function_response = asyncio.run(self.function(**extension_args)) 33 | else: 34 | function_response = self.function(**extension_args) 35 | 36 | return function_response 37 | 38 | 39 | class ExtensionsManager: 40 | """Manages the extensions that can be invoked by the system.""" 41 | 42 | def __init__(self, config): 43 | self.extensions = {} 44 | 45 | def load(self): 46 | """Loads the extensions into the manager.""" 47 | from .query_order_data import query_order_data 48 | 49 | self.extensions["query_order_data"] = Extension( 50 | name="query_order_data", 51 | function=query_order_data, 52 | ) 53 | 54 | def get_extension(self, name: str) -> Extension: 55 | """Gets an extension by its name.""" 56 | return self.extensions.get(name, None) 57 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/aoai.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from openai import AzureOpenAI, AsyncAzureOpenAI 4 | from azure.identity import DefaultAzureCredential, get_bearer_token_provider 5 | from typing import Union 6 | from promptflow.tracing import trace 7 | 8 | 9 | @trace 10 | def get_azure_openai_client( 11 | stream: bool = False, azure_endpoint: str = None, api_version: str = None 12 | ) -> Union[AzureOpenAI, AsyncAzureOpenAI]: 13 | """Gets an AzureOpenAI client.""" 14 | 15 | # check if the azure_endpoint is provided or in the environment variables 16 | assert ( 17 | azure_endpoint is not None or "AZURE_OPENAI_ENDPOINT" in os.environ 18 | ), "azure_endpoint is None, AZURE_OPENAI_ENDPOINT environment variable is required" 19 | 20 | # create an AzureOpenAI client using AAD or key based auth 21 | if "AZURE_OPENAI_API_KEY" in os.environ: 22 | logging.warning( 23 | "Using key-based authentification, instead we recommend using Azure AD authentification instead." 24 | ) 25 | aoai_client = AzureOpenAI( 26 | azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], 27 | api_key=os.environ["AZURE_OPENAI_API_KEY"], 28 | api_version=api_version 29 | or os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"), 30 | ) 31 | else: 32 | logging.info("Using Azure AD authentification [recommended]") 33 | credential = DefaultAzureCredential() 34 | token_provider = get_bearer_token_provider( 35 | credential, "https://cognitiveservices.azure.com/.default" 36 | ) 37 | aoai_client = AzureOpenAI( 38 | azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], 39 | api_version=api_version 40 | or os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"), 41 | azure_ad_token_provider=token_provider, 42 | ) 43 | return aoai_client 44 | -------------------------------------------------------------------------------- /infra/core/search/search-services.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure AI Search instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param sku object = { 7 | name: 'standard' 8 | } 9 | 10 | param authOptions object = {} 11 | param disableLocalAuth bool = false 12 | param disabledDataExfiltrationOptions array = [] 13 | param encryptionWithCmk object = { 14 | enforcement: 'Unspecified' 15 | } 16 | @allowed([ 17 | 'default' 18 | 'highDensity' 19 | ]) 20 | param hostingMode string = 'default' 21 | param networkRuleSet object = { 22 | bypass: 'None' 23 | ipRules: [] 24 | } 25 | param partitionCount int = 1 26 | @allowed([ 27 | 'enabled' 28 | 'disabled' 29 | ]) 30 | param publicNetworkAccess string = 'enabled' 31 | param replicaCount int = 1 32 | @allowed([ 33 | 'disabled' 34 | 'free' 35 | 'standard' 36 | ]) 37 | param semanticSearch string = 'disabled' 38 | 39 | var searchIdentityProvider = (sku.name == 'free') ? null : { 40 | type: 'SystemAssigned' 41 | } 42 | 43 | resource search 'Microsoft.Search/searchServices@2021-04-01-preview' = { 44 | name: name 45 | location: location 46 | tags: tags 47 | // The free tier does not support managed identity 48 | identity: searchIdentityProvider 49 | properties: { 50 | authOptions: authOptions 51 | disableLocalAuth: disableLocalAuth 52 | disabledDataExfiltrationOptions: disabledDataExfiltrationOptions 53 | encryptionWithCmk: encryptionWithCmk 54 | hostingMode: hostingMode 55 | networkRuleSet: networkRuleSet 56 | partitionCount: partitionCount 57 | publicNetworkAccess: publicNetworkAccess 58 | replicaCount: replicaCount 59 | semanticSearch: semanticSearch 60 | } 61 | sku: sku 62 | } 63 | 64 | output id string = search.id 65 | output endpoint string = 'https://${name}.search.windows.net/' 66 | output name string = search.name 67 | output principalId string = !empty(searchIdentityProvider) ? search.identity.principalId : '' 68 | 69 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/entry.py: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # --------------------------------------------------------- 4 | from typing import TypedDict 5 | 6 | # set environment variables before importing any other code 7 | from dotenv import load_dotenv, find_dotenv 8 | import json 9 | 10 | print(find_dotenv()) 11 | load_dotenv(override=True) 12 | 13 | 14 | class ChatResponse(TypedDict): 15 | context: dict 16 | reply: str 17 | 18 | 19 | from promptflow.core import tool 20 | 21 | # local imports 22 | import os 23 | import sys 24 | 25 | # TODO: using sys.path as hotfix to be able to run the script from 3 different locations 26 | sys.path.append(os.path.join(os.path.dirname(__file__))) 27 | from chat import chat_completion 28 | 29 | 30 | # The inputs section will change based on the arguments of the tool function, after you save the code 31 | # Adding type to arguments and return value will help the system show the types properly 32 | # Please update the function name/signature per need 33 | @tool 34 | def flow_entry_copilot_assistants( 35 | chat_input: str, stream=False, chat_history: list = [], context: str = None 36 | ) -> ChatResponse: 37 | # json parse context as dict 38 | context = json.loads(context) if context else {} 39 | 40 | # refactor the whole chat_history thing 41 | conversation = [ 42 | { 43 | "role": "user" if "inputs" in message else "assistant", 44 | "content": ( 45 | message["inputs"]["chat_input"] 46 | if "inputs" in message 47 | else message["outputs"]["chat_output"] 48 | ), 49 | } 50 | for message in chat_history 51 | ] 52 | 53 | # add the user input as last message in the conversation 54 | conversation.append({"role": "user", "content": chat_input}) 55 | 56 | return chat_completion(conversation, stream=stream, context=context) 57 | -------------------------------------------------------------------------------- /infra/core/ai/cognitiveservices.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cognitive Services instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | @description('The custom subdomain name used to access the API. Defaults to the value of the name parameter.') 6 | param customSubDomainName string = name 7 | param deployments array = [] 8 | param kind string = 'OpenAI' 9 | 10 | @description('Enable/disable the use of key based authentification entirely on the resource (if Disabled, only Entra ID will be authorized).') 11 | @allowed([ 'Enabled', 'Disabled' ]) 12 | param keyBasedAuthAccess string = 'Enabled' 13 | 14 | @allowed([ 'Enabled', 'Disabled' ]) 15 | param publicNetworkAccess string = 'Enabled' 16 | param sku object = { 17 | name: 'S0' 18 | } 19 | 20 | param allowedIpRules array = [] 21 | param networkAcls object = empty(allowedIpRules) ? { 22 | defaultAction: 'Allow' 23 | } : { 24 | ipRules: allowedIpRules 25 | defaultAction: 'Deny' 26 | } 27 | 28 | resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { 29 | name: name 30 | location: location 31 | tags: tags 32 | kind: kind 33 | properties: { 34 | customSubDomainName: customSubDomainName 35 | publicNetworkAccess: publicNetworkAccess 36 | networkAcls: networkAcls 37 | disableLocalAuth: keyBasedAuthAccess == 'Enabled' ? false : true 38 | } 39 | sku: sku 40 | } 41 | 42 | @batchSize(1) 43 | resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { 44 | parent: account 45 | name: deployment.name 46 | properties: { 47 | model: deployment.model 48 | raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null 49 | } 50 | sku: contains(deployment, 'sku') ? deployment.sku : { 51 | name: 'Standard' 52 | capacity: 20 53 | } 54 | }] 55 | 56 | output endpoint string = account.properties.endpoint 57 | output endpoints object = account.properties.endpoints 58 | output id string = account.id 59 | output name string = account.name 60 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/chat.py: -------------------------------------------------------------------------------- 1 | """This script contains the main chat completion function used as 2 | an entry point for our demo.""" 3 | 4 | import os 5 | from promptflow.tracing import trace 6 | 7 | # local imports 8 | import sys 9 | 10 | # TODO: using sys.path as hotfix to be able to run the script from 3 different locations 11 | sys.path.append(os.path.join(os.path.dirname(__file__))) 12 | 13 | from agent_arch.aoai import get_azure_openai_client 14 | from agent_arch.config import Configuration 15 | from agent_arch.sessions import SessionManager 16 | from agent_arch.orchestrator import Orchestrator 17 | from agent_arch.extensions.manager import ExtensionsManager 18 | 19 | 20 | @trace 21 | def chat_completion( 22 | messages: list[dict], 23 | stream: bool = False, 24 | context: dict[str, any] = {}, 25 | ): 26 | # a couple basic checks 27 | if not messages: 28 | return {"error": "No messages provided."} 29 | 30 | # loads the system config from the environment variables 31 | # with overrides from the context 32 | config = Configuration.from_env_and_context(context) 33 | 34 | # get the Azure OpenAI client 35 | aoai_client = get_azure_openai_client(stream=False) # TODO: Assistants Streaming 36 | 37 | # the session manager is responsible for creating and storing sessions 38 | session_manager = SessionManager(aoai_client) 39 | 40 | if "session_id" not in context: 41 | session = session_manager.create_session() 42 | context["session_id"] = session.id 43 | # record all messages so far 44 | for message in messages: 45 | session.record_message(message) 46 | else: 47 | session = session_manager.get_session(context.get("session_id")) 48 | # record the user message into the session 49 | session.record_message(messages[-1]) 50 | 51 | # the extension manager is responsible for loading and invoking extensions 52 | extensions = ExtensionsManager(config) 53 | extensions.load() 54 | 55 | # the orchestrator is responsible for managing the assistant run 56 | orchestrator = Orchestrator(config, aoai_client, session, extensions) 57 | orchestrator.run_loop() 58 | 59 | # for now we'll use this trick for outputs 60 | def output_queue_iterate(): 61 | while session.output_queue: 62 | yield session.output_queue.popleft() 63 | 64 | return {"reply": output_queue_iterate(), "context": context} 65 | -------------------------------------------------------------------------------- /src/provision.yaml: -------------------------------------------------------------------------------- 1 | ai: 2 | # use references to an existing AI Hub+Project resource to connect it to your hub 3 | # or else provision.py will create a resource with those references 4 | subscription_id: "" 5 | resource_group_name: "" 6 | hub_name: "" 7 | project_name: "" 8 | location: "eastus" 9 | 10 | aoai: 11 | # use references to an existing AOAI resource to connect it to your hub 12 | # or else provision.py will create a resource with those references 13 | 14 | # uncomment only if sub/rg/location are different from AI Hub 15 | # subscription_id: "" 16 | # resource_group_name: "" 17 | # IMPORTANT: for assistant, location needs to be in [australiaeast, eastus, eastus2, francecentral, norwayeast, swedencentral, uksouth] 18 | # location: "eastus" 19 | aoai_resource_name: "" 20 | kind: "OpenAI" # use OpenAI for AIServices 21 | 22 | # specify which auth mode to connect from local code to AzureOpenAI resource 23 | auth: 24 | mode: "aad" # use aad [recommended] or key [default] 25 | role: "a001fd3d-188f-4b5d-821b-7da978bf7442" # Cognitive Service OpenAI Contributor 26 | 27 | # specify the name of the existing/creating hub connection for this resource 28 | connection_name: "aoai-connection" # this needs to match with name used for env vars below 29 | 30 | # specify deployments existing/creating 31 | deployments: 32 | - name: "chat-35-turbo" 33 | model: "gpt-35-turbo" 34 | version: "1106" # this version is required for Assistant API to work 35 | capacity: 30 36 | - name: "evaluation-35-turbo" 37 | model: "gpt-35-turbo" 38 | version: "0301" # evaluation works more reliably on a non-preview model 39 | # version: "0613" 40 | capacity: 30 41 | 42 | environment: 43 | # below will be used for --export-env argument 44 | variables: 45 | # those env vars are drawn from the AI hub connections 46 | AZURE_OPENAI_ENDPOINT: "azureml://connections/${aoai.connection_name}/target" 47 | # we're not using key auth, so this is commented out 48 | # see aoai.auth.mode above 49 | # AZURE_OPENAI_API_KEY: "azureml://connections/${aoai.connection_name}/credentials/key" 50 | 51 | # those are just constants 52 | AZURE_OPENAI_CHAT_DEPLOYMENT: "${aoai.deployments[0].name}" 53 | AZURE_OPENAI_EVALUATION_DEPLOYMENT: "${aoai.deployments[1].name}" 54 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | - Full paths of source file(s) related to the manifestation of the issue 23 | - The location of the affected source code (tag/branch/commit or direct URL) 24 | - Any special configuration required to reproduce the issue 25 | - Step-by-step instructions to reproduce the issue 26 | - Proof-of-concept or exploit code (if possible) 27 | - Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /infra/core/host/ml-online-endpoint.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry.' 2 | param name string 3 | param serviceName string 4 | param location string = resourceGroup().location 5 | param tags object = {} 6 | param aiProjectName string 7 | param aiHubName string 8 | param keyVaultName string 9 | param kind string = 'Managed' 10 | param authMode string = 'Key' 11 | 12 | resource endpoint 'Microsoft.MachineLearningServices/workspaces/onlineEndpoints@2023-10-01' = { 13 | name: name 14 | location: location 15 | parent: workspace 16 | kind: kind 17 | tags: union(tags, { 'azd-service-name': serviceName }) 18 | identity: { 19 | type: 'SystemAssigned' 20 | } 21 | properties: { 22 | authMode: authMode 23 | } 24 | } 25 | 26 | var azureMLDataScientist = resourceId('Microsoft.Authorization/roleDefinitions', 'f6c7c914-8db3-469d-8ca1-694a8f32e121') 27 | 28 | resource azureMLDataScientistRoleHub 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 29 | name: guid(subscription().id, resourceGroup().id, aiHubName, name, azureMLDataScientist) 30 | scope: hubWorkspace 31 | properties: { 32 | principalId: endpoint.identity.principalId 33 | principalType: 'ServicePrincipal' 34 | roleDefinitionId: azureMLDataScientist 35 | } 36 | } 37 | 38 | resource azureMLDataScientistRoleWorkspace 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 39 | name: guid(subscription().id, resourceGroup().id, aiProjectName, name, azureMLDataScientist) 40 | scope: workspace 41 | properties: { 42 | principalId: endpoint.identity.principalId 43 | principalType: 'ServicePrincipal' 44 | roleDefinitionId: azureMLDataScientist 45 | } 46 | } 47 | 48 | var azureMLWorkspaceConnectionSecretsReader = resourceId( 49 | 'Microsoft.Authorization/roleDefinitions', 50 | 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5' 51 | ) 52 | 53 | resource azureMLWorkspaceConnectionSecretsReaderRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 54 | name: guid(subscription().id, resourceGroup().id, aiProjectName, name, azureMLWorkspaceConnectionSecretsReader) 55 | scope: endpoint 56 | properties: { 57 | principalId: endpoint.identity.principalId 58 | principalType: 'ServicePrincipal' 59 | roleDefinitionId: azureMLWorkspaceConnectionSecretsReader 60 | } 61 | } 62 | 63 | module keyVaultAccess '../security/keyvault-access.bicep' = { 64 | name: '${name}-keyvault-access' 65 | params: { 66 | keyVaultName: keyVaultName 67 | principalId: endpoint.identity.principalId 68 | } 69 | } 70 | 71 | resource hubWorkspace 'Microsoft.MachineLearningServices/workspaces@2023-08-01-preview' existing = { 72 | name: aiHubName 73 | } 74 | 75 | resource workspace 'Microsoft.MachineLearningServices/workspaces@2023-08-01-preview' existing = { 76 | name: aiProjectName 77 | } 78 | 79 | output name string = endpoint.name 80 | output scoringEndpoint string = endpoint.properties.scoringUri 81 | output swaggerEndpoint string = endpoint.properties.swaggerUri 82 | output principalId string = endpoint.identity.principalId 83 | -------------------------------------------------------------------------------- /infra/core/storage/storage-account.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure storage account.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @allowed([ 7 | 'Cool' 8 | 'Hot' 9 | 'Premium' ]) 10 | param accessTier string = 'Hot' 11 | param allowBlobPublicAccess bool = true 12 | param allowCrossTenantReplication bool = true 13 | param allowSharedKeyAccess bool = true 14 | param containers array = [] 15 | param corsRules array = [] 16 | param defaultToOAuthAuthentication bool = false 17 | param deleteRetentionPolicy object = {} 18 | @allowed([ 'AzureDnsZone', 'Standard' ]) 19 | param dnsEndpointType string = 'Standard' 20 | param files array = [] 21 | param kind string = 'StorageV2' 22 | param minimumTlsVersion string = 'TLS1_2' 23 | param queues array = [] 24 | param shareDeleteRetentionPolicy object = {} 25 | param supportsHttpsTrafficOnly bool = true 26 | param tables array = [] 27 | param networkAcls object = { 28 | bypass: 'AzureServices' 29 | defaultAction: 'Allow' 30 | } 31 | @allowed([ 'Enabled', 'Disabled' ]) 32 | param publicNetworkAccess string = 'Enabled' 33 | param sku object = { name: 'Standard_LRS' } 34 | 35 | resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { 36 | name: name 37 | location: location 38 | tags: tags 39 | kind: kind 40 | sku: sku 41 | properties: { 42 | accessTier: accessTier 43 | allowBlobPublicAccess: allowBlobPublicAccess 44 | allowCrossTenantReplication: allowCrossTenantReplication 45 | allowSharedKeyAccess: allowSharedKeyAccess 46 | defaultToOAuthAuthentication: defaultToOAuthAuthentication 47 | dnsEndpointType: dnsEndpointType 48 | minimumTlsVersion: minimumTlsVersion 49 | networkAcls: networkAcls 50 | publicNetworkAccess: publicNetworkAccess 51 | supportsHttpsTrafficOnly: supportsHttpsTrafficOnly 52 | } 53 | 54 | resource blobServices 'blobServices' = if (!empty(containers)) { 55 | name: 'default' 56 | properties: { 57 | cors: { 58 | corsRules: corsRules 59 | } 60 | deleteRetentionPolicy: deleteRetentionPolicy 61 | } 62 | resource container 'containers' = [for container in containers: { 63 | name: container.name 64 | properties: { 65 | publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' 66 | } 67 | }] 68 | } 69 | 70 | resource fileServices 'fileServices' = if (!empty(files)) { 71 | name: 'default' 72 | properties: { 73 | cors: { 74 | corsRules: corsRules 75 | } 76 | shareDeleteRetentionPolicy: shareDeleteRetentionPolicy 77 | } 78 | } 79 | 80 | resource queueServices 'queueServices' = if (!empty(queues)) { 81 | name: 'default' 82 | properties: { 83 | 84 | } 85 | resource queue 'queues' = [for queue in queues: { 86 | name: queue.name 87 | properties: { 88 | metadata: {} 89 | } 90 | }] 91 | } 92 | 93 | resource tableServices 'tableServices' = if (!empty(tables)) { 94 | name: 'default' 95 | properties: {} 96 | } 97 | } 98 | 99 | output id string = storage.id 100 | output name string = storage.name 101 | output primaryEndpoints object = storage.properties.primaryEndpoints 102 | -------------------------------------------------------------------------------- /infra/core/ai/hub.bicep: -------------------------------------------------------------------------------- 1 | @description('The AI Studio Hub Resource name') 2 | param name string 3 | @description('The display name of the AI Studio Hub Resource') 4 | param displayName string = name 5 | @description('The storage account ID to use for the AI Studio Hub Resource') 6 | param storageAccountId string 7 | @description('The key vault ID to use for the AI Studio Hub Resource') 8 | param keyVaultId string 9 | @description('The application insights ID to use for the AI Studio Hub Resource') 10 | param appInsightsId string = '' 11 | @description('The container registry ID to use for the AI Studio Hub Resource') 12 | param containerRegistryId string = '' 13 | @description('The AI Services account name to use for the AI Studio Hub Resource') 14 | param aiServicesName string 15 | @description('The Azure Cognitive Search service name to use for the AI Studio Hub Resource') 16 | param aiSearchName string = '' 17 | @description('The SKU name to use for the AI Studio Hub Resource') 18 | param skuName string = 'Basic' 19 | @description('The SKU tier to use for the AI Studio Hub Resource') 20 | @allowed(['Basic', 'Free', 'Premium', 'Standard']) 21 | param skuTier string = 'Basic' 22 | @description('The public network access setting to use for the AI Studio Hub Resource') 23 | @allowed(['Enabled','Disabled']) 24 | param publicNetworkAccess string = 'Enabled' 25 | 26 | param location string = resourceGroup().location 27 | param tags object = {} 28 | 29 | resource hub 'Microsoft.MachineLearningServices/workspaces@2024-04-01' = { 30 | name: name 31 | location: location 32 | tags: tags 33 | sku: { 34 | name: skuName 35 | tier: skuTier 36 | } 37 | kind: 'Hub' 38 | identity: { 39 | type: 'SystemAssigned' 40 | } 41 | properties: { 42 | friendlyName: displayName 43 | storageAccount: storageAccountId 44 | keyVault: keyVaultId 45 | applicationInsights: !empty(appInsightsId) ? appInsightsId : null 46 | containerRegistry: !empty(containerRegistryId) ? containerRegistryId : null 47 | hbiWorkspace: false 48 | managedNetwork: { 49 | isolationMode: 'Disabled' 50 | } 51 | v1LegacyMode: false 52 | publicNetworkAccess: publicNetworkAccess 53 | discoveryUrl: 'https://${location}.api.azureml.ms/discovery' 54 | } 55 | 56 | resource openAiConnection 'connections' = { 57 | name: 'aoai-connection' 58 | properties: { 59 | category: 'AzureOpenAI' 60 | authType: 'AAD' 61 | isSharedToAll: true 62 | target: aiServices.properties.endpoints['OpenAI Language Model Instance API'] 63 | metadata: { 64 | ApiType: 'azure' 65 | ResourceId: aiServices.id 66 | } 67 | } 68 | } 69 | 70 | resource searchConnection 'connections' = 71 | if (!empty(aiSearchName)) { 72 | name: 'search-connection' 73 | properties: { 74 | category: 'CognitiveSearch' 75 | authType: 'ApiKey' 76 | isSharedToAll: true 77 | target: 'https://${aiSearchName}.search.windows.net/' 78 | credentials: { 79 | key: !empty(aiSearchName) ? search.listAdminKeys().primaryKey : '' 80 | } 81 | } 82 | } 83 | } 84 | 85 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 86 | name: aiServicesName 87 | } 88 | 89 | resource search 'Microsoft.Search/searchServices@2021-04-01-preview' existing = 90 | if (!empty(aiSearchName)) { 91 | name: aiSearchName 92 | } 93 | 94 | output name string = hub.name 95 | output id string = hub.id 96 | output principalId string = hub.identity.principalId 97 | -------------------------------------------------------------------------------- /infra/core/host/ai-environment.bicep: -------------------------------------------------------------------------------- 1 | @minLength(1) 2 | @description('Primary location for all resources') 3 | param location string 4 | 5 | @description('The AI Hub resource name.') 6 | param hubName string 7 | @description('The AI Project resource name.') 8 | param projectName string 9 | @description('The Key Vault resource name.') 10 | param keyVaultName string 11 | @description('The Storage Account resource name.') 12 | param storageAccountName string 13 | @description('The AI Services resource name.') 14 | param aiServicesName string 15 | @description('The Open AI model deployments.') 16 | param openAiModelDeployments array = [] 17 | @description('The Log Analytics resource name.') 18 | param logAnalyticsName string = '' 19 | @description('The Application Insights resource name.') 20 | param appInsightsName string = '' 21 | @description('The Container Registry resource name.') 22 | param containerRegistryName string = '' 23 | @description('The Azure Search resource name.') 24 | param searchName string = '' 25 | param tags object = {} 26 | 27 | module hubDependencies '../ai/hub-dependencies.bicep' = { 28 | name: 'hubDependencies' 29 | params: { 30 | location: location 31 | tags: tags 32 | keyVaultName: keyVaultName 33 | storageAccountName: storageAccountName 34 | containerRegistryName: containerRegistryName 35 | appInsightsName: appInsightsName 36 | logAnalyticsName: logAnalyticsName 37 | aiServicesName: aiServicesName 38 | openAiModelDeployments: openAiModelDeployments 39 | searchName: searchName 40 | } 41 | } 42 | 43 | module hub '../ai/hub.bicep' = { 44 | name: 'hub' 45 | params: { 46 | location: location 47 | tags: tags 48 | name: hubName 49 | displayName: hubName 50 | keyVaultId: hubDependencies.outputs.keyVaultId 51 | storageAccountId: hubDependencies.outputs.storageAccountId 52 | containerRegistryId: hubDependencies.outputs.containerRegistryId 53 | appInsightsId: hubDependencies.outputs.appInsightsId 54 | aiServicesName: hubDependencies.outputs.aiServicesName 55 | aiSearchName: hubDependencies.outputs.searchName 56 | } 57 | } 58 | 59 | module project '../ai/project.bicep' = { 60 | name: 'project' 61 | params: { 62 | location: location 63 | tags: tags 64 | name: projectName 65 | displayName: projectName 66 | hubName: hub.outputs.name 67 | } 68 | } 69 | 70 | // Outputs 71 | // Resource Group 72 | output resourceGroupName string = resourceGroup().name 73 | 74 | // Hub 75 | output hubName string = hub.outputs.name 76 | output hubPrincipalId string = hub.outputs.principalId 77 | 78 | // Project 79 | output projectName string = project.outputs.name 80 | output projectPrincipalId string = project.outputs.principalId 81 | 82 | // Key Vault 83 | output keyVaultName string = hubDependencies.outputs.keyVaultName 84 | output keyVaultEndpoint string = hubDependencies.outputs.keyVaultEndpoint 85 | 86 | // Application Insights 87 | output appInsightsName string = hubDependencies.outputs.appInsightsName 88 | output logAnalyticsWorkspaceName string = hubDependencies.outputs.logAnalyticsWorkspaceName 89 | 90 | // Container Registry 91 | output containerRegistryName string = hubDependencies.outputs.containerRegistryName 92 | output containerRegistryEndpoint string = hubDependencies.outputs.containerRegistryEndpoint 93 | 94 | // Storage Account 95 | output storageAccountName string = hubDependencies.outputs.storageAccountName 96 | 97 | // Open AI 98 | output aiServicesName string = hubDependencies.outputs.aiServicesName 99 | output openAiEndpoint string = hubDependencies.outputs.openAiEndpoint 100 | 101 | // Search 102 | output searchName string = hubDependencies.outputs.searchName 103 | output searchEndpoint string = hubDependencies.outputs.searchEndpoint 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # any file .env with suffix (for example .env.local, .env.dev, .env.prod, etc.) 163 | .env* 164 | config.json 165 | .promptflow* 166 | 167 | # azd environment file 168 | .azure/ 169 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /infra/core/host/container-registry.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Indicates whether admin user is enabled') 7 | param adminUserEnabled bool = false 8 | 9 | @description('Indicates whether anonymous pull is enabled') 10 | param anonymousPullEnabled bool = false 11 | 12 | @description('Azure ad authentication as arm policy settings') 13 | param azureADAuthenticationAsArmPolicy object = { 14 | status: 'enabled' 15 | } 16 | 17 | @description('Indicates whether data endpoint is enabled') 18 | param dataEndpointEnabled bool = false 19 | 20 | @description('Encryption settings') 21 | param encryption object = { 22 | status: 'disabled' 23 | } 24 | 25 | @description('Export policy settings') 26 | param exportPolicy object = { 27 | status: 'enabled' 28 | } 29 | 30 | @description('Metadata search settings') 31 | param metadataSearch string = 'Disabled' 32 | 33 | @description('Options for bypassing network rules') 34 | param networkRuleBypassOptions string = 'AzureServices' 35 | 36 | @description('Public network access setting') 37 | param publicNetworkAccess string = 'Enabled' 38 | 39 | @description('Quarantine policy settings') 40 | param quarantinePolicy object = { 41 | status: 'disabled' 42 | } 43 | 44 | @description('Retention policy settings') 45 | param retentionPolicy object = { 46 | days: 7 47 | status: 'disabled' 48 | } 49 | 50 | @description('Scope maps setting') 51 | param scopeMaps array = [] 52 | 53 | @description('SKU settings') 54 | param sku object = { 55 | name: 'Basic' 56 | } 57 | 58 | @description('Soft delete policy settings') 59 | param softDeletePolicy object = { 60 | retentionDays: 7 61 | status: 'disabled' 62 | } 63 | 64 | @description('Trust policy settings') 65 | param trustPolicy object = { 66 | type: 'Notary' 67 | status: 'disabled' 68 | } 69 | 70 | @description('Zone redundancy setting') 71 | param zoneRedundancy string = 'Disabled' 72 | 73 | @description('The log analytics workspace ID used for logging and monitoring') 74 | param workspaceId string = '' 75 | 76 | // 2023-11-01-preview needed for metadataSearch 77 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { 78 | name: name 79 | location: location 80 | tags: tags 81 | sku: sku 82 | properties: { 83 | adminUserEnabled: adminUserEnabled 84 | anonymousPullEnabled: anonymousPullEnabled 85 | dataEndpointEnabled: dataEndpointEnabled 86 | encryption: encryption 87 | metadataSearch: metadataSearch 88 | networkRuleBypassOptions: networkRuleBypassOptions 89 | policies:{ 90 | quarantinePolicy: quarantinePolicy 91 | trustPolicy: trustPolicy 92 | retentionPolicy: retentionPolicy 93 | exportPolicy: exportPolicy 94 | azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy 95 | softDeletePolicy: softDeletePolicy 96 | } 97 | publicNetworkAccess: publicNetworkAccess 98 | zoneRedundancy: zoneRedundancy 99 | } 100 | 101 | resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { 102 | name: scopeMap.name 103 | properties: scopeMap.properties 104 | }] 105 | } 106 | 107 | // TODO: Update diagnostics to be its own module 108 | // Blocking issue: https://github.com/Azure/bicep/issues/622 109 | // Unable to pass in a `resource` scope or unable to use string interpolation in resource types 110 | resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { 111 | name: 'registry-diagnostics' 112 | scope: containerRegistry 113 | properties: { 114 | workspaceId: workspaceId 115 | logs: [ 116 | { 117 | category: 'ContainerRegistryRepositoryEvents' 118 | enabled: true 119 | } 120 | { 121 | category: 'ContainerRegistryLoginEvents' 122 | enabled: true 123 | } 124 | ] 125 | metrics: [ 126 | { 127 | category: 'AllMetrics' 128 | enabled: true 129 | timeGrain: 'PT1M' 130 | } 131 | ] 132 | } 133 | } 134 | 135 | output id string = containerRegistry.id 136 | output loginServer string = containerRegistry.properties.loginServer 137 | output name string = containerRegistry.name 138 | -------------------------------------------------------------------------------- /src/create_assistant.py: -------------------------------------------------------------------------------- 1 | """Creates an OpenAI Assistant with a code interpreter tool and a custom function tool. 2 | 3 | You would typically run this script once to create an assistant with the desired tools. 4 | Once the assistant is created, you can interact with it using the OpenAI API (see src/copilot_sdk_flow/chat.py). 5 | """ 6 | 7 | import os 8 | import json 9 | import logging 10 | import argparse 11 | from openai import AzureOpenAI 12 | from azure.identity import DefaultAzureCredential, get_bearer_token_provider 13 | 14 | from dotenv import load_dotenv, dotenv_values 15 | load_dotenv(override=True) 16 | 17 | 18 | def get_arg_parser(parser: argparse.ArgumentParser = None) -> argparse.ArgumentParser: 19 | """Get the argument parser for the script.""" 20 | if parser is None: 21 | parser = argparse.ArgumentParser(description=__doc__) 22 | 23 | parser.add_argument( 24 | "--export-env", 25 | type=str, 26 | default=os.path.join(os.path.dirname(__file__), ".env"), 27 | ) 28 | 29 | return parser 30 | 31 | 32 | def main(): 33 | """Create an assistant with a code interpreter tool and a function tool.""" 34 | logging.basicConfig(level=logging.INFO) 35 | 36 | # remove logging of some dependencies 37 | logging.getLogger("azure.core").setLevel(logging.WARNING) 38 | logging.getLogger("azure.identity").setLevel(logging.WARNING) 39 | 40 | parser = get_arg_parser() 41 | args = parser.parse_args() 42 | 43 | assert ( 44 | "AZURE_OPENAI_ENDPOINT" in os.environ 45 | ), "Please set AZURE_OPENAI_ENDPOINT in the environment variables." 46 | assert ( 47 | "AZURE_OPENAI_CHAT_DEPLOYMENT" in os.environ 48 | ), "Please set AZURE_OPENAI_CHAT_DEPLOYMENT in the environment variables." 49 | 50 | logging.info(f"Connecting to Azure OpenAI endpoint {os.getenv('AZURE_OPENAI_ENDPOINT')}") 51 | # create an AzureOpenAI client using AAD or key based auth 52 | if "AZURE_OPENAI_API_KEY" in os.environ: 53 | logging.warning( 54 | "Using key-based authentification, instead we recommend using Azure AD authentification instead." 55 | ) 56 | client = AzureOpenAI( 57 | azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), 58 | api_key=os.getenv("AZURE_OPENAI_API_KEY"), 59 | api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"), 60 | ) 61 | else: 62 | logging.info("Using Azure AD authentification [recommended]") 63 | credential = DefaultAzureCredential() 64 | token_provider = get_bearer_token_provider( 65 | credential, "https://cognitiveservices.azure.com/.default" 66 | ) 67 | client = AzureOpenAI( 68 | azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), 69 | api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"), 70 | azure_ad_token_provider=token_provider, 71 | ) 72 | 73 | with open( 74 | os.path.join( 75 | os.path.dirname(__file__), 76 | "copilot_sdk_flow", 77 | "agent_arch", 78 | "extensions", 79 | "query_order_data.json", 80 | ) 81 | ) as f: 82 | custom_function_spec = json.load(f) 83 | 84 | logging.info(f"Creating assistant...") 85 | assistant = client.beta.assistants.create( 86 | name="Contoso Sales Assistant", 87 | instructions="You are a helpful data analytics assistant helping user answer questions about the contoso sales data.", 88 | model=os.getenv("AZURE_OPENAI_CHAT_DEPLOYMENT"), 89 | tools=[ 90 | {"type": "code_interpreter"}, 91 | { 92 | "type": "function", 93 | "function": custom_function_spec, 94 | }, 95 | ], 96 | ) 97 | 98 | logging.info(f"Assistant created with id: {assistant.id}") 99 | 100 | logging.info(f"Exporting assistant id to {args.export_env}...") 101 | dotenv_vars = dotenv_values(args.export_env) 102 | dotenv_vars["AZURE_OPENAI_ASSISTANT_ID"] = assistant.id 103 | with open(args.export_env, "w") as f: 104 | for key, value in dotenv_vars.items(): 105 | f.write(f"{key}={value}\n") 106 | 107 | print( 108 | f""" 109 | ****************************************************************** 110 | Successfully created assistant with id: {assistant.id}. 111 | It has been written as an environment variable in {args.export_env}. 112 | 113 | AZURE_OPENAI_ASSISTANT_ID={assistant.id} 114 | 115 | ******************************************************************""" 116 | ) 117 | 118 | 119 | if __name__ == "__main__": 120 | main() 121 | -------------------------------------------------------------------------------- /infra/core/ai/hub-dependencies.bicep: -------------------------------------------------------------------------------- 1 | param location string = resourceGroup().location 2 | param tags object = {} 3 | 4 | @description('Name of the key vault') 5 | param keyVaultName string 6 | @description('Name of the storage account') 7 | param storageAccountName string 8 | @description('Name of the AI Services resource') 9 | param aiServicesName string 10 | param openAiModelDeployments array = [] 11 | @description('Name of the Log Analytics workspace') 12 | param logAnalyticsName string = '' 13 | @description('Name of the Application Insights instance') 14 | param appInsightsName string = '' 15 | @description('Name of the container registry') 16 | param containerRegistryName string = '' 17 | @description('Name of the Azure Cognitive Search service') 18 | param searchName string = '' 19 | 20 | module keyVault '../security/keyvault.bicep' = { 21 | name: 'keyvault' 22 | params: { 23 | location: location 24 | tags: tags 25 | name: keyVaultName 26 | } 27 | } 28 | 29 | module storageAccount '../storage/storage-account.bicep' = { 30 | name: 'storageAccount' 31 | params: { 32 | location: location 33 | tags: tags 34 | name: storageAccountName 35 | containers: [ 36 | { 37 | name: 'default' 38 | } 39 | ] 40 | files: [ 41 | { 42 | name: 'default' 43 | } 44 | ] 45 | queues: [ 46 | { 47 | name: 'default' 48 | } 49 | ] 50 | tables: [ 51 | { 52 | name: 'default' 53 | } 54 | ] 55 | corsRules: [ 56 | { 57 | allowedOrigins: [ 58 | 'https://mlworkspace.azure.ai' 59 | 'https://ml.azure.com' 60 | 'https://*.ml.azure.com' 61 | 'https://ai.azure.com' 62 | 'https://*.ai.azure.com' 63 | 'https://mlworkspacecanary.azure.ai' 64 | 'https://mlworkspace.azureml-test.net' 65 | ] 66 | allowedMethods: [ 67 | 'GET' 68 | 'HEAD' 69 | 'POST' 70 | 'PUT' 71 | 'DELETE' 72 | 'OPTIONS' 73 | 'PATCH' 74 | ] 75 | maxAgeInSeconds: 1800 76 | exposedHeaders: [ 77 | '*' 78 | ] 79 | allowedHeaders: [ 80 | '*' 81 | ] 82 | } 83 | ] 84 | deleteRetentionPolicy: { 85 | allowPermanentDelete: false 86 | enabled: false 87 | } 88 | shareDeleteRetentionPolicy: { 89 | enabled: true 90 | days: 7 91 | } 92 | } 93 | } 94 | 95 | module logAnalytics '../monitor/loganalytics.bicep' = 96 | if (!empty(logAnalyticsName)) { 97 | name: 'logAnalytics' 98 | params: { 99 | location: location 100 | tags: tags 101 | name: logAnalyticsName 102 | } 103 | } 104 | 105 | module appInsights '../monitor/applicationinsights.bicep' = 106 | if (!empty(appInsightsName) && !empty(logAnalyticsName)) { 107 | name: 'appInsights' 108 | params: { 109 | location: location 110 | tags: tags 111 | name: appInsightsName 112 | logAnalyticsWorkspaceId: !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' 113 | } 114 | } 115 | 116 | module containerRegistry '../host/container-registry.bicep' = 117 | if (!empty(containerRegistryName)) { 118 | name: 'containerRegistry' 119 | params: { 120 | location: location 121 | tags: tags 122 | name: containerRegistryName 123 | } 124 | } 125 | 126 | module cognitiveServices '../ai/cognitiveservices.bicep' = { 127 | name: 'cognitiveServices' 128 | params: { 129 | location: location 130 | tags: tags 131 | name: aiServicesName 132 | kind: 'AIServices' 133 | deployments: openAiModelDeployments 134 | } 135 | } 136 | 137 | module search '../search/search-services.bicep' = 138 | if (!empty(searchName)) { 139 | name: 'search' 140 | params: { 141 | location: location 142 | tags: tags 143 | name: searchName 144 | } 145 | } 146 | 147 | output keyVaultId string = keyVault.outputs.id 148 | output keyVaultName string = keyVault.outputs.name 149 | output keyVaultEndpoint string = keyVault.outputs.endpoint 150 | 151 | output storageAccountId string = storageAccount.outputs.id 152 | output storageAccountName string = storageAccount.outputs.name 153 | 154 | output containerRegistryId string = !empty(containerRegistryName) ? containerRegistry.outputs.id : '' 155 | output containerRegistryName string = !empty(containerRegistryName) ? containerRegistry.outputs.name : '' 156 | output containerRegistryEndpoint string = !empty(containerRegistryName) ? containerRegistry.outputs.loginServer : '' 157 | 158 | output appInsightsId string = !empty(appInsightsName) ? appInsights.outputs.id : '' 159 | output appInsightsName string = !empty(appInsightsName) ? appInsights.outputs.name : '' 160 | output logAnalyticsWorkspaceId string = !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' 161 | output logAnalyticsWorkspaceName string = !empty(logAnalyticsName) ? logAnalytics.outputs.name : '' 162 | 163 | output aiServicesId string = cognitiveServices.outputs.id 164 | output aiServicesName string = cognitiveServices.outputs.name 165 | output openAiEndpoint string = cognitiveServices.outputs.endpoints['OpenAI Language Model Instance API'] 166 | 167 | output searchId string = !empty(searchName) ? search.outputs.id : '' 168 | output searchName string = !empty(searchName) ? search.outputs.name : '' 169 | output searchEndpoint string = !empty(searchName) ? search.outputs.endpoint : '' 170 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/sessions.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import logging 3 | from openai import AzureOpenAI 4 | from openai.types.chat.chat_completion_message import ChatCompletionMessage 5 | from openai.types.beta.thread import Thread 6 | import traceback 7 | from typing import Any 8 | from promptflow.tracing import trace 9 | from collections import deque 10 | from agent_arch.messages import ( 11 | ExtensionCallMessage, 12 | ExtensionReturnMessage, 13 | StepNotification, 14 | TextResponse, 15 | ImageResponse, 16 | ) 17 | 18 | 19 | class Session: 20 | """Represents a session with the assistant.""" 21 | 22 | def __init__(self, thread: Thread, client: AzureOpenAI): 23 | """Initializes a new session with the assistant. 24 | 25 | Args: 26 | thread (Thread): The thread associated with the session. 27 | client (AzureOpenAI): The AzureOpenAI client. 28 | """ 29 | self.id = thread.id 30 | self.thread = thread 31 | self.client = client 32 | self.output_queue = deque() 33 | self.open = True 34 | 35 | @trace 36 | def record_message(self, message: Union[dict, ChatCompletionMessage]): 37 | """Appends a message to the session. 38 | 39 | Args: 40 | message (ChatCompletionMessage): The message to append. 41 | 42 | Returns: 43 | None 44 | """ 45 | if isinstance(message, dict): 46 | assert "role" in message, "role is required" 47 | assert "content" in message, "content is required" 48 | self.client.beta.threads.messages.create( 49 | thread_id=self.thread.id, 50 | role=message["role"], 51 | content=message["content"], 52 | ) 53 | elif isinstance(message, ChatCompletionMessage): 54 | self.client.beta.threads.messages.create( 55 | thread_id=self.thread.id, 56 | role=message.role, 57 | content=message.content, 58 | ) 59 | 60 | @trace 61 | def send(self, message: Any): 62 | """Sends a message back to the user. 63 | 64 | Args: 65 | message (Any): The message to send. 66 | """ 67 | if isinstance(message, ExtensionCallMessage): 68 | if message.name == "query_order_data": 69 | output_message = f"_Calling extension `{message.name}` with SQL query:_\n```sql\n{message.args['sql_query']}\n```\n\n" 70 | else: 71 | output_message = f"_Calling extension `{message.name}`_\n\n" 72 | elif isinstance(message, ExtensionReturnMessage): 73 | # output_message = f"_Extension `{message.name}` returned: `{message.content}`_\n\n" 74 | output_message = None 75 | elif isinstance(message, StepNotification): 76 | # output_message = f"_Agent moved forward with step: `{message.type}`: `{message.content}`_\n" 77 | output_message = None 78 | elif isinstance(message, TextResponse): 79 | output_message = message.content 80 | elif isinstance(message, ImageResponse): 81 | output_message = "![image](" + message.content + ")\n\n" 82 | else: 83 | logging.critical(f"Unknown message type: {type(message)}") 84 | output_message = f"`Unknown message type: {type(message)}`\n\n" 85 | if output_message: 86 | logging.info( 87 | f"Queueing message type={message.__class__.__name__} len={len(output_message)}" 88 | ) 89 | self.output_queue.append(output_message) 90 | 91 | def close(self): 92 | """Closes the session.""" 93 | self.open = False 94 | 95 | 96 | class SessionManager: 97 | """Manages assistant sessions.""" 98 | 99 | def __init__(self, aoai_client: AzureOpenAI): 100 | """Initializes a new session manager. 101 | 102 | Args: 103 | aoai_client (AzureOpenAI): The AzureOpenAI client. 104 | """ 105 | self.aoai_client = aoai_client 106 | self.sessions = {} 107 | 108 | @trace 109 | def create_session(self) -> Session: 110 | """Creates a new session.""" 111 | thread = self.aoai_client.beta.threads.create() 112 | return Session(thread=thread, client=self.aoai_client) 113 | 114 | @trace 115 | def get_session(self, session_id: str) -> Union[Session, None]: 116 | """Gets a session by its ID.""" 117 | if session_id in self.sessions: 118 | return self.sessions[session_id] 119 | 120 | try: 121 | thread = self.aoai_client.beta.threads.retrieve(session_id) 122 | except Exception as e: 123 | logging.critical( 124 | f"Error retrieving thread {session_id}: {traceback.format_exc()}" 125 | ) 126 | return None 127 | 128 | self.sessions[session_id] = Session(thread=thread, client=self.aoai_client) 129 | 130 | return self.sessions[thread.id] 131 | 132 | def set_session(self, session_id, session: Session): 133 | """Sets a session.""" 134 | self.sessions[session_id] = session 135 | 136 | def clear_session(self, session_id): 137 | """Clears a session.""" 138 | if session_id in self.sessions: 139 | del self.sessions[session_id] 140 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationStores": "appcs-", 5 | "appManagedEnvironments": "cae-", 6 | "appContainerApps": "ca-", 7 | "authorizationPolicyDefinitions": "policy-", 8 | "automationAutomationAccounts": "aa-", 9 | "blueprintBlueprints": "bp-", 10 | "blueprintBlueprintsArtifacts": "bpa-", 11 | "cacheRedis": "redis-", 12 | "cdnProfiles": "cdnp-", 13 | "cdnProfilesEndpoints": "cdne-", 14 | "cognitiveServicesAccounts": "cog-", 15 | "cognitiveServicesFormRecognizer": "cog-fr-", 16 | "cognitiveServicesTextAnalytics": "cog-ta-", 17 | "computeAvailabilitySets": "avail-", 18 | "computeCloudServices": "cld-", 19 | "computeDiskEncryptionSets": "des", 20 | "computeDisks": "disk", 21 | "computeDisksOs": "osdisk", 22 | "computeGalleries": "gal", 23 | "computeSnapshots": "snap-", 24 | "computeVirtualMachines": "vm", 25 | "computeVirtualMachineScaleSets": "vmss-", 26 | "containerInstanceContainerGroups": "ci", 27 | "containerRegistryRegistries": "cr", 28 | "containerServiceManagedClusters": "aks-", 29 | "databricksWorkspaces": "dbw-", 30 | "dataFactoryFactories": "adf-", 31 | "dataLakeAnalyticsAccounts": "dla", 32 | "dataLakeStoreAccounts": "dls", 33 | "dataMigrationServices": "dms-", 34 | "dBforMySQLServers": "mysql-", 35 | "dBforPostgreSQLServers": "psql-", 36 | "devicesIotHubs": "iot-", 37 | "devicesProvisioningServices": "provs-", 38 | "devicesProvisioningServicesCertificates": "pcert-", 39 | "documentDBDatabaseAccounts": "cosmos-", 40 | "eventGridDomains": "evgd-", 41 | "eventGridDomainsTopics": "evgt-", 42 | "eventGridEventSubscriptions": "evgs-", 43 | "eventHubNamespaces": "evhns-", 44 | "eventHubNamespacesEventHubs": "evh-", 45 | "hdInsightClustersHadoop": "hadoop-", 46 | "hdInsightClustersHbase": "hbase-", 47 | "hdInsightClustersKafka": "kafka-", 48 | "hdInsightClustersMl": "mls-", 49 | "hdInsightClustersSpark": "spark-", 50 | "hdInsightClustersStorm": "storm-", 51 | "hybridComputeMachines": "arcs-", 52 | "insightsActionGroups": "ag-", 53 | "insightsComponents": "appi-", 54 | "keyVaultVaults": "kv-", 55 | "kubernetesConnectedClusters": "arck", 56 | "kustoClusters": "dec", 57 | "kustoClustersDatabases": "dedb", 58 | "loadTesting": "lt-", 59 | "logicIntegrationAccounts": "ia-", 60 | "logicWorkflows": "logic-", 61 | "machineLearningServicesWorkspaces": "mlw-", 62 | "managedIdentityUserAssignedIdentities": "id-", 63 | "managementManagementGroups": "mg-", 64 | "migrateAssessmentProjects": "migr-", 65 | "networkApplicationGateways": "agw-", 66 | "networkApplicationSecurityGroups": "asg-", 67 | "networkAzureFirewalls": "afw-", 68 | "networkBastionHosts": "bas-", 69 | "networkConnections": "con-", 70 | "networkDnsZones": "dnsz-", 71 | "networkExpressRouteCircuits": "erc-", 72 | "networkFirewallPolicies": "afwp-", 73 | "networkFirewallPoliciesWebApplication": "waf", 74 | "networkFirewallPoliciesRuleGroups": "wafrg", 75 | "networkFrontDoors": "fd-", 76 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 77 | "networkLoadBalancersExternal": "lbe-", 78 | "networkLoadBalancersInternal": "lbi-", 79 | "networkLoadBalancersInboundNatRules": "rule-", 80 | "networkLocalNetworkGateways": "lgw-", 81 | "networkNatGateways": "ng-", 82 | "networkNetworkInterfaces": "nic-", 83 | "networkNetworkSecurityGroups": "nsg-", 84 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 85 | "networkNetworkWatchers": "nw-", 86 | "networkPrivateDnsZones": "pdnsz-", 87 | "networkPrivateLinkServices": "pl-", 88 | "networkPublicIPAddresses": "pip-", 89 | "networkPublicIPPrefixes": "ippre-", 90 | "networkRouteFilters": "rf-", 91 | "networkRouteTables": "rt-", 92 | "networkRouteTablesRoutes": "udr-", 93 | "networkTrafficManagerProfiles": "traf-", 94 | "networkVirtualNetworkGateways": "vgw-", 95 | "networkVirtualNetworks": "vnet-", 96 | "networkVirtualNetworksSubnets": "snet-", 97 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 98 | "networkVirtualWans": "vwan-", 99 | "networkVpnGateways": "vpng-", 100 | "networkVpnGatewaysVpnConnections": "vcn-", 101 | "networkVpnGatewaysVpnSites": "vst-", 102 | "notificationHubsNamespaces": "ntfns-", 103 | "notificationHubsNamespacesNotificationHubs": "ntf-", 104 | "operationalInsightsWorkspaces": "log-", 105 | "portalDashboards": "dash-", 106 | "powerBIDedicatedCapacities": "pbi-", 107 | "purviewAccounts": "pview-", 108 | "recoveryServicesVaults": "rsv-", 109 | "resourcesResourceGroups": "rg-", 110 | "searchSearchServices": "srch-", 111 | "serviceBusNamespaces": "sb-", 112 | "serviceBusNamespacesQueues": "sbq-", 113 | "serviceBusNamespacesTopics": "sbt-", 114 | "serviceEndPointPolicies": "se-", 115 | "serviceFabricClusters": "sf-", 116 | "signalRServiceSignalR": "sigr", 117 | "sqlManagedInstances": "sqlmi-", 118 | "sqlServers": "sql-", 119 | "sqlServersDataWarehouse": "sqldw-", 120 | "sqlServersDatabases": "sqldb-", 121 | "sqlServersDatabasesStretch": "sqlstrdb-", 122 | "storageStorageAccounts": "st", 123 | "storageStorageAccountsVm": "stvm", 124 | "storSimpleManagers": "ssimp", 125 | "streamAnalyticsCluster": "asa-", 126 | "synapseWorkspaces": "syn", 127 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 128 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 129 | "synapseWorkspacesSqlPoolsSpark": "synsp", 130 | "timeSeriesInsightsEnvironments": "tsi-", 131 | "webServerFarms": "plan-", 132 | "webSitesAppService": "app-", 133 | "webSitesAppServiceEnvironment": "ase-", 134 | "webSitesFunctions": "func-", 135 | "webStaticSites": "stapp-" 136 | } -------------------------------------------------------------------------------- /src/check_quota.py: -------------------------------------------------------------------------------- 1 | import os 2 | from azure.identity import DefaultAzureCredential 3 | from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient 4 | import argparse 5 | from tabulate import tabulate 6 | 7 | # list of candidate models we need 8 | CANDIDATE_MODELS = [ 9 | {"name": "gpt-35-turbo", "version": "1106", "sku": "Standard", "kind": "OpenAI"}, 10 | {"name": "gpt-35-turbo", "version": "0301", "sku": "Standard", "kind": "OpenAI"}, 11 | {"name": "gpt-35-turbo", "version": "0613", "sku": "Standard", "kind": "OpenAI"}, 12 | # {"name": "gpt-4", "version": "1106-Preview", "sku": "Standard", "kind": "OpenAI"}, 13 | ] 14 | 15 | # list of regions in which to look for candidate models 16 | CANDIDATE_LOCATIONS = [ 17 | "australiaeast", 18 | "eastus", 19 | "eastus2", 20 | "francecentral", 21 | "norwayeast", 22 | "swedencentral", 23 | "uksouth", 24 | ] 25 | 26 | # copied from https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits 27 | REGIONAL_QUOTA_LIMITS = { 28 | # gpt-4 29 | ("australiaeast", "Standard", "gpt-4"): 40, 30 | ("eastus", "Standard", "gpt-4"): 0, 31 | ("eastus2", "Standard", "gpt-4"): 0, 32 | ("francecentral", "Standard", "gpt-4"): 20, 33 | ("norwayeast", "Standard", "gpt-4"): 0, 34 | ("swedencentral", "Standard", "gpt-4"): 40, 35 | ("uksouth", "Standard", "gpt-4"): 0, 36 | # gpt-35-turbo 37 | ("australiaeast", "Standard", "gpt-35-turbo"): 300, 38 | ("eastus", "Standard", "gpt-35-turbo"): 240, 39 | ("eastus2", "Standard", "gpt-35-turbo"): 300, 40 | ("francecentral", "Standard", "gpt-35-turbo"): 240, 41 | ("norwayeast", "Standard", "gpt-35-turbo"): 0, 42 | ("swedencentral", "Standard", "gpt-35-turbo"): 300, 43 | ("uksouth", "Standard", "gpt-35-turbo"): 240, 44 | } 45 | 46 | 47 | def fetch_quota(client, locations, models): 48 | """Fetch the quota for the specified models in the specified locations. 49 | 50 | Args: 51 | client (CognitiveServicesManagementClient): The client to use to fetch the quota. 52 | locations (list): The list of locations to search for the models. 53 | models (list): The list of models to search for, see CANDIDATE_MODELS 54 | """ 55 | fetched_quotas_table = [] 56 | 57 | for location in locations: 58 | print(f"Fetching quotas for the candidate models in {location}") 59 | for model in client.models.list(location=location): 60 | for _model in models: 61 | if ( 62 | model.model.name == _model["name"] 63 | and ( 64 | model.model.version == _model["version"] 65 | or _model["version"] == "*" 66 | ) 67 | and (model.kind == _model["kind"] or _model["kind"] == "*") 68 | ): 69 | for sku in model.model.skus: 70 | if sku.name == _model["sku"] or _model["sku"] == "*": 71 | # print(model.serialize()) 72 | quota = REGIONAL_QUOTA_LIMITS.get( 73 | (location, sku.name, model.model.name), 0 74 | ) 75 | fetched_quotas_table.append( 76 | { 77 | "model": model.model.name, 78 | "version": model.model.version, 79 | "kind": model.kind, 80 | "location": location, 81 | "sku": sku.name, 82 | "quota": quota, 83 | "remaining_quota": quota, 84 | } 85 | ) 86 | return fetched_quotas_table 87 | 88 | 89 | def fetch_deployments(client): 90 | """Fetch the deployments for the specified models in the specified locations. 91 | 92 | Args: 93 | client (CognitiveServicesManagementClient): The client to use to fetch the deployments. 94 | """ 95 | deployments_table = [] 96 | 97 | for account in client.accounts.list(): 98 | print(f"Fetching deployments for the account {account.name}...") 99 | resource_group = account.id.split("/")[4] 100 | for deployment in client.deployments.list( 101 | resource_group_name=resource_group, 102 | account_name=account.name, 103 | ): 104 | deployments_table.append( 105 | { 106 | "account": account.name, 107 | "location": account.location, 108 | "resource_group": resource_group, 109 | "deployment": deployment.name, 110 | "model": deployment.properties.model.name, 111 | "version": deployment.properties.model.version, 112 | "sku": deployment.sku.name, 113 | "used_capacity": deployment.sku.capacity, 114 | } 115 | ) 116 | # print(deployments_table[-1]) 117 | return deployments_table 118 | 119 | 120 | def main(): 121 | """Main function to run the script.""" 122 | parser = argparse.ArgumentParser() 123 | parser.add_argument( 124 | "--subscription-id", 125 | help="Azure subscription id", 126 | type=str, 127 | default=os.getenv("AZURE_SUBSCRIPTION_ID"), 128 | ) 129 | args = parser.parse_args() 130 | 131 | # get a client 132 | client = CognitiveServicesManagementClient( 133 | credential=DefaultAzureCredential(), 134 | subscription_id=args.subscription_id, 135 | ) 136 | 137 | # Fetch the quota for the candidate models in the candidate locations 138 | print("Fetching quotas for the candidate models in the candidate locations") 139 | fetched_quotas_table = fetch_quota(client, CANDIDATE_LOCATIONS, CANDIDATE_MODELS) 140 | # print(json.dumps(fetched_quotas_table, indent=4)) 141 | 142 | # Fetch the deployments for the candidate models 143 | print("Fetching existing deployments in your subscription for the candidate models") 144 | fetched_deployments_table = fetch_deployments(client) 145 | # print(json.dumps(fetched_deployments_table, indent=4)) 146 | 147 | # substract the capacity of the deployments from the quota 148 | for quota in fetched_quotas_table: 149 | for deployment in fetched_deployments_table: 150 | # capacity is segmented per model per location 151 | # different model versions are merged into a single model capacity 152 | if ( 153 | quota["model"] == deployment["model"] 154 | and quota["location"] == deployment["location"] 155 | and quota["sku"] == deployment["sku"] 156 | ): 157 | quota["remaining_quota"] -= deployment["used_capacity"] 158 | if "used_at" not in quota: 159 | quota["used_at"] = [] 160 | quota["used_at"].append( 161 | deployment["deployment"] 162 | + "@" 163 | + deployment["version"] 164 | + ":" 165 | + str(deployment["used_capacity"]) 166 | ) 167 | 168 | # show table in a readable format 169 | print(tabulate(fetched_quotas_table, headers="keys", tablefmt="pretty")) 170 | 171 | 172 | if __name__ == "__main__": 173 | main() 174 | -------------------------------------------------------------------------------- /src/evaluate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import argparse 3 | import logging 4 | 5 | import os 6 | import pandas as pd 7 | 8 | from promptflow.core import AzureOpenAIModelConfiguration 9 | from promptflow.evals.evaluate import evaluate 10 | from promptflow.evals.evaluators import ( 11 | CoherenceEvaluator, 12 | F1ScoreEvaluator, 13 | FluencyEvaluator, 14 | GroundednessEvaluator, 15 | RelevanceEvaluator, 16 | SimilarityEvaluator, 17 | QAEvaluator, 18 | # ChatEvaluator, 19 | ) 20 | from tabulate import tabulate 21 | 22 | # local imports 23 | import sys 24 | 25 | # TODO: using sys.path as hotfix to be able to run the script from 3 different locations 26 | sys.path.append(os.path.join(os.path.dirname(__file__))) 27 | from copilot_sdk_flow.entry import flow_entry_copilot_assistants 28 | 29 | from dotenv import load_dotenv 30 | 31 | load_dotenv() 32 | 33 | 34 | def get_model_config(evaluation_endpoint, evaluation_model): 35 | """Get the model configuration for the evaluation.""" 36 | 37 | # create an AzureOpenAI client using AAD or key based auth 38 | if "AZURE_OPENAI_API_KEY" in os.environ: 39 | logging.warning( 40 | "Using key-based authentification, instead we recommend using Azure AD authentification instead." 41 | ) 42 | api_key = os.getenv("AZURE_OPENAI_API_KEY") 43 | 44 | model_config = AzureOpenAIModelConfiguration( 45 | azure_endpoint=evaluation_endpoint, 46 | api_key=api_key, 47 | azure_deployment=evaluation_model, 48 | ) 49 | else: 50 | logging.info("Using Azure AD authentification [recommended]") 51 | model_config = AzureOpenAIModelConfiguration( 52 | azure_endpoint=evaluation_endpoint, 53 | azure_deployment=evaluation_model, 54 | ) 55 | 56 | return model_config 57 | 58 | 59 | def run_evaluation( 60 | evaluation_name, 61 | evaluation_model_config, 62 | evaluation_data_path, 63 | metrics, 64 | output_path=None, 65 | ): 66 | """Run the evaluation routine.""" 67 | # completion_func = latency_qna_function 68 | completion_func = flow_entry_copilot_assistants 69 | 70 | # Initializing Relevance Evaluator 71 | evaluators = {} 72 | evaluators_config = {} 73 | for metric_name in metrics: 74 | if metric_name == "coherence": 75 | evaluators[metric_name] = CoherenceEvaluator(evaluation_model_config) 76 | # map fields required by the evaluators to either 77 | # fields in the completion_func return dict (target.*) 78 | # or fields in the input data (data.*) 79 | evaluators_config[metric_name] = { 80 | "question": "${data.chat_input}", 81 | "answer": "${target.reply}", 82 | } 83 | elif metric_name == "f1score": 84 | evaluators[metric_name] = F1ScoreEvaluator() 85 | evaluators_config[metric_name] = { 86 | "answer": "${target.reply}", 87 | "ground_truth": "${data.ground_truth}", 88 | } 89 | elif metric_name == "fluency": 90 | evaluators[metric_name] = FluencyEvaluator(evaluation_model_config) 91 | evaluators_config[metric_name] = { 92 | "question": "${data.chat_input}", 93 | "answer": "${target.reply}", 94 | } 95 | elif metric_name == "groundedness": 96 | evaluators[metric_name] = GroundednessEvaluator(evaluation_model_config) 97 | evaluators_config[metric_name] = { 98 | "answer": "${target.reply}", 99 | "context": "${target.context}", 100 | } 101 | elif metric_name == "relevance": 102 | evaluators[metric_name] = RelevanceEvaluator(evaluation_model_config) 103 | evaluators_config[metric_name] = { 104 | "question": "${data.chat_input}", 105 | "answer": "${target.reply}", 106 | "context": "${target.context}", 107 | } 108 | elif metric_name == "similarity": 109 | evaluators[metric_name] = SimilarityEvaluator(evaluation_model_config) 110 | evaluators_config[metric_name] = { 111 | "question": "${data.chat_input}", 112 | "answer": "${target.reply}", 113 | "ground_truth": "${data.ground_truth}", 114 | } 115 | elif metric_name == "qa": 116 | evaluators[metric_name] = QAEvaluator(evaluation_model_config) 117 | evaluators_config[metric_name] = { 118 | "question": "${data.chat_input}", 119 | "answer": "${target.reply}", 120 | "context": "${target.context}", 121 | "ground_truth": "${data.ground_truth}", 122 | } 123 | elif metric_name == "latency": 124 | raise NotImplementedError("Latency metric is not implemented yet") 125 | else: 126 | raise ValueError(f"Unknown metric: {metric_name}") 127 | 128 | logging.info( 129 | f"Running evaluation name={evaluation_name} on dataset {evaluation_data_path}" 130 | ) 131 | 132 | result = evaluate( 133 | target=completion_func, 134 | evaluation_name=evaluation_name, 135 | evaluators=evaluators, 136 | evaluator_config=evaluators_config, 137 | data=evaluation_data_path, 138 | ) 139 | 140 | tabular_result = pd.DataFrame(result.get("rows")) 141 | return result, tabular_result 142 | 143 | 144 | def main(): 145 | """Run the evaluation script.""" 146 | # create argument parser 147 | parser = argparse.ArgumentParser() 148 | parser.add_argument( 149 | "--evaluation-data-path", 150 | help="Path to JSONL file containing evaluation dataset", 151 | required=True, 152 | ) 153 | parser.add_argument( 154 | "--evaluation-name", 155 | help="evaluation name used to log the evaluation to AI Studio", 156 | type=str, 157 | default="eval-sdk-dev", 158 | ) 159 | parser.add_argument( 160 | "--evaluation-endpoint", 161 | help="Azure OpenAI endpoint used for evaluation", 162 | type=str, 163 | default=os.getenv("AZURE_OPENAI_ENDPOINT"), 164 | ) 165 | parser.add_argument( 166 | "--evaluation-model", 167 | help="Azure OpenAI model deployment name used for evaluation", 168 | type=str, 169 | default=os.getenv("AZURE_OPENAI_EVALUATION_DEPLOYMENT"), 170 | ) 171 | parser.add_argument( 172 | "--metrics", 173 | nargs="+", 174 | help="List of metrics to evaluate", 175 | choices=[ 176 | "coherence", 177 | "f1score", 178 | "fluency", 179 | "groundedness", 180 | "relevance", 181 | "similarity", 182 | "qa", 183 | "chat", 184 | "latency", 185 | ], 186 | required=True, 187 | ) 188 | args = parser.parse_args() 189 | 190 | # set logging 191 | logging.basicConfig( 192 | level=logging.INFO, 193 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 194 | ) 195 | # set logging level from some dependencies 196 | logging.getLogger("azure.core").setLevel(logging.ERROR) 197 | logging.getLogger("azure.identity").setLevel(logging.ERROR) 198 | logging.getLogger("azure.monitor").setLevel(logging.ERROR) 199 | # logging.getLogger("promptflow").setLevel(logging.ERROR) 200 | 201 | logging.info(f"Running script with arguments: {args}") 202 | 203 | # get a model config for evaluation 204 | eval_model_config = get_model_config( 205 | args.evaluation_endpoint, args.evaluation_model 206 | ) 207 | 208 | # run the evaluation routine 209 | result, tabular_result = run_evaluation( 210 | evaluation_name=args.evaluation_name, 211 | evaluation_model_config=eval_model_config, 212 | evaluation_data_path=args.evaluation_data_path, 213 | metrics=args.metrics, 214 | ) 215 | 216 | print("-----Summarized Metrics-----") 217 | print(result["metrics"]) 218 | print("-----Tabular Result-----") 219 | print(tabulate(tabular_result, headers="keys", tablefmt="pretty", maxcolwidths=50)) 220 | print(f"View evaluation results in AI Studio: {result['studio_url']}") 221 | 222 | 223 | if __name__ == "__main__": 224 | main() 225 | -------------------------------------------------------------------------------- /src/copilot_sdk_flow/agent_arch/orchestrator.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import json 4 | import base64 5 | 6 | from promptflow.tracing import trace 7 | 8 | # local imports 9 | from agent_arch.config import Configuration 10 | from agent_arch.messages import ( 11 | TextResponse, 12 | ImageResponse, 13 | ExtensionCallMessage, 14 | ExtensionReturnMessage, 15 | StepNotification, 16 | ) 17 | 18 | 19 | class Orchestrator: 20 | def __init__(self, config: Configuration, client, session, extensions): 21 | self.client = client 22 | self.config = config 23 | self.session = session 24 | self.extensions = extensions 25 | 26 | # getting the Assistant API specific constructs 27 | logging.info( 28 | f"Retrieving assistant with id: {config.AZURE_OPENAI_ASSISTANT_ID}" 29 | ) 30 | self.assistant = self.client.beta.assistants.retrieve( 31 | self.config.AZURE_OPENAI_ASSISTANT_ID 32 | ) 33 | self.thread = self.session.thread 34 | 35 | logging.info(f"Orchestrator initialized with session_id: {session.id}") 36 | 37 | self.run = None 38 | self.last_step_id = None 39 | self.last_message_id = None 40 | 41 | @trace 42 | def run_loop(self): 43 | logging.info(f"Creating the run") 44 | self.run = self.client.beta.threads.runs.create( 45 | thread_id=self.thread.id, assistant_id=self.assistant.id 46 | ) 47 | logging.info(f"Pre loop run status: {self.run.status}") 48 | 49 | start_time = time.time() 50 | 51 | # loop until max_waiting_time is reached 52 | while (time.time() - start_time) < self.config.ORCHESTRATOR_MAX_WAITING_TIME: 53 | # checks the run regularly 54 | self.run = self.client.beta.threads.runs.retrieve( 55 | thread_id=self.thread.id, run_id=self.run.id 56 | ) 57 | logging.info( 58 | f"Run status: {self.run.status} (time={int(time.time() - start_time)}s, max_waiting_time={self.config.ORCHESTRATOR_MAX_WAITING_TIME})" 59 | ) 60 | 61 | # check if a step has been completed 62 | run_steps = self.client.beta.threads.runs.steps.list( 63 | thread_id=self.thread.id, run_id=self.run.id, after=self.last_step_id 64 | ) 65 | for step in run_steps: 66 | logging.info( 67 | "The assistant has moved forward to step {}".format(step.id) 68 | ) 69 | self.process_step(step) 70 | self.last_step_id = step.id 71 | 72 | # check if there are messages 73 | for message in self.client.beta.threads.messages.list( 74 | thread_id=self.thread.id, order="asc", after=self.last_message_id 75 | ): 76 | message = self.client.beta.threads.messages.retrieve( 77 | thread_id=self.thread.id, message_id=message.id 78 | ) 79 | self.process_message(message) 80 | # self.session.send(message) 81 | self.last_message_id = message.id 82 | 83 | if self.run.status == "completed": 84 | logging.info(f"Run completed.") 85 | return self.completed() 86 | elif self.run.status == "requires_action": 87 | logging.info(f"Run requires action.") 88 | self.requires_action() 89 | elif self.run.status == "cancelled": 90 | raise Exception(f"Run was cancelled: {self.run.status}") 91 | elif self.run.status == "expired": 92 | raise Exception(f"Run expired: {self.run.status}") 93 | elif self.run.status == "failed": 94 | raise ValueError( 95 | f"Run failed with status: {self.run.status}, last_error: {self.run.last_error}" 96 | ) 97 | elif self.run.status in ["in_progress", "queued"]: 98 | time.sleep(0.25) 99 | else: 100 | raise ValueError(f"Unknown run status: {self.run.status}") 101 | 102 | @trace 103 | def process_message(self, message): 104 | for entry in message.content: 105 | if message.role == "user": 106 | # this means a message we just added 107 | pass 108 | elif entry.type == "text": 109 | self.session.send( 110 | TextResponse(role=message.role, content=entry.text.value) 111 | ) 112 | elif entry.type == "image_file": 113 | file_id = entry.image_file.file_id 114 | self.session.send( 115 | ImageResponse.from_bytes(self.client.files.content(file_id).read()) 116 | ) 117 | else: 118 | logging.critical("Unknown content type: {}".format(entry.type)) 119 | 120 | @trace 121 | def process_step(self, step): 122 | """Process a step from the run""" 123 | if step.type == "tool_calls": 124 | for tool_call in step.step_details.tool_calls: 125 | if tool_call.type == "code": 126 | self.session.send( 127 | StepNotification( 128 | type=step.type, content=str(tool_call.model_dump()) 129 | ) 130 | ) 131 | elif tool_call.type == "function": 132 | self.session.send( 133 | StepNotification( 134 | type=step.type, content=str(tool_call.model_dump()) 135 | ) 136 | ) 137 | else: 138 | logging.error(f"Unsupported tool call type: {tool_call.type}") 139 | else: 140 | logging.error(f"Unsupported step type: {step.type}") 141 | 142 | @trace 143 | def completed(self): 144 | """What to do when run.status == 'completed'""" 145 | self.session.close() 146 | 147 | @trace 148 | def requires_action(self): 149 | """What to do when run.status == 'requires_action'""" 150 | # if the run requires us to run a tool 151 | tool_call_outputs = [] 152 | 153 | for tool_call in self.run.required_action.submit_tool_outputs.tool_calls: 154 | if tool_call.type == "function": 155 | # let's keep sync for now 156 | logging.info( 157 | f"Calling tool: {tool_call.function.name} with args: {tool_call.function.arguments}" 158 | ) 159 | # decode the arguments from the api 160 | try: 161 | extension_args = json.loads(tool_call.function.arguments) 162 | except json.JSONDecodeError as e: 163 | logging.critical(f"Error decoding extension arguments: {e}") 164 | continue 165 | 166 | # send some early message to the user 167 | self.session.send( 168 | ExtensionCallMessage( 169 | name=tool_call.function.name, args=extension_args 170 | ) 171 | ) 172 | 173 | # invoke the extension 174 | tool_call_output = self.extensions.get_extension( 175 | tool_call.function.name 176 | ).invoke(**extension_args) 177 | 178 | # send success to the user 179 | self.session.send( 180 | ExtensionReturnMessage( 181 | name=tool_call.function.name, content=tool_call_output 182 | ) 183 | ) 184 | 185 | # store the output for the tool 186 | tool_call_outputs.append( 187 | { 188 | "tool_call_id": tool_call.id, 189 | "output": json.dumps(tool_call_output), 190 | } 191 | ) 192 | else: 193 | raise ValueError(f"Unsupported tool call type: {tool_call.type}") 194 | 195 | if tool_call_outputs: 196 | logging.info(f"Submitting tool outputs: {tool_call_outputs}") 197 | _ = self.client.beta.threads.runs.submit_tool_outputs( 198 | thread_id=self.thread.id, 199 | run_id=self.run.id, 200 | tool_outputs=tool_call_outputs, 201 | ) 202 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Assistant for sales data analytics in python and promptflow 2 | 3 | This repository implements a data analytics chatbot based on the Assistants API. 4 | The chatbot can answer questions in natural language, and interpret them as queries 5 | on an example sales dataset. 6 | 7 | This document explains how to provision, evaluate and deploy using the Azure AI SDK. 8 | For instructions on how to use azd instead, please refer to [the repository README](../README.md) instead. 9 | 10 | ## Getting Started 11 | 12 | ### Prerequisites 13 | 14 | - Install [azd](https://aka.ms/install-azd) 15 | - Windows: `winget install microsoft.azd` 16 | - Linux: `curl -fsSL https://aka.ms/install-azd.sh | bash` 17 | - MacOS: `brew tap azure/azd && brew install azd` 18 | - An Azure subscription - [Create one for free](https://azure.microsoft.com/free/cognitive-services) 19 | - Access granted to Azure OpenAI in the desired Azure subscription 20 | Currently, access to this service is granted only by application. You can apply for access to Azure OpenAI by completing the form at [aka.ms/oai/access](https://aka.ms/oai/access). 21 | - Python 3.10 or 3.11 versions 22 | 23 | Note: This model uses gpt-35-turbo or gpt-4 for assistants which may not be available in all Azure locations. Check for [up-to-date region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#standard-deployment-model-availability) and select a location during deployment accordingly. 24 | 25 | ### Installation 26 | 27 | 1. First, clone the code sample locally and enter into the `src/` subdirectory: 28 | 29 | ```bash 30 | git clone https://github.com/Azure-Samples/assistant-data-openai-python-promptflow 31 | cd assistant-data-openai-python-promptflow/src/ 32 | ``` 33 | 34 | 2. Next, create a new Python virtual environment where we can safely install the SDK packages: 35 | 36 | * On MacOS and Linux run: 37 | ```bash 38 | python --version 39 | python -m venv .venv 40 | source .venv/bin/activate 41 | ``` 42 | 43 | * On Windows run: 44 | ```ps 45 | python --version 46 | python -m venv .venv 47 | .venv\scripts\activate 48 | ``` 49 | 50 | 3. Now that your environment is activated, install the SDK packages (from the `src/` folder): 51 | 52 | ```bash 53 | pip install -r requirements.txt 54 | ``` 55 | 56 | ### Before your start: check your quota 57 | 58 | To ensure you have quota to provision `gpt-35-turbo` version `1106`, you can either go to [oai.azure.com](https://oai.azure.com/) and check the Quota page in a given location. 59 | 60 | You can also try running our experimental script to check quota in your subscription: 61 | 62 | ```bash 63 | python ./check_quota.py --subscription-id [SUBSCRIPTION_ID] 64 | ``` 65 | 66 | > Note: this script is a tentative to help locating quota, but it might provide numbers that are not accurate. The [Azure OpenAI portal](https://oai.azure.com/) and our [docs of quota limits](https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits) would be the source of truth. 67 | 68 | It will show a table of the locations where you have `gpt-35-turbo` available. 69 | 70 | 71 | ``` 72 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 73 | | model | version | kind | location | sku | quota | remaining_quota | 74 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 75 | | gpt-35-turbo | 0613 | OpenAI | australiaeast | Standard | 300 | 270 | 76 | | gpt-35-turbo | 1106 | OpenAI | australiaeast | Standard | 300 | 270 | 77 | | gpt-35-turbo | 0301 | OpenAI | eastus | Standard | 240 | 0 | 78 | | gpt-35-turbo | 0613 | OpenAI | eastus | Standard | 240 | 0 | 79 | | gpt-35-turbo | 0613 | OpenAI | eastus2 | Standard | 300 | 300 | 80 | | gpt-35-turbo | 0301 | OpenAI | francecentral | Standard | 240 | 0 | 81 | | gpt-35-turbo | 0613 | OpenAI | francecentral | Standard | 240 | 0 | 82 | | gpt-35-turbo | 1106 | OpenAI | francecentral | Standard | 240 | 0 | 83 | | gpt-35-turbo | 0613 | OpenAI | swedencentral | Standard | 300 | 150 | 84 | | gpt-35-turbo | 1106 | OpenAI | swedencentral | Standard | 300 | 150 | 85 | | gpt-35-turbo | 0301 | OpenAI | uksouth | Standard | 240 | 60 | 86 | | gpt-35-turbo | 0613 | OpenAI | uksouth | Standard | 240 | 60 | 87 | | gpt-35-turbo | 1106 | OpenAI | uksouth | Standard | 240 | 60 | 88 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 89 | ``` 90 | 91 | Pick any location where you have both `1106` and `0301`, or both `1106` and `0613`, with remaining_quota above 60. 92 | 93 | ### Step 1 : Provision the resources 94 | 95 | The provision.py script will help provision the resources you need to run this sample. 96 | 97 | **IMPORTANT**: You **must** specify your desired resources in the file `provision.yaml` - there are notes in that file to help you. 98 | This file also allows you to modify the location and version numbers of the models to match 99 | with your available quota (see previous section). 100 | 101 | The script will: 102 | 103 | - Check whether the resources you specified exist, otherwise it will create them. 104 | - Assign the right RBAC assignent (Cognitive Service OpenAI Contributor) towards the Azure OpenAI resource to leverage AAD (see troubleshooting guide below). 105 | - It will then populate a `.env` file for you that references the provisioned or attached resources, including your keys (if specified). 106 | 107 | ```bash 108 | python provision.py 109 | ``` 110 | 111 | **IMPORTANT**: By default, provisioning relies on Microsoft Entra ID (AAD) authentification instead of keys. The provisioning script will assign to yourself the role "Cognitive Services OpenAI User" to the specified Azure OpenAI Instance. Alternatively, you can set env var `AZURE_OPENAI_API_KEY` with your api key. But we recommend Entra ID instead for a more secure setup. 112 | 113 | Once you complete the process, you can find `.env` file under the `src/` folder. Your `.env` file should look like this: 114 | 115 | ```bash 116 | AZURE_SUBSCRIPTION_ID=... 117 | AZURE_RESOURCE_GROUP=... 118 | AZURE_AI_HUB_NAME=... 119 | AZURE_AI_PROJET_NAME=... 120 | AZURE_OPENAI_ENDPOINT=... 121 | AZURE_OPENAI_CHAT_DEPLOYMENT=... 122 | ``` 123 | 124 | Those environment variables will be required for the following steps to work. 125 | 126 | 127 | ### Step 2. Create an assistant 128 | 129 | For the code to run, you need to create an assistant. This means setting up an assistant in your Azure OpenAI resource. 130 | You will get an assistant id you can inject in the code through an env var to run the assistant. 131 | 132 | ```bash 133 | python create_assistant.py 134 | ``` 135 | 136 | It will write the assistant id into your `.env` file: 137 | 138 | ``` 139 | ****************************************************************** 140 | Successfully created assistant with id: [IDENTIFIER]. 141 | It has been written as an environment variable in ./.env. 142 | 143 | AZURE_OPENAI_ASSISTANT_ID=[IDENTIFIER] 144 | 145 | ****************************************************************** 146 | ``` 147 | 148 | ### Step 3. Run the assistant flow locally 149 | 150 | To run the flow locally, use `pf` cli: 151 | 152 | ```bash 153 | pf flow test --flow ./copilot_sdk_flow/flow.flex.yaml --inputs chat_input="which month has peak sales in 2023" 154 | ``` 155 | 156 | You can add `--ui` to run the local test bed. 157 | 158 | ### Step 4. Run an evaluation locally 159 | 160 | The evaluation script consists in running the completion function on a groundtruth dataset and evaluate the results. 161 | 162 | ```bash 163 | python evaluate.py --evaluation-name assistant-dev --evaluation-data-path ./data/ground_truth_sample.jsonl --metrics similarity 164 | ``` 165 | 166 | This will print out the results of the evaluation, as well as a link to the Azure AI Studio to browse the results online. 167 | 168 | ### Step 5. Deploy the flow in Azure AI Studio 169 | 170 | To deploy the flow in your Azure AI project under a managed endpoint, use: 171 | 172 | ```bash 173 | python deploy.py --endpoint-name [UNIQUE_NAME] 174 | ``` 175 | 176 | ## Troubleshooting 177 | 178 | ### Principal does not have access to API/Operation 179 | 180 | When leveraging Azure OpenAI using Microsoft Entra ID (AAD) authentification, a typical issue when running inferences is getting the exception: 181 | 182 | ```json 183 | {"error": {"code": "PermissionDenied", "message": "Principal does not have access to API/Operation."}} 184 | ``` 185 | 186 | To resolve this, you'll need to assign to yourself the role "Cognitive Services OpenAI User" or "Cognitive Services OpenAI Contributor to the Azure OpenAI Instance: 187 | 188 | 1. Find your `OBJECT_ID`: 189 | 190 | ```bash 191 | az ad signed-in-user show --query id -o tsv 192 | ``` 193 | 194 | 2. Then run the following command to grand permissions (at the level of the resource group): 195 | 196 | ```bash 197 | az role assignment create \ 198 | --role "Cognitive Services OpenAI Contributor" \ 199 | --assignee-object-id "$OBJECT_ID" \ 200 | --assignee-principal-type User \ 201 | --scope /subscriptions/"$AZURE_SUBSCRIPTION_ID"/resourceGroups/"$AZURE_RESOURCE_GROUP"/providers/Microsoft.CognitiveServices/accounts/"$AZURE_OPENAI_NAME" \ 202 | ``` 203 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | @allowed(['australiaeast', 'eastus', 'eastus2', 'francecentral', 'norwayeast', 'swedencentral', 'uksouth']) 11 | param location string 12 | 13 | @description('The Azure resource group where new resources will be deployed') 14 | param resourceGroupName string = '' 15 | @description('The Azure AI Studio Hub resource name. If ommited will be generated') 16 | param aiHubName string = '' 17 | @description('The Azure AI Studio project name. If ommited will be generated') 18 | param aiProjectName string = '' 19 | @description('The application insights resource name. If ommited will be generated') 20 | param appInsightsName string = '' 21 | @description('The Open AI resource name. If ommited will be generated') 22 | param openAiName string = '' 23 | @description('The name of the OpenAI deployment for the assistant') 24 | param openAiChatDeploymentName string = '' 25 | @description('The version of the OpenAI deployment for the assistant') 26 | param openAiChatDeploymentVersion string = '' 27 | @description('The name of the OpenAI deployment for the evaluation') 28 | param openAiEvaluationDeploymentName string = '' 29 | @description('The version of the OpenAI deployment for the evaluation') 30 | param openAiEvaluationDeploymentVersion string = '' 31 | @description('The Azure Container Registry resource name. If ommited will be generated') 32 | param containerRegistryName string = '' 33 | @description('The Azure Key Vault resource name. If ommited will be generated') 34 | param keyVaultName string = '' 35 | @description('The Azure Storage Account resource name. If ommited will be generated') 36 | param storageAccountName string = '' 37 | @description('The log analytics workspace name. If ommited will be generated') 38 | param logAnalyticsWorkspaceName string = '' 39 | @description('The name of the machine learning online endpoint. If ommited will be generated') 40 | param endpointName string = '' 41 | @description('Id of the user or app to assign application roles') 42 | param principalId string = '' 43 | @description('Type of the principal. Valid values: User,ServicePrincipal') 44 | param principalType string = 'User' 45 | @description('The name of the azd service to use for the machine learning endpoint') 46 | param endpointServiceName string = 'chat' 47 | 48 | param useContainerRegistry bool = true 49 | param useAppInsights bool = true 50 | 51 | var abbrs = loadJsonContent('./abbreviations.json') 52 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 53 | var tags = { 'azd-env-name': environmentName } 54 | 55 | // Organize resources in a resource group 56 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 57 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 58 | location: location 59 | tags: tags 60 | } 61 | 62 | module ai 'core/host/ai-environment.bicep' = { 63 | name: 'ai' 64 | scope: rg 65 | params: { 66 | location: location 67 | tags: tags 68 | hubName: !empty(aiHubName) ? aiHubName : 'ai-hub-${resourceToken}' 69 | projectName: !empty(aiProjectName) ? aiProjectName : 'ai-project-${resourceToken}' 70 | keyVaultName: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 71 | storageAccountName: !empty(storageAccountName) 72 | ? storageAccountName 73 | : '${abbrs.storageStorageAccounts}${resourceToken}' 74 | aiServicesName: !empty(openAiName) ? openAiName : 'aoai-${resourceToken}' 75 | openAiModelDeployments: [ 76 | { 77 | name: openAiChatDeploymentName 78 | model: { 79 | format: 'OpenAI' 80 | name: 'gpt-35-turbo' 81 | version: openAiChatDeploymentVersion // use this version for Assistant API to work 82 | } 83 | sku: { 84 | name: 'Standard' 85 | capacity: 30 86 | } 87 | } 88 | { 89 | name: openAiEvaluationDeploymentName 90 | model: { 91 | format: 'OpenAI' 92 | name: 'gpt-35-turbo' 93 | version: openAiEvaluationDeploymentVersion // use this version for the evaluation API 94 | } 95 | sku: { 96 | name: 'Standard' 97 | capacity: 30 98 | } 99 | } 100 | ] 101 | logAnalyticsName: !useAppInsights 102 | ? '' 103 | : !empty(logAnalyticsWorkspaceName) 104 | ? logAnalyticsWorkspaceName 105 | : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 106 | appInsightsName: !useAppInsights 107 | ? '' 108 | : !empty(appInsightsName) ? appInsightsName : '${abbrs.insightsComponents}${resourceToken}' 109 | containerRegistryName: !useContainerRegistry 110 | ? '' 111 | : !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}' 112 | } 113 | } 114 | 115 | module machineLearningEndpoint './core/host/ml-online-endpoint.bicep' = { 116 | name: 'endpoint' 117 | scope: rg 118 | params: { 119 | name: !empty(endpointName) ? endpointName : 'mloe-${resourceToken}' 120 | location: location 121 | tags: tags 122 | serviceName: endpointServiceName 123 | aiHubName: ai.outputs.hubName 124 | aiProjectName: ai.outputs.projectName 125 | keyVaultName: ai.outputs.keyVaultName 126 | } 127 | } 128 | 129 | module keyVaultAccess 'core/security/keyvault-access.bicep' = { 130 | name: 'keyvault-access' 131 | scope: rg 132 | params: { 133 | keyVaultName: ai.outputs.keyVaultName 134 | principalId: ai.outputs.projectPrincipalId 135 | } 136 | } 137 | 138 | module userAcrRolePush 'core/security/role.bicep' = { 139 | name: 'user-acr-role-push' 140 | scope: rg 141 | params: { 142 | principalId: principalId 143 | roleDefinitionId: '8311e382-0749-4cb8-b61a-304f252e45ec' 144 | principalType: principalType 145 | } 146 | } 147 | 148 | module userAcrRolePull 'core/security/role.bicep' = { 149 | name: 'user-acr-role-pull' 150 | scope: rg 151 | params: { 152 | principalId: principalId 153 | roleDefinitionId: '7f951dda-4ed3-4680-a7ca-43fe172d538d' 154 | principalType: principalType 155 | } 156 | } 157 | 158 | module userRoleDataScientist 'core/security/role.bicep' = { 159 | name: 'user-role-data-scientist' 160 | scope: rg 161 | params: { 162 | principalId: principalId 163 | roleDefinitionId: 'f6c7c914-8db3-469d-8ca1-694a8f32e121' 164 | principalType: principalType 165 | } 166 | } 167 | 168 | module userRoleSecretsReader 'core/security/role.bicep' = { 169 | name: 'user-role-secrets-reader' 170 | scope: rg 171 | params: { 172 | principalId: principalId 173 | roleDefinitionId: 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5' 174 | principalType: principalType 175 | } 176 | } 177 | 178 | module userCognitiveServicesOpenAIContributor 'core/security/role.bicep' = { 179 | name: 'user-cognitive-services-openAI-contributor' 180 | scope: rg 181 | params: { 182 | principalId: principalId 183 | roleDefinitionId: 'a001fd3d-188f-4b5d-821b-7da978bf7442' 184 | principalType: principalType 185 | } 186 | } 187 | 188 | module endpointCognitiveServicesOpenAIContributor 'core/security/role.bicep' = { 189 | name: 'endpoint-cognitive-services-openAI-contributor' 190 | scope: rg 191 | params: { 192 | principalId: machineLearningEndpoint.outputs.principalId 193 | roleDefinitionId: 'a001fd3d-188f-4b5d-821b-7da978bf7442' 194 | principalType: 'ServicePrincipal' 195 | } 196 | } 197 | 198 | module mlServiceRoleDataScientist 'core/security/role.bicep' = { 199 | name: 'ml-service-role-data-scientist' 200 | scope: rg 201 | params: { 202 | principalId: ai.outputs.projectPrincipalId 203 | roleDefinitionId: 'f6c7c914-8db3-469d-8ca1-694a8f32e121' 204 | principalType: 'ServicePrincipal' 205 | } 206 | } 207 | 208 | module mlServiceRoleSecretsReader 'core/security/role.bicep' = { 209 | name: 'ml-service-role-secrets-reader' 210 | scope: rg 211 | params: { 212 | principalId: ai.outputs.projectPrincipalId 213 | roleDefinitionId: 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5' 214 | principalType: 'ServicePrincipal' 215 | } 216 | } 217 | 218 | // output the names of the resources 219 | output AZURE_TENANT_ID string = tenant().tenantId 220 | output AZURE_RESOURCE_GROUP string = rg.name 221 | 222 | output AZUREAI_HUB_NAME string = ai.outputs.hubName 223 | output AZUREAI_PROJECT_NAME string = ai.outputs.projectName 224 | output AZUREAI_ENDPOINT_NAME string = machineLearningEndpoint.outputs.name 225 | 226 | output AZURE_OPENAI_NAME string = ai.outputs.aiServicesName 227 | output AZURE_OPENAI_ENDPOINT string = ai.outputs.openAiEndpoint 228 | output AZURE_OPENAI_CHAT_DEPLOYMENT string = openAiChatDeploymentName 229 | output AZURE_OPENAI_EVALUATION_DEPLOYMENT string = openAiEvaluationDeploymentName 230 | 231 | output AZURE_CONTAINER_REGISTRY_NAME string = ai.outputs.containerRegistryName 232 | output AZURE_CONTAINER_REGISTRY_ENDPOINT string = ai.outputs.containerRegistryEndpoint 233 | 234 | output AZURE_KEYVAULT_NAME string = ai.outputs.keyVaultName 235 | output AZURE_KEYVAULT_ENDPOINT string = ai.outputs.keyVaultEndpoint 236 | 237 | output AZURE_STORAGE_ACCOUNT_NAME string = ai.outputs.storageAccountName 238 | output AZURE_STORAGE_ACCOUNT_ENDPOINT string = ai.outputs.storageAccountName 239 | 240 | output AZURE_APP_INSIGHTS_NAME string = ai.outputs.appInsightsName 241 | output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = ai.outputs.logAnalyticsWorkspaceName 242 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - python 6 | - bicep 7 | products: 8 | - azure 9 | - ai-services 10 | - azure-openai 11 | urlFragment: assistant-data-openai-python-promptflow 12 | --- 13 | 14 | 15 | 16 | # Assistant for sales data analytics in python and promptflow 17 | 18 | This repository implements a data analytics chatbot based on the Assistants API. 19 | The chatbot can answer questions in natural language, and interpret them as queries 20 | on an example sales dataset. 21 | 22 | This document focused on instructions for **azd**. To discover how to evaluate and deploy using the Azure AI SDK, please find instructions in [src/README](../src/README.md) instead. 23 | 24 | ## Features 25 | 26 | * An implementation of the Assistants API using functions and code interpreter 27 | * Deployment available via GitHub actions or Azure AI SDK 28 | * An agent performing data analytics to answer questions in natural language 29 | 30 | 31 | ### Architecture Diagram 32 | 33 | ![Architecture Diagram](../images/architecture-diagram-assistant-promptflow.png) 34 | 35 | 36 | ## Getting Started 37 | 38 | ### Prerequisites 39 | 40 | - Install [azd](https://aka.ms/install-azd) 41 | - Windows: `winget install microsoft.azd` 42 | - Linux: `curl -fsSL https://aka.ms/install-azd.sh | bash` 43 | - MacOS: `brew tap azure/azd && brew install azd` 44 | - An Azure subscription - [Create one for free](https://azure.microsoft.com/free/cognitive-services) 45 | - Access granted to Azure OpenAI in the desired Azure subscription 46 | Currently, access to this service is granted only by application. You can apply for access to Azure OpenAI by completing the form at [aka.ms/oai/access](https://aka.ms/oai/access). 47 | - Python 3.10 or 3.11 versions 48 | 49 | Note: This azd template uses `gpt-35-turbo` (1106) or `gpt-4` (1106) for assistants which may not be available in all Azure regions. Check for [up-to-date region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#standard-deployment-model-availability) and select a region during deployment accordingly. 50 | 51 | ### Installation 52 | 53 | 1. First, clone the code sample locally: 54 | 55 | ```bash 56 | git clone https://github.com/Azure-Samples/assistant-data-openai-python-promptflow 57 | cd assistant-data-openai-python-promptflow 58 | ``` 59 | 60 | 2. Next, create a new Python virtual environment where we can safely install the SDK packages: 61 | 62 | * On MacOS and Linux run: 63 | ```bash 64 | python --version 65 | python -m venv .venv 66 | source .venv/bin/activate 67 | ``` 68 | 69 | * On Windows run: 70 | ```ps 71 | python --version 72 | python -m venv .venv 73 | .venv\scripts\activate 74 | ``` 75 | 76 | 3. Now that your environment is activated, install the SDK packages 77 | 78 | ```bash 79 | pip install -r ./src/requirements.txt 80 | ``` 81 | 82 | ## Quickstart 83 | 84 | ## Before your start: check your quota 85 | 86 | To ensure you have quota to provision `gpt-35-turbo` version `1106`, you can either go to [oai.azure.com](https://oai.azure.com/) and check the Quota page in a given region. 87 | 88 | You can also try running our experimental script to check quota in your subscription: 89 | 90 | ```bash 91 | python ./src/check_quota.py --subscription-id [SUBSCRIPTION_ID] 92 | ``` 93 | 94 | > Note: this script is a tentative to help locating quota, but it might provide numbers that are not accurate. The [Azure OpenAI portal](https://oai.azure.com/) and our [docs of quota limits](https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits) would be the source of truth. 95 | 96 | It will show a table of the regions where you have `gpt-35-turbo` available. 97 | 98 | ``` 99 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 100 | | model | version | kind | location | sku | quota | remaining_quota | 101 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 102 | | gpt-35-turbo | 0613 | OpenAI | australiaeast | Standard | 300 | 270 | 103 | | gpt-35-turbo | 1106 | OpenAI | australiaeast | Standard | 300 | 270 | 104 | | gpt-35-turbo | 0301 | OpenAI | eastus | Standard | 240 | 0 | 105 | | gpt-35-turbo | 0613 | OpenAI | eastus | Standard | 240 | 0 | 106 | | gpt-35-turbo | 0613 | OpenAI | eastus2 | Standard | 300 | 300 | 107 | | gpt-35-turbo | 0301 | OpenAI | francecentral | Standard | 240 | 0 | 108 | | gpt-35-turbo | 0613 | OpenAI | francecentral | Standard | 240 | 0 | 109 | | gpt-35-turbo | 1106 | OpenAI | francecentral | Standard | 240 | 0 | 110 | | gpt-35-turbo | 0613 | OpenAI | swedencentral | Standard | 300 | 150 | 111 | | gpt-35-turbo | 1106 | OpenAI | swedencentral | Standard | 300 | 150 | 112 | | gpt-35-turbo | 0301 | OpenAI | uksouth | Standard | 240 | 60 | 113 | | gpt-35-turbo | 0613 | OpenAI | uksouth | Standard | 240 | 60 | 114 | | gpt-35-turbo | 1106 | OpenAI | uksouth | Standard | 240 | 60 | 115 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 116 | ``` 117 | 118 | Pick any region where you have both `1106` and `0301`, or both `1106` and `0613`, with remaining_quota above 60. 119 | 120 | By default, `azd provision` will use `1106` for the chat model, and `0301` for the evaluation model. To modify which version to use for the chat and evaluation models, use the env vars before running `azd provision`. 121 | 122 | In bash: 123 | 124 | ```bash 125 | AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION="1106" 126 | AZURE_OPENAI_EVALUATION_DEPLOYMENT_VERSION="0301" 127 | ``` 128 | 129 | or in powershell: 130 | 131 | ```powershell 132 | $Env:AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION="1106" 133 | $Env:AZURE_OPENAI_EVALUATION_DEPLOYMENT_VERSION="0301" 134 | ``` 135 | 136 | ## Step 1 : Provision the resources 137 | 138 | Use azd to provision all the resources of this template for you: 139 | 140 | ```bash 141 | azd provision 142 | ``` 143 | Once you complete the process, you can find `.env` file under .azure\{env} folder. Your `.env` file should look like this: 144 | 145 | ```bash 146 | AZURE_ENV_NAME=... 147 | AZURE_TENANT_ID=... 148 | AZURE_SUBSCRIPTION_ID=... 149 | AZURE_RESOURCE_GROUP=... 150 | AZURE_LOCATION=... 151 | AZUREAI_HUB_NAME=... 152 | AZUREAI_PROJECT_NAME=... 153 | AZUREAI_ENDPOINT_NAME=... 154 | AZURE_OPENAI_ENDPOINT=... 155 | AZURE_OPENAI_CHAT_DEPLOYMENT="chat-35-turbo" 156 | AZURE_OPENAI_EVALUATION_DEPLOYMENT="evaluation-35-turbo" 157 | ``` 158 | 159 | It will also programmatically create an assistant in your Azure OpenAI instance. So you should expect an environment variable: 160 | 161 | ```bash 162 | AZURE_OPENAI_ASSISTANT_ID=... 163 | ``` 164 | 165 | **Troubleshooting** - If you do not see a `.env` file at the root of the repo in the end of this process, it means the `postprovision` steps have failed. Before moving foward, do the following: 166 | 167 | ```bash 168 | cp ./.azure/$AZURE_ENV_NAME/.env ./.env 169 | python -m pip install -r ./src/requirements.txt 170 | python ./src/create_assistant.py --export-env ./.env 171 | ``` 172 | 173 | ### Step 2. Deploy 174 | 175 | Use azd to create the assistant in your Azure OpenAI instance, package the orchestration code and deploy it in an endpoint. 176 | 177 | ```bash 178 | azd deploy 179 | ``` 180 | 181 | ### Step 3. Run the assistant flow locally 182 | 183 | To run the flow locally, use `pf` cli: 184 | 185 | ```bash 186 | pf flow test --flow ./src/copilot_sdk_flow/flow.flex.yaml --inputs chat_input="which month has peak sales in 2023" 187 | ``` 188 | 189 | You can add `--ui` to run the local test bed. 190 | 191 | ### Step 4. Run an evaluation locally 192 | 193 | The evaluation script consists in running the completion function on a groundtruth dataset and evaluate the results. 194 | 195 | ```bash 196 | python ./src/evaluate.py --evaluation-name assistant-dev --evaluation-data-path ./src/data/ground_truth_sample.jsonl --metrics similarity 197 | ``` 198 | 199 | This will print out the results of the evaluation, as well as a link to the Azure AI Studio to browse the results online. 200 | 201 | ## Clean up 202 | 203 | To clean up all the resources created by this sample: 204 | 205 | 1. Run `azd down` 206 | 2. When asked if you are sure you want to continue, enter `y` 207 | 3. When asked if you want to permanently delete the resources, enter `y` 208 | 209 | The resource group and all the resources will be deleted. 210 | 211 | ## Costs 212 | 213 | You can estimate the cost of this project's architecture with [Azure's pricing calculator](https://azure.microsoft.com/pricing/calculator/) 214 | 215 | - Azure OpenAI - Standard tier, GPT-4, GPT-35-turbo and Ada models. [See Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) 216 | - Azure AI Search - Basic tier, Semantic Ranker enabled [See Pricing](https://azure.microsoft.com/en-us/pricing/details/search/) 217 | 218 | ## Security Guidelines 219 | 220 | Each template has either [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) or Key Vault built in to eliminate the need for developers to manage these credentials. Applications can use managed identities to obtain Microsoft Entra tokens without having to manage any credentials. Additionally, we have added a [GitHub Action tool](https://github.com/microsoft/security-devops-action) that scans the infrastructure-as-code files and generates a report containing any detected issues. To ensure best practices in your repo we recommend anyone creating solutions based on our templates ensure that the [Github secret scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) setting is enabled in your repos. 221 | 222 | To be secure by design, we require templates in any Microsoft template collections to have the [Github secret scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) setting is enabled in your repos.' 223 | 224 | ## Resources 225 | - [Develop Python apps that use Azure AI services](https://learn.microsoft.com/azure/developer/python/azure-ai-for-python-developers) 226 | - Discover more sample apps in the [azd template gallery](https://aka.ms/ai-apps)! 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Assistant for sales data analytics in python and promptflow 2 | 3 | This repository implements a data analytics chatbot based on the Assistants API. 4 | The chatbot can answer questions in natural language, and interpret them as queries 5 | on an example sales dataset. 6 | 7 | This document focused on instructions for **azd**. To discover how to evaluate and deploy using the Azure AI SDK, please find instructions in [src/README](src/README.md) instead. 8 | 9 | ## Features 10 | 11 | * An implementation of the Assistants API using functions and code interpreter 12 | * Deployment available via GitHub actions or Azure AI SDK 13 | * An agent performing data analytics to answer questions in natural language 14 | 15 | 16 | ### Architecture Diagram 17 | 18 | ![Architecture Diagram](images/architecture-diagram-assistant-promptflow.png) 19 | 20 | 21 | ## Getting Started 22 | 23 | ### Prerequisites 24 | 25 | - Install [azd](https://aka.ms/install-azd) 26 | - Windows: `winget install microsoft.azd` 27 | - Linux: `curl -fsSL https://aka.ms/install-azd.sh | bash` 28 | - MacOS: `brew tap azure/azd && brew install azd` 29 | - An Azure subscription with [permission](https://learn.microsoft.com/en-us/azure/role-based-access-control/rbac-and-directory-admin-roles#azure-roles) to create and deploy resources - [Create one for free](https://azure.microsoft.com/free/cognitive-services) 30 | - Access granted to Azure OpenAI in the desired Azure subscription 31 | Currently, access to this service is granted only by application. You can apply for access to Azure OpenAI by completing the form at [aka.ms/oai/access](https://aka.ms/oai/access). 32 | - Python 3.10 or 3.11 versions 33 | - [PowerShell 7 or later](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.4&viewFallbackFrom=powershell-7.3) (Windows only) 34 | 35 | Note: This azd template uses `gpt-35-turbo` (1106) or `gpt-4` (1106) for assistants which may not be available in all Azure regions. Check for [up-to-date region availability](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#standard-deployment-model-availability) and select a location during deployment accordingly. 36 | 37 | ### Installation 38 | 39 | 1. First, clone the code sample locally: 40 | 41 | ```bash 42 | git clone https://github.com/Azure-Samples/assistant-data-openai-python-promptflow 43 | cd assistant-data-openai-python-promptflow 44 | ``` 45 | 46 | 2. Next, create a new Python virtual environment where we can safely install the SDK packages: 47 | 48 | * On MacOS and Linux run: 49 | ```bash 50 | python --version 51 | python -m venv .venv 52 | source .venv/bin/activate 53 | ``` 54 | 55 | * On Windows run: 56 | ```ps 57 | python --version 58 | python -m venv .venv 59 | .venv\scripts\activate 60 | ``` 61 | 62 | 3. Now that your environment is activated, install the SDK packages 63 | 64 | ```bash 65 | pip install -r ./src/requirements.txt 66 | ``` 67 | 68 | ## Quickstart 69 | 70 | ## Before your start: check your quota 71 | 72 | To ensure you have quota to provision `gpt-35-turbo` version `1106`, you can either go to [oai.azure.com](https://oai.azure.com/) and check the Quota page in a given location. 73 | 74 | You can also try running our experimental script to check quota in your subscription: 75 | 76 | ```bash 77 | azd auth login # if you haven't logged in yet 78 | python ./src/check_quota.py --subscription-id [SUBSCRIPTION_ID] 79 | ``` 80 | 81 | > Note: this script is a tentative to help locating quota, but it might provide numbers that are not accurate. The [Azure OpenAI portal](https://oai.azure.com/) and our [docs of quota limits](https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits) would be the source of truth. 82 | 83 | It will show a table of the locations where you have `gpt-35-turbo` available. 84 | 85 | ``` 86 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 87 | | model | version | kind | location | sku | quota | remaining_quota | 88 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 89 | | gpt-35-turbo | 0613 | OpenAI | australiaeast | Standard | 300 | 270 | 90 | | gpt-35-turbo | 1106 | OpenAI | australiaeast | Standard | 300 | 270 | 91 | | gpt-35-turbo | 0301 | OpenAI | eastus | Standard | 240 | 0 | 92 | | gpt-35-turbo | 0613 | OpenAI | eastus | Standard | 240 | 0 | 93 | | gpt-35-turbo | 0613 | OpenAI | eastus2 | Standard | 300 | 300 | 94 | | gpt-35-turbo | 0301 | OpenAI | francecentral | Standard | 240 | 0 | 95 | | gpt-35-turbo | 0613 | OpenAI | francecentral | Standard | 240 | 0 | 96 | | gpt-35-turbo | 1106 | OpenAI | francecentral | Standard | 240 | 0 | 97 | | gpt-35-turbo | 0613 | OpenAI | swedencentral | Standard | 300 | 150 | 98 | | gpt-35-turbo | 1106 | OpenAI | swedencentral | Standard | 300 | 150 | 99 | | gpt-35-turbo | 0301 | OpenAI | uksouth | Standard | 240 | 60 | 100 | | gpt-35-turbo | 0613 | OpenAI | uksouth | Standard | 240 | 60 | 101 | | gpt-35-turbo | 1106 | OpenAI | uksouth | Standard | 240 | 60 | 102 | +--------------+---------+--------+---------------+----------+-------+-----------------+ 103 | ``` 104 | 105 | Pick any location where you have both `1106` and `0301`, or both `1106` and `0613`, with remaining_quota above 60. 106 | 107 | By default, `azd provision` will use `1106` for the chat model, and `0301` for the evaluation model. To modify which version to use for the chat and evaluation models, use the env vars before running `azd provision`. 108 | 109 | In bash: 110 | 111 | ```bash 112 | AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION="1106" 113 | AZURE_OPENAI_EVALUATION_DEPLOYMENT_VERSION="0301" 114 | ``` 115 | 116 | or in powershell: 117 | 118 | ```powershell 119 | $Env:AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION="1106" 120 | $Env:AZURE_OPENAI_EVALUATION_DEPLOYMENT_VERSION="0301" 121 | ``` 122 | 123 | ## Step 1 : Provision the resources 124 | 125 | Use azd to provision all the resources of this template for you: 126 | 127 | ```bash 128 | azd provision 129 | ``` 130 | Once you complete the process, you can find `.env` file under .azure\{env} folder. Your `.env` file should look like this: 131 | 132 | ```bash 133 | AZURE_ENV_NAME=... 134 | AZURE_TENANT_ID=... 135 | AZURE_SUBSCRIPTION_ID=... 136 | AZURE_RESOURCE_GROUP=... 137 | AZURE_LOCATION=... 138 | AZUREAI_HUB_NAME=... 139 | AZUREAI_PROJECT_NAME=... 140 | AZUREAI_ENDPOINT_NAME=... 141 | AZURE_OPENAI_ENDPOINT=... 142 | AZURE_OPENAI_CHAT_DEPLOYMENT="chat-35-turbo" 143 | AZURE_OPENAI_EVALUATION_DEPLOYMENT="evaluation-35-turbo" 144 | ``` 145 | 146 | It will also programmatically create an assistant in your Azure OpenAI instance. So you should expect an environment variable: 147 | 148 | ```bash 149 | AZURE_OPENAI_ASSISTANT_ID=... 150 | ``` 151 | 152 | **Troubleshooting** - If you do not see a `.env` file at the root of the repo in the end of this process, it means the `postprovision` steps have failed. Before moving foward, do the following: 153 | 154 | ```bash 155 | cp ./.azure/$AZURE_ENV_NAME/.env ./.env 156 | python -m pip install -r ./src/requirements.txt 157 | python ./src/create_assistant.py --export-env ./.env 158 | ``` 159 | 160 | ### Step 2. Deploy 161 | 162 | Use azd to create the assistant in your Azure OpenAI instance, package the orchestration code and deploy it in an endpoint. 163 | 164 | ```bash 165 | azd deploy 166 | ``` 167 | 168 | ### Step 3. Run the assistant flow locally 169 | 170 | To run the flow locally, use `pf` cli: 171 | 172 | ```bash 173 | pf flow test --flow ./src/copilot_sdk_flow/flow.flex.yaml --inputs chat_input="which month has peak sales in 2023" 174 | ``` 175 | 176 | You can add `--ui` to run the local test bed. 177 | 178 | ### Step 4. Run an evaluation locally 179 | 180 | The evaluation script consists in running the completion function on a groundtruth dataset and evaluate the results. 181 | 182 | ```bash 183 | python ./src/evaluate.py --evaluation-name assistant-dev --evaluation-data-path ./src/data/ground_truth_sample.jsonl --metrics similarity 184 | ``` 185 | 186 | This will print out the results of the evaluation, as well as a link to the Azure AI Studio to browse the results online. 187 | 188 | ## Clean up 189 | 190 | To clean up all the resources created by this sample: 191 | 192 | 1. Run `azd down` 193 | 2. When asked if you are sure you want to continue, enter `y` 194 | 3. When asked if you want to permanently delete the resources, enter `y` 195 | 196 | The resource group and all the resources will be deleted. 197 | 198 | ## Guidance 199 | 200 | ### Costs 201 | 202 | You can estimate the cost of this project's architecture with [Azure's pricing calculator](https://azure.microsoft.com/pricing/calculator/) 203 | 204 | - Azure OpenAI - Standard tier, GPT-4, GPT-35-turbo and Ada models. [See Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) 205 | - Azure AI Search - Basic tier, Semantic Ranker enabled [See Pricing](https://azure.microsoft.com/en-us/pricing/details/search/) 206 | 207 | ### Security Guidelines 208 | 209 | Each template has either [Managed Identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) or Key Vault built in to eliminate the need for developers to manage these credentials. Applications can use managed identities to obtain Microsoft Entra tokens without having to manage any credentials. Additionally, we have added a [GitHub Action tool](https://github.com/microsoft/security-devops-action) that scans the infrastructure-as-code files and generates a report containing any detected issues. To ensure best practices in your repo we recommend anyone creating solutions based on our templates ensure that the [Github secret scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) setting is enabled in your repos. 210 | 211 | To be secure by design, we require templates in any Microsoft template collections to have the [Github secret scanning](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) setting is enabled in your repos.' 212 | 213 | ## Resources 214 | - [Develop Python apps that use Azure AI services](https://learn.microsoft.com/azure/developer/python/azure-ai-for-python-developers) 215 | - Discover more sample apps in the [azd template gallery](https://aka.ms/ai-apps)! 216 | -------------------------------------------------------------------------------- /src/deploy.py: -------------------------------------------------------------------------------- 1 | """Deploy a flow to Azure AI.""" 2 | 3 | import os 4 | from typing import List 5 | 6 | import argparse 7 | import logging 8 | import os 9 | 10 | from azure.ai.ml import MLClient 11 | from azure.identity import DefaultAzureCredential 12 | from azure.ai.ml.entities import ( 13 | ManagedOnlineEndpoint, 14 | ManagedOnlineDeployment, 15 | Model, 16 | Environment, 17 | BuildContext, 18 | OnlineRequestSettings, 19 | ) 20 | 21 | from dotenv import load_dotenv 22 | 23 | load_dotenv() 24 | 25 | 26 | def get_arg_parser(parser: argparse.ArgumentParser = None) -> argparse.ArgumentParser: 27 | """Get the argument parser for the script.""" 28 | if parser is None: 29 | parser = argparse.ArgumentParser(__doc__) 30 | 31 | parser.add_argument( 32 | "--flow-path", 33 | help="Path to the flow", 34 | type=str, 35 | default=os.path.join(os.path.dirname(__file__), "copilot_sdk_flow"), 36 | ) 37 | parser.add_argument( 38 | "--aoai-connection-name", 39 | help="Azure OpenAI connection name to use for the deployment", 40 | type=str, 41 | default="aoai-connection", 42 | ) 43 | parser.add_argument( 44 | "--deployment-name", 45 | help="deployment name to use when deploying or invoking the flow", 46 | type=str, 47 | default="assistants-flow-deployment", 48 | ) 49 | parser.add_argument( 50 | "--endpoint-name", 51 | help="endpoint name to use when deploying or invoking the flow", 52 | type=str, 53 | default=os.getenv("AZUREAI_ENDPOINT_NAME"), 54 | ) 55 | parser.add_argument( 56 | "--instance-type", 57 | help="instance type to use for the deployment", 58 | type=str, 59 | default="Standard_E16s_v3", 60 | ) 61 | parser.add_argument( 62 | "--instance-count", 63 | help="instance count to use for the deployment", 64 | type=int, 65 | default=1, 66 | ) 67 | parser.add_argument( 68 | "--verbose", 69 | help="enable verbose logging", 70 | action="store_true", 71 | ) 72 | 73 | return parser 74 | 75 | 76 | def get_ml_client() -> MLClient: 77 | """Get the ML client for the script.""" 78 | if "AZURE_SUBSCRIPTION_ID" not in os.environ: 79 | # if not using environment variables, you can use the config.json file 80 | logging.info("Using config.json file for authentication") 81 | ml_client = MLClient.from_config() 82 | else: 83 | # if using environment variables 84 | logging.info( 85 | f"Connecting to Azure AI project {os.environ.get('AZUREAI_PROJECT_NAME')}..." 86 | ) 87 | ml_client = MLClient( 88 | credential=DefaultAzureCredential(), 89 | subscription_id=os.environ.get("AZURE_SUBSCRIPTION_ID"), 90 | resource_group_name=os.environ.get("AZURE_RESOURCE_GROUP"), 91 | workspace_name=os.environ.get("AZUREAI_PROJECT_NAME"), 92 | ) 93 | 94 | # test the client before going further 95 | ml_client.workspaces.get(os.environ.get("AZUREAI_PROJECT_NAME")) 96 | return ml_client 97 | 98 | 99 | def main(cli_args: List[str] = None): 100 | """Main entry point for the script.""" 101 | parser = get_arg_parser() 102 | args = parser.parse_args() 103 | 104 | if args.verbose: 105 | logging.basicConfig(level=logging.DEBUG) 106 | else: 107 | logging.basicConfig(level=logging.INFO) 108 | 109 | # turn off some dependencies logs 110 | logging.getLogger("azure.core").setLevel(logging.WARNING) 111 | logging.getLogger("azure.identity").setLevel(logging.WARNING) 112 | 113 | client = get_ml_client() 114 | 115 | # specify the endpoint creation settings 116 | assert ( 117 | args.endpoint_name is not None 118 | ), "Please provide an endpoint name using --endpoint-name, or set env var AZUREAI_ENDPOINT_NAME." 119 | try: 120 | endpoint = client.online_endpoints.get(args.endpoint_name) 121 | logging.info(f"Found existing endpoint: {args.endpoint_name}") 122 | except: 123 | logging.info(f"Endpoint {args.endpoint_name} not found, creating a new one...") 124 | endpoint = ManagedOnlineEndpoint( 125 | name=args.endpoint_name, 126 | properties={ 127 | "enforce_access_to_default_secret_stores": "enabled" # if you want secret injection support 128 | }, 129 | ) 130 | 131 | # handle credentials that the endpoint will need to access the Azure OpenAI resource 132 | try: 133 | connection = client.connections.get(args.aoai_connection_name) 134 | except: 135 | raise Exception( 136 | f"Connection {args.aoai_connection_name} not found in AI project {os.environ.get('AZUREAI_PROJECT_NAME')}. Please create a connection with the name {args.aoai_connection_name} in your Azure AI workspace or use --aoai-connection-name with the right connection name." 137 | ) 138 | 139 | connection_string = f"azureml://connections/{args.aoai_connection_name}" 140 | logging.info(f"Using connection: {connection_string}") 141 | 142 | # our deployment will need environment variables to store secrets 143 | # but those should be injected from the service side, not from our local code 144 | deployment_env_vars = {} 145 | if connection.credentials.type == "api_key": 146 | logging.info( 147 | f"Using API key for the deployment to authentificate for Azure OpenAI." 148 | ) 149 | # ${{azureml://connection/aoai-connection/target}} will inject the target value from the connection during deployment time 150 | deployment_env_vars["AZURE_OPENAI_ENDPOINT"] = ( 151 | "${{" + connection_string + "/target}}" 152 | ) 153 | # ${{azureml://connection/aoai-connection/credentials/key}} will inject the key value from the connection during deployment time 154 | deployment_env_vars["AZURE_OPENAI_API_KEY"] = ( 155 | "${{" + connection_string + "/credentials/key}}" 156 | ) 157 | elif connection.credentials.type == "aad": 158 | # ${{azureml://connection/aoai-connection/target}} will inject the target value from the connection during deployment time 159 | deployment_env_vars["AZURE_OPENAI_ENDPOINT"] = ( 160 | "${{" + connection_string + "/target}}" 161 | ) 162 | logging.warning( 163 | "Using Azure AD authentification for Azure OpenAI. Please ensure that the Azure OpenAI resource is configured to accept Azure AD authentification." 164 | ) 165 | else: 166 | raise ValueError( 167 | f"Connection {args.aoai_connection_name} credentials type {connection.credentials.type} not supported in this script (yet)." 168 | ) 169 | 170 | # other non-secret variables 171 | deployment_env_vars["AZURE_OPENAI_ASSISTANT_ID"] = os.getenv( 172 | "AZURE_OPENAI_ASSISTANT_ID" 173 | ) 174 | deployment_env_vars["AZURE_OPENAI_API_VERSION"] = os.getenv( 175 | "AZURE_OPENAI_API_VERSION", "2024-02-15-preview" 176 | ) 177 | deployment_env_vars["AZURE_OPENAI_CHAT_DEPLOYMENT"] = os.getenv( 178 | "AZURE_OPENAI_CHAT_DEPLOYMENT" 179 | ) 180 | 181 | model = Model( 182 | name="copilot_flow_model", 183 | path=args.flow_path, # path to promptflow folder 184 | properties=[ # this enables the chat interface in the endpoint test tab 185 | ["azureml.promptflow.source_flow_id", "basic-chat"], 186 | ["azureml.promptflow.mode", "chat"], 187 | ["azureml.promptflow.chat_input", "chat_input"], 188 | ["azureml.promptflow.chat_output", "reply"], 189 | ], 190 | ) 191 | logging.info("Packaged flow as a model for deployment") 192 | 193 | # create an environment for the deployment 194 | environment = Environment( # when pf is a model type, the environment section will not be required at all 195 | build=BuildContext( 196 | path=args.flow_path, 197 | ), 198 | inference_config={ 199 | "liveness_route": { 200 | "path": "/health", 201 | "port": 8080, 202 | }, 203 | "readiness_route": { 204 | "path": "/health", 205 | "port": 8080, 206 | }, 207 | "scoring_route": { 208 | "path": "/score", 209 | "port": 8080, 210 | }, 211 | }, 212 | ) 213 | 214 | # NOTE: this is a required fix 215 | deployment_env_vars["PRT_CONFIG_OVERRIDE"] = ( 216 | f"deployment.subscription_id={client.subscription_id},deployment.resource_group={client.resource_group_name},deployment.workspace_name={client.workspace_name},deployment.endpoint_name={args.endpoint_name},deployment.deployment_name={args.deployment_name}" 217 | ) 218 | deployment_env_vars["PROMPTFLOW_RUN_MODE"] = "serving" 219 | 220 | logging.info(f"Deployment will have the following environment variables:") 221 | for key in deployment_env_vars: 222 | logging.info(f"{key}=***") 223 | 224 | # specify the deployment creation settings 225 | deployment = ManagedOnlineDeployment( # defaults to key auth_mode 226 | name=args.deployment_name, 227 | endpoint_name=args.endpoint_name, 228 | model=model, # path to promptflow folder 229 | environment=environment, 230 | instance_type=args.instance_type, # can point to documentation for this: https://learn.microsoft.com/en-us/azure/machine-learning/reference-managed-online-endpoints-vm-sku-list?view=azureml-api-2 231 | instance_count=args.instance_count, 232 | environment_variables=deployment_env_vars, 233 | request_settings=OnlineRequestSettings( 234 | request_timeout_ms=60000 235 | ), # using 1 min timeout to answer (long assistant process time) 236 | ) 237 | 238 | # 1. create endpoint 239 | print(f"Creating/updating endpoint {args.endpoint_name}...") 240 | created_endpoint = client.begin_create_or_update( 241 | endpoint 242 | ).result() # result() means we wait on this to complete - currently endpoint doesnt have any status, but then deployment does have status 243 | 244 | # 2. create deployment 245 | print(f"Creating/updating deployment {args.deployment_name}...") 246 | created_deployment = client.begin_create_or_update(deployment).result() 247 | 248 | # 3. update endpoint traffic for the deployment 249 | endpoint.traffic = {args.deployment_name: 100} # set to 100% of traffic 250 | client.begin_create_or_update(endpoint).result() 251 | 252 | print(f"Your online endpoint name is: {created_endpoint.name}") 253 | print(f"Your deployment name is: {created_deployment.name}") 254 | 255 | test_url = f"https://ai.azure.com/projectdeployments/realtime/{args.endpoint_name}/{args.deployment_name}/detail?wsid=/subscriptions/{client.subscription_id}/resourceGroups/{client.resource_group_name}/providers/Microsoft.MachineLearningServices/workspaces/{client.workspace_name}" 256 | print(f"Test your deployment in Azure AI Studio at: {test_url}") 257 | 258 | 259 | if __name__ == "__main__": 260 | main() 261 | -------------------------------------------------------------------------------- /src/provision.py: -------------------------------------------------------------------------------- 1 | """Provision Azure AI resources for you.""" 2 | 3 | import logging 4 | import os 5 | import sys 6 | import re 7 | import argparse 8 | from pydantic import BaseModel, field_validator 9 | from omegaconf import OmegaConf 10 | from collections import OrderedDict 11 | import requests 12 | import traceback 13 | import uuid 14 | 15 | # from azure.ai.ml.entities import Project, Hub 16 | from azure.ai.ml import MLClient 17 | from azure.identity import DefaultAzureCredential, get_bearer_token_provider 18 | from azure.mgmt.search import SearchManagementClient 19 | from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient 20 | from azure.mgmt.resource import ResourceManagementClient 21 | 22 | # from azure.ai.ml.entities import Project,Hub 23 | from azure.ai.ml.entities import ( 24 | Hub, # TODO: need to replace with Hub 25 | Project, # TODO: need to replace with Project 26 | AzureOpenAIConnection, 27 | AzureAISearchConnection, 28 | ) 29 | 30 | from typing import List, Callable, Dict, Any, Optional, Union 31 | 32 | 33 | def get_arg_parser(parser: argparse.ArgumentParser = None) -> argparse.ArgumentParser: 34 | if parser is None: 35 | parser = argparse.ArgumentParser(__doc__) 36 | 37 | parser.add_argument( 38 | "--verbose", 39 | help="Enable verbose logging", 40 | action="store_true", 41 | ) 42 | parser.add_argument( 43 | "--yaml-spec", 44 | help="point to a provision.yaml spec file", 45 | type=str, 46 | default=os.path.join(os.path.dirname(__file__), "provision.yaml"), 47 | ) 48 | parser.add_argument( 49 | "--show-only", 50 | help="Don't provision but only show provisioning plan", 51 | action="store_true", 52 | ) 53 | parser.add_argument( 54 | "--export-env", 55 | help="Export environment variables into a file", 56 | default=os.path.join(os.path.dirname(__file__), ".env"), 57 | ) 58 | 59 | return parser 60 | 61 | 62 | ################################# 63 | # Resource provisioning classes # 64 | ################################# 65 | 66 | 67 | class AzureScopedResource(BaseModel): 68 | subscription_id: str 69 | resource_group_name: str 70 | location: str 71 | 72 | def scope(self) -> str: 73 | return f"/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group_name}" 74 | 75 | @field_validator("subscription_id", "resource_group_name", "location") 76 | @classmethod 77 | def validate_references(cls, v: str) -> str: 78 | if "<" in v or ">" in v: 79 | raise ValueError( 80 | f"Invalid value '{v}', did you forget to provide your own?" 81 | ) 82 | return v 83 | 84 | 85 | class RBACRoleAssignment(BaseModel): 86 | resource: AzureScopedResource 87 | role_definition_id: str 88 | object_id: str 89 | 90 | def scope(self) -> str: 91 | return ( 92 | self.resource.scope() 93 | + f"/roleAssignments/{self.role_definition_id}//{self.object_id}" 94 | ) 95 | 96 | @classmethod 97 | def get_self_client_id(cls) -> str: 98 | # run shell command 99 | # az ad signed-in-user show --query id -o tsv 100 | # to get the principal ID of the current user 101 | shell_command = "az ad signed-in-user show --query id -o tsv" 102 | # return os.popen(shell_command).read().strip() 103 | # use subprocess instead of os.popen 104 | try: 105 | import subprocess 106 | 107 | return ( 108 | subprocess.run(shell_command, shell=True, capture_output=True) 109 | .stdout.decode() 110 | .strip() 111 | ) 112 | except: 113 | raise Exception( 114 | f"Failed to get the object ID of the current user, please make sure you have logged in with Azure CLI.: {traceback.format_exc()}" 115 | ) 116 | 117 | def get_bearer_token(self) -> str: 118 | credential = DefaultAzureCredential() 119 | bearer_token_provider = get_bearer_token_provider( 120 | credential, "https://management.azure.com/.default" 121 | ) 122 | return bearer_token_provider() 123 | 124 | def exists(self) -> bool: 125 | try: 126 | # GET https://management.azure.com/{scope}/providers/Microsoft.Authorization/roleAssignments/{roleAssignmentName}?api-version=2022-04-01 127 | headers = { 128 | "Authorization": f"Bearer {self.get_bearer_token()}", 129 | # "Content-Type": "application/json", 130 | } 131 | response = requests.get( 132 | url=f"https://management.azure.com/{self.resource.scope()}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01", 133 | headers=headers, 134 | ) 135 | if response.status_code != 200: 136 | raise Exception(f"Failed to get role assignments: {response.text}") 137 | 138 | # returns the list of all assignments for the results, that we have to parse 139 | for role_assignment in response.json()["value"]: 140 | logging.debug(f"checking role_assignment: {role_assignment}") 141 | if ( 142 | role_assignment["properties"]["roleDefinitionId"].endswith( 143 | self.role_definition_id 144 | ) 145 | and role_assignment["properties"]["principalId"] == self.object_id 146 | ): 147 | logging.debug(f"Role assignment exists: {role_assignment}") 148 | return True 149 | return False 150 | except: 151 | logging.debug( 152 | f"Failed to check if role assignment exists: {traceback.format_exc()}" 153 | ) 154 | return False 155 | 156 | def create(self): 157 | logging.info( 158 | f"Assigning role {self.role_definition_id} to object_id {self.object_id} on scope {self.resource.scope()}..." 159 | ) 160 | headers = { 161 | "Authorization": f"Bearer {self.get_bearer_token()}", 162 | # "Content-Type": "application/json", 163 | } 164 | response = requests.put( 165 | url=f"https://management.azure.com/{self.resource.scope()}/providers/Microsoft.Authorization/roleAssignments/{str(uuid.uuid4())}?api-version=2022-04-01", 166 | headers=headers, 167 | json={ 168 | "properties": { 169 | "roleDefinitionId": f"{self.resource.scope()}/providers/Microsoft.Authorization/roleDefinitions/{self.role_definition_id}", 170 | "principalId": self.object_id, 171 | } 172 | }, 173 | ) 174 | if response.status_code == 409 and "RoleAssignmentExists" in response.text: 175 | logging.info("Role assignment already exists.") 176 | return 177 | if response.status_code != 200: 178 | raise Exception( 179 | f"Status_code={response.status_code}, failed to assign role: {response.text}" 180 | ) 181 | 182 | 183 | class ResourceGroup(AzureScopedResource): 184 | def exists(self) -> bool: 185 | """Check if the resource group exists.""" 186 | # use ResourceManagementClient 187 | client = ResourceManagementClient( 188 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 189 | ) 190 | 191 | try: 192 | response = client.resource_groups.get(self.resource_group_name) 193 | return True 194 | except Exception as e: 195 | return False 196 | 197 | def create(self) -> Any: 198 | """Create a resource group.""" 199 | client = ResourceManagementClient( 200 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 201 | ) 202 | response = client.resource_groups.create_or_update( 203 | resource_group_name=self.resource_group_name, 204 | parameters={"location": self.location}, 205 | ) 206 | return response 207 | 208 | 209 | class AzureAIHub(AzureScopedResource): 210 | hub_name: str 211 | 212 | def scope(self): 213 | return f"/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group_name}/providers/Microsoft.MachineLearningServices/workspaces/{self.hub_name}" 214 | 215 | def exists(self) -> bool: 216 | """Check if the resource exists.""" 217 | ml_client = MLClient( 218 | subscription_id=self.subscription_id, 219 | resource_group_name=self.resource_group_name, 220 | credential=DefaultAzureCredential(), 221 | ) 222 | 223 | try: 224 | created_hub = ml_client.workspaces.get(self.hub_name) 225 | logging.debug(f"hub found: {created_hub}") 226 | return True 227 | except Exception as e: 228 | logging.debug(f"hub not found: {e}") 229 | return False 230 | 231 | def create(self) -> Any: 232 | """Create the resource""" 233 | logging.info(f"Creating AI Hub {self.hub_name}...") 234 | ml_client = MLClient( 235 | subscription_id=self.subscription_id, 236 | resource_group_name=self.resource_group_name, 237 | credential=DefaultAzureCredential(), 238 | ) 239 | 240 | hub = Hub( 241 | name=self.hub_name, 242 | location=self.location, 243 | resource_group=self.resource_group_name, 244 | ) 245 | response = ml_client.workspaces.begin_create(hub).result() 246 | return response 247 | 248 | 249 | class AzureAIProject(AzureScopedResource): 250 | hub_name: str 251 | project_name: str 252 | 253 | def scope(self): 254 | return f"/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group_name}/providers/Microsoft.MachineLearningServices/workspaces/{self.hub_name}/projects/{self.project_name}" 255 | 256 | def exists(self) -> bool: 257 | """Check if the resource exists.""" 258 | ml_client = MLClient( 259 | subscription_id=self.subscription_id, 260 | resource_group_name=self.resource_group_name, 261 | credential=DefaultAzureCredential(), 262 | ) 263 | 264 | try: 265 | created_hub = ml_client.workspaces.get(self.hub_name) 266 | created_project = ml_client.workspaces.get(self.project_name) 267 | logging.debug(f"project found: {created_project}") 268 | return True 269 | except Exception as e: 270 | logging.debug(f"project not found: {e}") 271 | return False 272 | 273 | def create(self) -> Any: 274 | """Create the resource""" 275 | logging.info(f"Creating AI Project {self.project_name}...") 276 | ml_client = MLClient( 277 | subscription_id=self.subscription_id, 278 | resource_group_name=self.resource_group_name, 279 | credential=DefaultAzureCredential(), 280 | ) 281 | 282 | hub = ml_client.workspaces.get(self.hub_name) 283 | 284 | project = Project( 285 | name=self.project_name, 286 | hub_id=hub.id, 287 | location=hub.location, 288 | resource_group=hub.resource_group, 289 | ) 290 | response = ml_client.workspaces.begin_create(project).result() 291 | 292 | return response 293 | 294 | 295 | class AzureAISearch(AzureScopedResource): 296 | search_resource_name: str 297 | 298 | def scope(self): 299 | return f"/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group_name}/providers/Microsoft.Search/searchServices/{self.search_resource_name}" 300 | 301 | def exists(self) -> bool: 302 | """Check if the resource exists.""" 303 | client = SearchManagementClient( 304 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 305 | ) 306 | 307 | try: 308 | resource = client.services.get( 309 | resource_group_name=self.resource_group_name, 310 | search_service_name=self.search_resource_name, 311 | ) 312 | logging.debug(f"search found: {resource}") 313 | return True 314 | except Exception as e: 315 | logging.debug(f"search not found: {e}") 316 | return False 317 | 318 | def create(self) -> Any: 319 | """Create the resource""" 320 | logging.info(f"Creating AI Search {self.search_resource_name}...") 321 | client = SearchManagementClient( 322 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 323 | ) 324 | search = client.services.begin_create_or_update( 325 | resource_group_name=self.resource_group_name, 326 | search_service_name=self.search_resource_name, 327 | service={ 328 | "location": self.location, 329 | # "properties": {"hostingMode": "default", "partitionCount": 1, "replicaCount": 3}, 330 | "sku": {"name": "standard"}, 331 | # "tags": {"app-name": "My e-commerce app"}, 332 | }, 333 | ).result() 334 | return search 335 | 336 | 337 | class AzureOpenAIResource(AzureScopedResource): 338 | aoai_resource_name: str 339 | kind: Optional[str] = "OpenAI" 340 | 341 | def scope(self) -> str: 342 | return f"/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group_name}/providers/Microsoft.CognitiveServices/accounts/{self.aoai_resource_name}" 343 | 344 | def exists(self) -> bool: 345 | """Check if the resource exists.""" 346 | client = CognitiveServicesManagementClient( 347 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 348 | ) 349 | 350 | try: 351 | account = client.accounts.get( 352 | resource_group_name=self.resource_group_name, 353 | account_name=self.aoai_resource_name, 354 | ) 355 | logging.debug(f"aoai found: {account}") 356 | return True 357 | except Exception as e: 358 | logging.debug(f"aoai not found: {e}") 359 | return False 360 | 361 | def create(self) -> Any: 362 | """Create the resource""" 363 | logging.info(f"Creating Azure OpenAI {self.aoai_resource_name}...") 364 | client = CognitiveServicesManagementClient( 365 | credential=DefaultAzureCredential(), subscription_id=self.subscription_id 366 | ) 367 | account = client.accounts.begin_create( 368 | resource_group_name=self.resource_group_name, 369 | account_name=self.aoai_resource_name, 370 | account={ 371 | "sku": {"name": "S0"}, 372 | "kind": self.kind, 373 | "location": self.location, 374 | "properties": { 375 | # to hit api directly via endpoint 376 | "custom_sub_domain_name": self.aoai_resource_name 377 | }, 378 | }, 379 | ).result() 380 | return account 381 | 382 | 383 | class AzureOpenAIDeployment(BaseModel): 384 | resource: AzureOpenAIResource 385 | name: str 386 | model: str 387 | version: Optional[str] = None 388 | capacity: Optional[int] = 10 389 | 390 | def scope(self): 391 | return self.resource.scope() + f"/deployments/{self.name}" 392 | 393 | def exists(self) -> bool: 394 | """Check if the deployment exists.""" 395 | client = CognitiveServicesManagementClient( 396 | credential=DefaultAzureCredential(), 397 | subscription_id=self.resource.subscription_id, 398 | ) 399 | 400 | try: 401 | deployment = client.deployments.get( 402 | resource_group_name=self.resource.resource_group_name, 403 | account_name=self.resource.aoai_resource_name, 404 | deployment_name=self.name, 405 | ) 406 | logging.debug(f"aoai deployment found: {deployment}") 407 | return True 408 | except Exception as e: 409 | logging.debug(f"aoai deployment not found: {e}") 410 | return False 411 | 412 | def create(self) -> Any: 413 | """Create the deployment""" 414 | logging.info(f"Creating Azure OpenAI deployment {self.name}...") 415 | client = CognitiveServicesManagementClient( 416 | credential=DefaultAzureCredential(), 417 | subscription_id=self.resource.subscription_id, 418 | ) 419 | deployment = client.deployments.begin_create_or_update( 420 | resource_group_name=self.resource.resource_group_name, 421 | deployment_name=self.name, 422 | account_name=self.resource.aoai_resource_name, 423 | deployment={ 424 | "properties": { 425 | "model": { 426 | "format": "OpenAI", 427 | "name": self.model, 428 | "version": self.version, 429 | } 430 | }, 431 | "sku": {"capacity": self.capacity, "name": "Standard"}, 432 | }, 433 | ).result() 434 | return deployment 435 | 436 | 437 | class ConnectionSpec(BaseModel): 438 | hub: AzureAIHub 439 | resource: Union[AzureAISearch, AzureOpenAIResource] 440 | name: str 441 | auth: str 442 | 443 | def scope(self): 444 | return self.hub.scope() + f"/connections/{self.name}" 445 | 446 | def exists(self) -> bool: 447 | """Check if the connection in AI Hub exists.""" 448 | try: 449 | ml_client = MLClient( 450 | subscription_id=self.hub.subscription_id, 451 | resource_group_name=self.hub.resource_group_name, 452 | workspace_name=self.hub.hub_name, 453 | credential=DefaultAzureCredential(), 454 | ) 455 | created_connection = ml_client.connections.get(self.name) 456 | logging.debug(f"connection found: {created_connection}") 457 | return True 458 | except Exception as e: 459 | logging.debug(f"connection not found: {e}") 460 | return False 461 | 462 | def create(self) -> Any: 463 | """Create the connection in AI Hub.""" 464 | ml_client = MLClient( 465 | subscription_id=self.hub.subscription_id, 466 | resource_group_name=self.hub.resource_group_name, 467 | workspace_name=self.hub.hub_name, 468 | credential=DefaultAzureCredential(), 469 | ) 470 | if isinstance(self.resource, AzureAISearch): 471 | # get search client 472 | rsc_client = SearchManagementClient( 473 | credential=DefaultAzureCredential(), 474 | subscription_id=self.resource.subscription_id, 475 | ) 476 | 477 | # get resource endpoint and keys 478 | resource = rsc_client.services.get( 479 | resource_group_name=self.resource.resource_group_name, 480 | search_service_name=self.resource.search_resource_name, 481 | ) 482 | 483 | # TODO: need better 484 | resource_target = ( 485 | f"https://{self.resource.search_resource_name}.search.windows.net" 486 | ) 487 | 488 | # get keys 489 | rsc_keys = rsc_client.admin_keys.get( 490 | resource_group_name=self.resource.resource_group_name, 491 | search_service_name=self.resource.search_resource_name, 492 | ) 493 | 494 | # specify connection 495 | connection_config = AzureAISearchConnection( 496 | endpoint=resource_target, 497 | api_key=rsc_keys.primary_key, # using key-based auth 498 | name=self.name, 499 | ) 500 | 501 | # create connection 502 | return ml_client.connections.create_or_update(connection=connection_config) 503 | if isinstance(self.resource, AzureOpenAIResource): 504 | rsc_client = CognitiveServicesManagementClient( 505 | credential=DefaultAzureCredential(), 506 | subscription_id=self.resource.subscription_id, 507 | ) 508 | 509 | # get endpoint 510 | resource_target = rsc_client.accounts.get( 511 | resource_group_name=self.resource.resource_group_name, 512 | account_name=self.resource.aoai_resource_name, 513 | ).properties.endpoints["OpenAI Language Model Instance API"] 514 | 515 | # get keys 516 | rsc_keys = rsc_client.accounts.list_keys( 517 | resource_group_name=self.resource.resource_group_name, 518 | account_name=self.resource.aoai_resource_name, 519 | ) 520 | 521 | # specify connection 522 | connection_config = AzureOpenAIConnection( 523 | azure_endpoint=resource_target, 524 | api_key=rsc_keys.key1, # using key-based auth 525 | name=self.name, 526 | ) 527 | 528 | # create connection 529 | return ml_client.connections.create_or_update( 530 | workspace_connection=connection_config 531 | ) 532 | else: 533 | raise ValueError(f"Unknown connection type: {self.resource.type}") 534 | 535 | 536 | ##################### 537 | # Provisioning Plan # 538 | ##################### 539 | 540 | 541 | class ProvisioningPlan: 542 | def __init__(self): 543 | self.steps = OrderedDict() 544 | 545 | def _add_step(self, key, resource): 546 | if key in self.steps: 547 | # disregard duplicates 548 | logging.debug(f"discarding duplicate key {key}") 549 | else: 550 | logging.debug(f"adding key {key} to provisioning plan") 551 | self.steps[key] = resource 552 | 553 | def add_resource(self, resource: Any): 554 | key = resource.scope() 555 | self._add_step(key, resource) 556 | 557 | def remove_existing(self): 558 | """Remove existing resources from the plan.""" 559 | remove_keys = [] 560 | for k in self.steps: 561 | if self.steps[k].exists(): 562 | logging.info( 563 | f"Resource {self.steps[k].__class__.__name__}={k} already exists, skipping." 564 | ) 565 | remove_keys.append(k) 566 | else: 567 | logging.info( 568 | f"Resource {self.steps[k].__class__.__name__}={k} does not exist, will be added to plan." 569 | ) 570 | 571 | for k in remove_keys: 572 | del self.steps[k] 573 | 574 | def provision(self): 575 | """Provision resources in the plan.""" 576 | for k in self.steps: 577 | logging.info(f"Provisioning resource {k}...") 578 | self.steps[k].create() 579 | 580 | def get_main_ai_hub(self): 581 | for k in self.steps: 582 | if isinstance(self.steps[k], AzureAIHub): 583 | return self.steps[k] 584 | return None 585 | 586 | def get_main_ai_project(self): 587 | for k in self.steps: 588 | if isinstance(self.steps[k], AzureAIProject): 589 | return self.steps[k] 590 | return None 591 | 592 | 593 | ######## 594 | # MAIN # 595 | ######## 596 | 597 | 598 | def build_provision_plan(config) -> ProvisioningPlan: 599 | """Depending on values in config, creates a provisioning plan.""" 600 | plan = ProvisioningPlan() 601 | 602 | # Azure AI Hub 603 | if config.ai is None: 604 | raise ValueError("No AI resources in config.") 605 | plan.add_resource( 606 | ResourceGroup( 607 | subscription_id=config.ai.subscription_id, 608 | resource_group_name=config.ai.resource_group_name, 609 | location=config.ai.location, 610 | ) 611 | ) 612 | ai_hub = AzureAIHub( 613 | subscription_id=config.ai.subscription_id, 614 | resource_group_name=config.ai.resource_group_name, 615 | hub_name=config.ai.hub_name, 616 | location=config.ai.location, 617 | ) 618 | plan.add_resource(ai_hub) 619 | 620 | assert ( 621 | config.ai.hub_name != config.ai.project_name 622 | ), "AI hub_name cannot be the same as project_name" 623 | 624 | # Azure AI Project 625 | plan.add_resource( 626 | AzureAIProject( 627 | subscription_id=config.ai.subscription_id, 628 | resource_group_name=config.ai.resource_group_name, 629 | hub_name=config.ai.hub_name, 630 | project_name=config.ai.project_name, 631 | location=config.ai.location, 632 | ) 633 | ) 634 | 635 | # Search resource 636 | if hasattr(config, "search") and config.search is not None: 637 | search_subscription_id = ( 638 | config.search.subscription_id 639 | if hasattr(config.search, "subscription_id") 640 | else config.ai.subscription_id 641 | ) 642 | search_resource_group_name = ( 643 | config.search.resource_group_name 644 | if hasattr(config.search, "resource_group_name") 645 | else config.ai.resource_group_name 646 | ) 647 | search_location = ( 648 | config.search.location 649 | if hasattr(config.search, "location") 650 | else config.ai.location 651 | ) 652 | plan.add_resource( 653 | ResourceGroup( 654 | subscription_id=search_subscription_id, 655 | resource_group_name=search_resource_group_name, 656 | location=search_location, 657 | ) 658 | ) 659 | search = AzureAISearch( 660 | subscription_id=search_subscription_id, 661 | resource_group_name=search_resource_group_name, 662 | search_resource_name=config.search.search_resource_name, 663 | location=search_location, 664 | ) 665 | plan.add_resource(search) 666 | plan.add_resource( 667 | ConnectionSpec( 668 | hub=ai_hub, 669 | name=config.search.connection_name, 670 | auth="key", 671 | resource=search, 672 | ) 673 | ) 674 | 675 | # AOAI resource 676 | aoai_subscription_id = ( 677 | config.aoai.subscription_id 678 | if hasattr(config.aoai, "subscription_id") 679 | else config.ai.subscription_id 680 | ) 681 | aoai_resource_group_name = ( 682 | config.aoai.resource_group_name 683 | if hasattr(config.aoai, "resource_group_name") 684 | else config.ai.resource_group_name 685 | ) 686 | aoai_location = ( 687 | config.aoai.location if hasattr(config.aoai, "location") else config.ai.location 688 | ) 689 | plan.add_resource( 690 | ResourceGroup( 691 | subscription_id=aoai_subscription_id, 692 | resource_group_name=aoai_resource_group_name, 693 | location=aoai_location, 694 | ) 695 | ) 696 | aoai = AzureOpenAIResource( 697 | subscription_id=aoai_subscription_id, 698 | resource_group_name=aoai_resource_group_name, 699 | aoai_resource_name=config.aoai.aoai_resource_name, 700 | location=aoai_location, 701 | kind=config.aoai.kind if hasattr(config.aoai, "kind") else "OpenAI", 702 | ) 703 | plan.add_resource(aoai) 704 | plan.add_resource( 705 | ConnectionSpec( 706 | hub=ai_hub, name=config.aoai.connection_name, auth="key", resource=aoai 707 | ) 708 | ) 709 | if hasattr(config.aoai, "auth") and config.aoai.auth.mode == "aad": 710 | plan.add_resource( 711 | RBACRoleAssignment( 712 | resource=aoai, 713 | role_definition_id=config.aoai.auth.role, 714 | object_id=RBACRoleAssignment.get_self_client_id(), 715 | ) 716 | ) 717 | 718 | if hasattr(config.aoai, "deployments") and config.aoai.deployments: 719 | for deployment in config.aoai.deployments: 720 | plan.add_resource( 721 | AzureOpenAIDeployment( 722 | resource=aoai, 723 | name=deployment.name, 724 | model=deployment.model, 725 | version=( 726 | deployment.version if hasattr(deployment, "version") else None 727 | ), 728 | capacity=( 729 | deployment.capacity if hasattr(deployment, "capacity") else 10 730 | ), 731 | ) 732 | ) 733 | 734 | return plan 735 | 736 | 737 | def build_environment(environment_config, ai_project, env_file_path): 738 | """Get endpoints and keys from the config into the environment (dotenv).""" 739 | # connect to AI Hub 740 | ml_client = MLClient( 741 | subscription_id=ai_project.subscription_id, 742 | resource_group_name=ai_project.resource_group_name, 743 | workspace_name=ai_project.hub_name, 744 | credential=DefaultAzureCredential(), 745 | ) 746 | 747 | # load dotenv vars as a dictionary 748 | from dotenv import dotenv_values 749 | 750 | dotenv_vars = dotenv_values( 751 | dotenv_path=env_file_path, 752 | verbose=False, 753 | ) 754 | 755 | # overwrite values 756 | dotenv_vars["AZURE_SUBSCRIPTION_ID"] = ai_project.subscription_id 757 | dotenv_vars["AZURE_RESOURCE_GROUP"] = ai_project.resource_group_name 758 | dotenv_vars["AZUREAI_HUB_NAME"] = ai_project.hub_name 759 | dotenv_vars["AZUREAI_PROJECT_NAME"] = ai_project.project_name 760 | 761 | for key in environment_config.variables.keys(): 762 | conn_str = environment_config.variables[key] 763 | 764 | # write constants directly 765 | if not conn_str.startswith("azureml://"): 766 | dotenv_vars[key] = conn_str 767 | continue 768 | 769 | # regex extract connection name and type from 770 | # "azureml://connections/NAME/SUFFIX" 771 | try: 772 | # suffix can be either /target or /credentials/key 773 | name, suffix = re.match( 774 | r"azureml://connections/([^/]+)/(.*)", conn_str 775 | ).groups() 776 | # name, type = re.match(r"azureml://connections/(.*)/(.*)", conn_str).groups() 777 | except AttributeError: 778 | logging.critical(f"Invalid connection string: {conn_str}") 779 | continue 780 | 781 | logging.info(f"Getting connection {name}...") 782 | 783 | # get connection 784 | connection = ml_client.connections.get(name, populate_secrets=True) 785 | ml_client.connections.get 786 | if suffix == "target": 787 | # get target endpoint 788 | dotenv_vars[key] = connection.target 789 | elif suffix == "credentials/key": 790 | # get key itself 791 | # value = connection.credentials.get(key="api_key") 792 | value = connection.api_key 793 | dotenv_vars[key] = value or "" 794 | if value is None: 795 | logging.error(f"Key {name} not found in connection {conn_str}") 796 | continue 797 | else: 798 | raise NotImplementedError( 799 | f"Unsupported connection string: {conn_str} (expecting suffix /target or /credentials/key, got {suffix})" 800 | ) 801 | 802 | # write to file 803 | with open(env_file_path, "w") as f: 804 | for key in dotenv_vars: 805 | f.write(f"{key}={dotenv_vars[key]}\n") 806 | 807 | 808 | def main(): 809 | """Provision Azure AI resources for you.""" 810 | parser = get_arg_parser() 811 | args = parser.parse_args() 812 | 813 | if args.verbose: 814 | logging.basicConfig(level=logging.DEBUG) 815 | else: 816 | logging.basicConfig(level=logging.INFO) 817 | 818 | # exclude azure.* from logging 819 | logging.getLogger("azure.core").setLevel(logging.WARNING) 820 | logging.getLogger("azure.identity").setLevel(logging.WARNING) 821 | logging.getLogger("urllib3").setLevel(logging.WARNING) 822 | 823 | yaml_spec = OmegaConf.load(args.yaml_spec) 824 | provision_plan = build_provision_plan(yaml_spec) 825 | 826 | # save ai_project for commodity 827 | ai_project = provision_plan.get_main_ai_project() 828 | 829 | # remove from the plan resources that already exist 830 | provision_plan.remove_existing() 831 | 832 | if provision_plan.steps == {}: 833 | logging.info("All resources already exist, nothing to do.") 834 | else: 835 | print("Here's the resulting provisioning plan:") 836 | for step_key in provision_plan.steps: 837 | print( 838 | f"- {provision_plan.steps[step_key].__class__.__name__} : {str(provision_plan.steps[step_key])}" 839 | ) 840 | 841 | if not args.show_only: 842 | # provision all resources remaining 843 | provision_plan.provision() 844 | 845 | if args.export_env: 846 | logging.info(f"Building environment into {args.export_env}") 847 | build_environment(yaml_spec.environment, ai_project, args.export_env) 848 | 849 | 850 | if __name__ == "__main__": 851 | main() 852 | --------------------------------------------------------------------------------