├── 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 = "\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 | 
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 | 
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 |
--------------------------------------------------------------------------------