├── .azdo └── pipelines │ └── azure-dev.yml ├── .devcontainer └── devcontainer.json ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── azure-dev.yml │ ├── integration-test.yml │ └── template-validation.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── azure.yaml ├── data ├── ContosoBenefits.pdf └── fork.jpg ├── infra ├── abbreviations.json ├── main.bicep ├── main.parameters.json └── modules │ ├── aistudio │ ├── aihub.bicep │ ├── aiservices.bicep │ ├── dns.bicep │ ├── keyVault.bicep │ ├── network.bicep │ ├── searchService.bicep │ ├── sharedPrivateLinks.bicep │ └── storage.bicep │ ├── appinsights.bicep │ ├── appservice.bicep │ ├── bing.bicep │ ├── botservice.bicep │ ├── cosmos.bicep │ ├── gptDeployment.bicep │ └── msi.bicep ├── media └── architecture.png ├── scripts ├── setupSso.ps1 └── setupSso.sh └── src ├── .env.example ├── app.py ├── azure_ai_projects-1.0.0b1-py3-none-any.whl ├── bots ├── __init__.py ├── assistant_bot.py └── state_management_bot.py ├── config.py ├── conftest.py ├── data_models ├── __init__.py ├── conversation_data.py └── mime_type.py ├── dialogs ├── __init__.py └── login_dialog.py ├── gunicorn.conf.py ├── public ├── images │ ├── BotServices-Translucent.svg │ └── BotServices.png ├── index.html └── webchat.js.gz ├── pytest.ini ├── requirements.txt ├── routes ├── api │ ├── directline.py │ ├── files.py │ └── messages.py └── static │ └── static.py ├── services ├── bing.py ├── cosmos.py └── graph.py ├── tests ├── __init__.py ├── test_bot.py ├── test_directline.py ├── test_files.py ├── test_messages.py ├── test_models.py └── test_sample.py ├── tools ├── BingQuery.txt ├── ImageQuery.json └── ScheduleEvent.txt └── utils.py /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to mainline branch (main or master) 2 | # Set this to the mainline branch you are using 3 | trigger: 4 | - main 5 | - master 6 | 7 | # Azure Pipelines workflow to deploy to Azure using azd 8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` 9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd 10 | # See below for alternative task to install azd if you can't install above task in your organization 11 | 12 | pool: 13 | vmImage: ubuntu-latest 14 | 15 | steps: 16 | # - task: setup-azd@0 17 | # displayName: Install azd 18 | 19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd 20 | - task: Bash@3 21 | displayName: Install azd 22 | inputs: 23 | targetType: 'inline' 24 | script: | 25 | curl -fsSL https://aka.ms/install-azd.sh | bash 26 | 27 | # azd delegate auth to az to use service connection with AzureCLI@2 28 | - pwsh: | 29 | azd config set auth.useAzCliAuth "true" 30 | displayName: Configure AZD to Use AZ CLI Authentication. 31 | 32 | - task: AzureCLI@2 33 | displayName: Install Az Auth v2 34 | inputs: 35 | azureSubscription: azconnection 36 | scriptType: bash 37 | scriptLocation: inlineScript 38 | inlineScript: | 39 | az extension add --name authV2 -y 40 | env: 41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 43 | AZURE_LOCATION: $(AZURE_LOCATION) 44 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(AZD_INITIAL_ENVIRONMENT_CONFIG) 45 | 46 | - task: AzureCLI@2 47 | displayName: Provision Infrastructure 48 | inputs: 49 | azureSubscription: azconnection 50 | addSpnToEnvironment: true 51 | scriptType: bash 52 | scriptLocation: inlineScript 53 | inlineScript: | 54 | azd provision --no-prompt 55 | env: 56 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 57 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 58 | AZURE_LOCATION: $(AZURE_LOCATION) 59 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(AZD_INITIAL_ENVIRONMENT_CONFIG) 60 | 61 | - task: AzureCLI@2 62 | displayName: Deploy Application 63 | inputs: 64 | azureSubscription: azconnection 65 | scriptType: bash 66 | scriptLocation: inlineScript 67 | inlineScript: | 68 | azd deploy --no-prompt 69 | env: 70 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 71 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 72 | AZURE_LOCATION: $(AZURE_LOCATION) -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure Developer CLI", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.10-bullseye", 4 | "features": { 5 | // See https://containers.dev/features for list of features 6 | "azure-cli": "latest", 7 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 8 | "ghcr.io/azure/azure-dev/azd:latest": {} 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "GitHub.vscode-github-actions", 14 | "ms-azuretools.azure-dev", 15 | "ms-azuretools.vscode-azurefunctions", 16 | "ms-azuretools.vscode-bicep", 17 | "ms-azuretools.vscode-docker" 18 | // Include other VSCode language extensions if needed 19 | // Right click on an extension inside VSCode to add directly to devcontainer.json, or copy the extension ID 20 | ] 21 | } 22 | }, 23 | "forwardPorts": [ 24 | // Forward ports if needed for local development 25 | ], 26 | "postCreateCommand": "", 27 | "remoteUser": "vscode", 28 | "hostRequirements": { 29 | "memory": "8gb" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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. -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Dev 2 | on: 3 | workflow_dispatch: 4 | 5 | # GitHub Actions workflow to deploy to Azure using azd 6 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 7 | 8 | # Set up permissions for deploying with secretless Azure federated credentials 9 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 10 | permissions: 11 | id-token: write 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | env: 18 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 19 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 20 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 21 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 22 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Install azd 28 | uses: Azure/setup-azd@v1.0.0 29 | 30 | - name: Log in with Azure Developer (Federated Credentials) 31 | if: ${{ env.AZURE_CLIENT_ID != '' }} 32 | run: | 33 | azd auth login ` 34 | --client-id "$Env:AZURE_CLIENT_ID" ` 35 | --federated-credential-provider "github" ` 36 | --tenant-id "$Env:AZURE_TENANT_ID" 37 | shell: pwsh 38 | 39 | - name: Azure login 40 | uses: azure/login@v2 41 | with: 42 | client-id: ${{ env.AZURE_CLIENT_ID }} 43 | tenant-id: ${{ env.AZURE_TENANT_ID }} 44 | subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} 45 | 46 | - name: Log in with Azure (Client Credentials) 47 | if: ${{ env.AZURE_CREDENTIALS != '' }} 48 | run: | 49 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 50 | Write-Host "::add-mask::$($info.clientSecret)" 51 | 52 | azd auth login ` 53 | --client-id "$($info.clientId)" ` 54 | --client-secret "$($info.clientSecret)" ` 55 | --tenant-id "$($info.tenantId)" 56 | shell: pwsh 57 | env: 58 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 59 | 60 | - name: Provision Infrastructure 61 | run: azd provision --no-prompt 62 | env: 63 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 64 | 65 | - name: Deploy Application 66 | run: azd deploy --no-prompt -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | name: Run Integration Tests 2 | on: 3 | workflow_dispatch: 4 | 5 | # GitHub Actions workflow to deploy to Azure using azd 6 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 7 | 8 | # Set up permissions for deploying with secretless Azure federated credentials 9 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 10 | permissions: 11 | id-token: write 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | env: 18 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 19 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 20 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 21 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 22 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Install azd 28 | uses: Azure/setup-azd@v1.0.0 29 | 30 | - name: Log in with Azure Developer (Federated Credentials) 31 | if: ${{ env.AZURE_CLIENT_ID != '' }} 32 | run: | 33 | azd auth login ` 34 | --client-id "$Env:AZURE_CLIENT_ID" ` 35 | --federated-credential-provider "github" ` 36 | --tenant-id "$Env:AZURE_TENANT_ID" 37 | shell: pwsh 38 | 39 | - name: Azure login 40 | uses: azure/login@v2 41 | with: 42 | client-id: ${{ env.AZURE_CLIENT_ID }} 43 | tenant-id: ${{ env.AZURE_TENANT_ID }} 44 | subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} 45 | 46 | - name: Log in with Azure (Client Credentials) 47 | if: ${{ env.AZURE_CREDENTIALS != '' }} 48 | run: | 49 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 50 | Write-Host "::add-mask::$($info.clientSecret)" 51 | 52 | azd auth login ` 53 | --client-id "$($info.clientId)" ` 54 | --client-secret "$($info.clientSecret)" ` 55 | --tenant-id "$($info.tenantId)" 56 | shell: pwsh 57 | env: 58 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 59 | 60 | - name: Retrieve environment 61 | run: azd env refresh --no-prompt 62 | env: 63 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 64 | 65 | - name: Run tests 66 | run: cd src && pytest --cov=. --cov-report xml --nf tests/* -------------------------------------------------------------------------------- /.github/workflows/template-validation.yml: -------------------------------------------------------------------------------- 1 | name: Template Validation 2 | on: 3 | workflow_dispatch: 4 | 5 | permissions: 6 | contents: read 7 | id-token: write 8 | pull-requests: write 9 | 10 | jobs: 11 | template_validation_job: 12 | runs-on: ubuntu-latest 13 | name: template validation 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: microsoft/template-validation-action@v0.3.2 17 | id: validation 18 | env: 19 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 20 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 21 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 22 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 23 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 24 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: print result 28 | run: cat ${{ steps.validation.outputs.resultFile }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .azure 2 | 3 | .venv 4 | .conda 5 | .env 6 | __pycache__ 7 | 8 | node_modules 9 | 10 | bin 11 | obj 12 | appsettings.json 13 | 14 | .DS_Store 15 | 16 | .pytest_cache 17 | .coverage -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this project 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! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Marco Cardoso 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure Agents Travel Assistant 2 | 3 | End-to-end sample of a travel agent implemented with Azure AI Agent Service and Bot Framework. The travel assistant leverages the Azure AI platform to understand images and documents, providing assistance during travel planning and common travel situations. Key features include: 4 | 5 | - **Image Recognition**: Identify landmarks, extract text from travel documents, and more. 6 | - **Document Understanding**: Parse itineraries, booking confirmations, and other travel-related documents. 7 | - **Travel Assistance**: Offer recommendations, reminders, and support for various travel scenarios. 8 | 9 | > [!IMPORTANT] 10 | > The Azure AI Agent Service is currently in Private Preview. This early stage of development means the product is actively evolving, with significant updates and improvements expected. Users should anticipate changes as we work towards refining features, enhancing functionality, and expanding capabilities. We welcome feedback and contributions during this phase to help shape the future of the product. [Azure AI Agent Service: Private Preview Waitlist Application](https://customervoice.microsoft.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR4zfDSs3yfVOpxzjhs7PkSlUNlhGSlZTMkFBSDNaSVRMSDZBUjhPT1VLMCQlQCN0PWcu) 11 | 12 | ## Solution Architecture 13 | 14 | Below are the main components deployed as part of this solution: 15 | 16 | ![Solution Architecture](./media/architecture.png) 17 | 18 | - User sends messages through one of the [supported Bot Framework Channels](https://learn.microsoft.com/en-us/azure/bot-service/bot-service-channels-reference?view=azure-bot-service-4.0); 19 | - The Azure Bot Service connects to the Python web application through the /api/messages endpoint; 20 | - The Application communicates with the Azure AI Agent Runtime to process incoming messages using assistants; 21 | - Results are streamed back in realtime, and posted back to the user's channel; 22 | - The conversation history is stored in Cosmos DB, as well as ephemeral conversation state. 23 | > Note: Communications between App Services, Azure AI and Cosmos DB can be configured to use Private Endpoints. Authentication to these services is done using a User-Assigned Managed Identity. 24 | 25 | ## Getting Started 26 | 27 | To get started with the Azure Agents Travel Assistant, follow the instructions below to set up your environment and run the sample application. 28 | 29 | ### Prerequisites 30 | 31 | - An Azure subscription 32 | - Azure OpenAI: 50k Tokens per Minute of Pay-as-you-go quota for GPT-4o or GPT-4o-mini 33 | - Azure App Services: Available VM quota - P0v3 recommended 34 | - Azure CLI 35 | - Azure Developer CLI 36 | - Python 3.10 or later (for running locally) 37 | 38 | ### Installation 39 | 40 | 1. Before you begin, make sure you are logged in to both AZ and AZD CLIs 41 | ```sh 42 | az login # or az login -t 43 | azd auth login # or azd auth login --tenant 44 | ``` 45 | 46 | 2. Clone the repository: 47 | ```sh 48 | git clone https://github.com/Azure-Samples/azureai-travel-agent-python 49 | cd azureai-travel-agent-python 50 | ``` 51 | 52 | 3. Deploy the infrastructure and sample app 53 | ```sh 54 | azd up 55 | ``` 56 | You will be prompted to select and Azure subscription, a region, and an environment name. The environment name should be a short string, and it will be used to name the deployed resources. 57 | 58 | 4. (Optional) Run locally: 59 | ```sh 60 | cd src 61 | pip install -r requirements.txt 62 | python app.py 63 | ``` 64 | 65 | 5. (Optional) Open http://localhost:3978/api/messages in the [Bot Framework Emulator](https://github.com/microsoft/BotFramework-Emulator) 66 | 67 | ## Features 68 | 69 | - **Publicly available travel knowledge**: Ask about well-known destinations and tourist attractions 70 | - **Document Upload**: Upload PDF, Word and other document formats and add to File Search to use the information contained as part of the conversation 71 | - **Image Upload**: Upload images and ask questions about the location, landmark or directions 72 | - **Web search**: The Agent may use Bing Search to obtain updated information about certain locations, accomodations, weather and more. 73 | 74 | ## Guidance 75 | 76 | ### Regional Availablility 77 | 78 | You may deploy this solution on any regions that support Azure AI Agents. Some components, such as Bot Service and Bing Search, are deployed in a global model, and as such are not tied to a single region. Make sure to review Data Residency requirements. 79 | 80 | **At this time, only the East US region supports Azure AI Agents.** 81 | 82 | - [Regionalization in Azure AI Bot Service](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-regionalization?view=azure-bot-service-4.0) 83 | 84 | If you need to deploy services across more than one region, use the commands below to set regions for specific services. Services with unspecified locations will be created in the "main" region chosen. 85 | 86 | ```sh 87 | # azd env set AZURE__LOCATION 88 | # For example: 89 | azd env set AZURE_APPSERVICE_LOCATION eastus2 90 | azd env set AZURE_COSMOSDB_LOCATION westus 91 | ``` 92 | 93 | Review ./infra/main.parameters.json for a full list of available environment configurations. 94 | 95 | ### Model Support 96 | 97 | This quickstart supports both GPT-4o and GPT-4o-mini. Ohter models may also perform well depending on question complexity. Standard deployments are used by default, but you may update them to Global Standard or Provisioned SKUs after successfully deploying the solution. 98 | 99 | ### Troubleshooting 100 | 101 | - Provisioning errors: 102 | - `Azure Open AI: InsufficientQuota`: Your Azure Subscription does not have enough pay-as-you-go quota for the selected region/model. Select a different subscription, region, model, or [request quota](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/quota?tabs=rest). 103 | - `Bing Search: Insufficient Quota`: You cannot add a new Bing resource in the selected Subscription and SKU. Select a different SKU or region, or delete other Bing Search resources 104 | - `Cosmos DB: Service Unavailable`: This region may be under high demand and does not have available resources for your subcription type. Select a different region or try again later 105 | - `Any resource: Invalid Resource Location, the resource already exists`: A resource with the same name already exists in a different region or resource group. Delete existing resources or retry with a different environment name 106 | - Runtime errors: 107 | - `Message: Invalid subscription ID`: Your subscription may not be enabled for Azure AI Agents. During the preview of this functionality, it may be required to complete additional steps to onboard your subscription to Azure AI Agents. Alternatively, update the .env file (locally) or app service environment variables (on Azure) to point the application to another AI Project. 108 | - `azure.core.exceptions.ClientAuthenticationError: (PermissionDenied) Principal does not have access to API/Operation.`: Your current user does not have Azure ML Data Scientist role, or your IP is not allowed to access the Azure AI Hub. Review the RBAC and networking configurations of your AI Hub/Project. Similar exceptions may happen when not logged in with the Azure CLI. 109 | 110 | ## Security 111 | 112 | Most of the resources deployed in this template leverage Private Endpoints and Entra ID authentication for enhanced security. Make sure to use the corresponding parameters to use these features. 113 | 114 | There is currently one exception to this pattern in this repository: 115 | 116 | - Bot Service does not support Entra ID authentication for the Direct Line / Web channel. 117 | 118 | For this reason, this service will use a secret, properly stored in the Key Vault deployed with the quickstart. 119 | 120 | ## Resources 121 | 122 | - [Getting started with Azure OpenAI Assistants (Preview)](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/assistant) 123 | - [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/overview) 124 | - [Generative AI For Beginners](https://github.com/microsoft/generative-ai-for-beginners) 125 | 126 | ## How to Contribute 127 | 128 | This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit 129 | 130 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. 131 | 132 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq) or contact with any additional questions or comments. 133 | 134 | ## Key Contacts & Contributors 135 | 136 | | Contact | GitHub ID | Email | 137 | |---------|-----------|-------| 138 | | Marco Cardoso | @MarcoABCardoso | macardoso@microsoft.com | 139 | 140 | ## License 141 | 142 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party's policies. 143 | -------------------------------------------------------------------------------- /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](https://aka.ms/opensource/security/definition), 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://aka.ms/opensource/security/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://aka.ms/opensource/security/pgpkey). 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://aka.ms/opensource/security/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://aka.ms/opensource/security/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://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 7 | feature request as a new Issue. 8 | 9 | ## Microsoft Support Policy 10 | 11 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 12 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: azure-agents-python 4 | services: 5 | azure-agents-app: 6 | project: src 7 | host: appservice 8 | language: python 9 | hooks: 10 | postprovision: 11 | windows: 12 | shell: pwsh 13 | run: Copy-Item ".azure/$(azd env get-value AZURE_ENV_NAME)/.env" -Destination "src/.env" 14 | interactive: false 15 | continueOnError: false 16 | posix: 17 | shell: sh 18 | run: cp .azure/$(azd env get-value AZURE_ENV_NAME)/.env src/.env 19 | interactive: false 20 | continueOnError: false -------------------------------------------------------------------------------- /data/ContosoBenefits.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/data/ContosoBenefits.pdf -------------------------------------------------------------------------------- /data/fork.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/data/fork.jpg -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationConfigurationStores": "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 | "cognitiveServicesAzureAI": "cog-ai-", 16 | "cognitiveServicesBing": "cog-bg-", 17 | "cognitiveServicesOpenAI": "cog-oa-", 18 | "cognitiveServicesSpeech": "cog-spc-", 19 | "cognitiveServicesFormRecognizer": "cog-fr-", 20 | "cognitiveServicesTextAnalytics": "cog-ta-", 21 | "cognitiveServicesBot": "cog-bot-", 22 | "computeAvailabilitySets": "avail-", 23 | "computeCloudServices": "cld-", 24 | "computeDiskEncryptionSets": "des", 25 | "computeDisks": "disk", 26 | "computeDisksOs": "osdisk", 27 | "computeGalleries": "gal", 28 | "computeSnapshots": "snap-", 29 | "computeVirtualMachines": "vm", 30 | "computeVirtualMachineScaleSets": "vmss-", 31 | "containerInstanceContainerGroups": "ci", 32 | "containerRegistryRegistries": "cr", 33 | "containerServiceManagedClusters": "aks-", 34 | "databricksWorkspaces": "dbw-", 35 | "dataFactoryFactories": "adf-", 36 | "dataLakeAnalyticsAccounts": "dla", 37 | "dataLakeStoreAccounts": "dls", 38 | "dataMigrationServices": "dms-", 39 | "dBforMySQLServers": "mysql-", 40 | "dBforPostgreSQLServers": "psql-", 41 | "devicesIotHubs": "iot-", 42 | "devicesProvisioningServices": "provs-", 43 | "devicesProvisioningServicesCertificates": "pcert-", 44 | "documentDBDatabaseAccounts": "cosmos-", 45 | "eventGridDomains": "evgd-", 46 | "eventGridDomainsTopics": "evgt-", 47 | "eventGridEventSubscriptions": "evgs-", 48 | "eventHubNamespaces": "evhns-", 49 | "eventHubNamespacesEventHubs": "evh-", 50 | "hdInsightClustersHadoop": "hadoop-", 51 | "hdInsightClustersHbase": "hbase-", 52 | "hdInsightClustersKafka": "kafka-", 53 | "hdInsightClustersMl": "mls-", 54 | "hdInsightClustersSpark": "spark-", 55 | "hdInsightClustersStorm": "storm-", 56 | "hybridComputeMachines": "arcs-", 57 | "insightsActionGroups": "ag-", 58 | "insightsComponents": "appi-", 59 | "keyVaultVaults": "kv-", 60 | "kubernetesConnectedClusters": "arck", 61 | "kustoClusters": "dec", 62 | "kustoClustersDatabases": "dedb", 63 | "logicIntegrationAccounts": "ia-", 64 | "logicWorkflows": "logic-", 65 | "machineLearningServicesWorkspaces": "mlw-", 66 | "managedIdentityUserAssignedIdentities": "id-", 67 | "managementManagementGroups": "mg-", 68 | "migrateAssessmentProjects": "migr-", 69 | "networkApplicationGateways": "agw-", 70 | "networkApplicationSecurityGroups": "asg-", 71 | "networkAzureFirewalls": "afw-", 72 | "networkBastionHosts": "bas-", 73 | "networkConnections": "con-", 74 | "networkDnsZones": "dnsz-", 75 | "networkExpressRouteCircuits": "erc-", 76 | "networkFirewallPolicies": "afwp-", 77 | "networkFirewallPoliciesWebApplication": "waf", 78 | "networkFirewallPoliciesRuleGroups": "wafrg", 79 | "networkFrontDoors": "fd-", 80 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 81 | "networkLoadBalancersExternal": "lbe-", 82 | "networkLoadBalancersInternal": "lbi-", 83 | "networkLoadBalancersInboundNatRules": "rule-", 84 | "networkLocalNetworkGateways": "lgw-", 85 | "networkNatGateways": "ng-", 86 | "networkNetworkInterfaces": "nic-", 87 | "networkNetworkSecurityGroups": "nsg-", 88 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 89 | "networkNetworkWatchers": "nw-", 90 | "networkPrivateDnsZones": "pdnsz-", 91 | "networkPrivateLinkServices": "pl-", 92 | "networkPublicIPAddresses": "pip-", 93 | "networkPublicIPPrefixes": "ippre-", 94 | "networkRouteFilters": "rf-", 95 | "networkRouteTables": "rt-", 96 | "networkRouteTablesRoutes": "udr-", 97 | "networkTrafficManagerProfiles": "traf-", 98 | "networkVirtualNetworkGateways": "vgw-", 99 | "networkVirtualNetworks": "vnet-", 100 | "networkVirtualNetworksSubnets": "snet-", 101 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 102 | "networkVirtualWans": "vwan-", 103 | "networkVpnGateways": "vpng-", 104 | "networkVpnGatewaysVpnConnections": "vcn-", 105 | "networkVpnGatewaysVpnSites": "vst-", 106 | "notificationHubsNamespaces": "ntfns-", 107 | "notificationHubsNamespacesNotificationHubs": "ntf-", 108 | "operationalInsightsWorkspaces": "log-", 109 | "portalDashboards": "dash-", 110 | "powerBIDedicatedCapacities": "pbi-", 111 | "purviewAccounts": "pview-", 112 | "recoveryServicesVaults": "rsv-", 113 | "resourcesResourceGroups": "rg-", 114 | "searchSearchServices": "srch-", 115 | "serviceBusNamespaces": "sb-", 116 | "serviceBusNamespacesQueues": "sbq-", 117 | "serviceBusNamespacesTopics": "sbt-", 118 | "serviceEndPointPolicies": "se-", 119 | "serviceFabricClusters": "sf-", 120 | "signalRServiceSignalR": "sigr", 121 | "sqlManagedInstances": "sqlmi-", 122 | "sqlServers": "sql-", 123 | "sqlServersDataWarehouse": "sqldw-", 124 | "sqlServersDatabases": "sqldb-", 125 | "sqlServersDatabasesStretch": "sqlstrdb-", 126 | "storageStorageAccounts": "st", 127 | "storageStorageAccountsVm": "stvm", 128 | "storSimpleManagers": "ssimp", 129 | "streamAnalyticsCluster": "asa-", 130 | "synapseWorkspaces": "syn", 131 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 132 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 133 | "synapseWorkspacesSqlPoolsSpark": "synsp", 134 | "timeSeriesInsightsEnvironments": "tsi-", 135 | "webServerFarms": "plan-", 136 | "webSitesAppService": "app-", 137 | "webSitesAppServiceEnvironment": "ase-", 138 | "webSitesFunctions": "func-", 139 | "webStaticSites": "stapp-" 140 | } -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | // Common configurations 4 | @description('Name of the environment') 5 | param environmentName string 6 | @description('Principal ID to grant access to the AI services. Leave empty to skip') 7 | param myPrincipalId string = '' 8 | @description('Current principal type being used') 9 | @allowed(['User', 'ServicePrincipal']) 10 | param myPrincipalType string 11 | @description('IP addresses to grant access to the AI services. Leave empty to skip') 12 | param allowedIpAddresses string = '' 13 | var allowedIpAddressesArray = !empty(allowedIpAddresses) ? split(allowedIpAddresses, ',') : [] 14 | @description('Resource group name for the AI services. Defauts to rg-') 15 | param resourceGroupName string = '' 16 | @description('Resource group name for the DNS configurations. Defaults to rg-dns') 17 | param dnsResourceGroupName string = '' 18 | @description('Tags for all AI resources created. JSON object') 19 | param tags object = {} 20 | 21 | // Network configurations 22 | @description('Allow or deny public network access to the AI services (recommended: Disabled)') 23 | @allowed(['Enabled', 'Disabled']) 24 | param publicNetworkAccess string 25 | @description('Authentication type to use (recommended: identity)') 26 | @allowed(['identity', 'accessKey']) 27 | param authMode string = 'identity' 28 | @description('Address prefixes for the spoke vNet') 29 | param vnetAddressPrefixes array = ['10.0.0.0/16'] 30 | @description('Address prefix for the private endpoint subnet') 31 | param privateEndpointSubnetAddressPrefix string = '10.0.0.0/24' 32 | @description('Address prefix for the application subnet') 33 | param appSubnetAddressPrefix string = '10.0.1.0/24' 34 | 35 | // AI Services configurations 36 | @description('Name of the AI Services account. Automatically generated if left blank') 37 | param aiServicesName string = '' 38 | @description('Name of the AI Hub resource. Automatically generated if left blank') 39 | param aiHubName string = '' 40 | @description('Name of the Storage Account. Automatically generated if left blank') 41 | param storageName string = '' 42 | @description('Name of the Key Vault. Automatically generated if left blank') 43 | param keyVaultName string = '' 44 | @description('Name of the Bing account. Automatically generated if left blank') 45 | param bingName string = '' 46 | @description('Name of the Bot Service. Automatically generated if left blank') 47 | param botName string = '' 48 | 49 | // Other configurations 50 | @description('Name of the Bot Service. Automatically generated if left blank') 51 | param msiName string = '' 52 | @description('Name of the Cosmos DB Account. Automatically generated if left blank') 53 | param cosmosName string = '' 54 | @description('Name of the App Service Plan. Automatically generated if left blank') 55 | param appPlanName string = '' 56 | @description('Name of the App Services Instance. Automatically generated if left blank') 57 | param appName string = '' 58 | @description('Whether to enable authentication (requires Entra App Developer role)') 59 | param enableAuthentication bool = false 60 | @description('Whether to deploy an AI Hub') 61 | param deployAIHub bool = true 62 | @description('Whether to deploy a sample AI Project') 63 | param deployAIProject bool = true 64 | 65 | @description('Gen AI model name and version to deploy') 66 | @allowed(['gpt-4;1106-Preview', 'gpt-4;0125-Preview', 'gpt-4o;2024-05-13', 'gpt-4o-mini;2024-07-18']) 67 | param model string 68 | @description('Tokens per minute capacity for the model. Units of 1000 (capacity = 10 means 10,000 tokens per minute)') 69 | param modelCapacity int 70 | 71 | 72 | // Location and overrides 73 | @description('Location to deploy AI Services') 74 | param aiServicesLocation string = deployment().location 75 | @description('Location to deploy Cosmos DB') 76 | param cosmosLocation string = deployment().location 77 | @description('Location to deploy App Service') 78 | param appServiceLocation string = deployment().location 79 | @description('Location to deploy Bot Service') 80 | param botServiceLocation string = 'global' 81 | @description('Location to deploy Storage account') 82 | param storageLocation string = deployment().location 83 | @description('Location to deploy Key Vault') 84 | param keyVaultLocation string = deployment().location 85 | @description('Location to deploy Managed Identity') 86 | param msiLocation string = deployment().location 87 | @description('Location to deploy virtual network resources') 88 | param vnetLocation string = deployment().location 89 | @description('Location to deploy private DNS zones') 90 | param dnsLocation string = deployment().location 91 | 92 | var modelName = split(model, ';')[0] 93 | var modelVersion = split(model, ';')[1] 94 | 95 | var abbrs = loadJsonContent('abbreviations.json') 96 | var uniqueSuffix = substring(uniqueString(subscription().id, environmentName), 1, 3) 97 | var location = deployment().location 98 | 99 | var names = { 100 | resourceGroup: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 101 | dnsResourceGroup: !empty(dnsResourceGroupName) ? dnsResourceGroupName : '${abbrs.resourcesResourceGroups}dns' 102 | msi: !empty(msiName) ? msiName : '${abbrs.managedIdentityUserAssignedIdentities}${environmentName}-${uniqueSuffix}' 103 | cosmos: !empty(cosmosName) ? cosmosName : '${abbrs.documentDBDatabaseAccounts}${environmentName}-${uniqueSuffix}' 104 | appPlan: !empty(appPlanName) ? appPlanName : '${abbrs.webSitesAppServiceEnvironment}${environmentName}-${uniqueSuffix}' 105 | app: !empty(appName) ? appName : '${abbrs.webSitesAppService}${environmentName}-${uniqueSuffix}' 106 | bot: !empty(botName) ? botName : '${abbrs.cognitiveServicesBot}${environmentName}-${uniqueSuffix}' 107 | vnet: '${abbrs.networkVirtualNetworks}${environmentName}-${uniqueSuffix}' 108 | privateLinkSubnet: '${abbrs.networkVirtualNetworksSubnets}${environmentName}-pl-${uniqueSuffix}' 109 | appSubnet: '${abbrs.networkVirtualNetworksSubnets}${environmentName}-app-${uniqueSuffix}' 110 | aiServices: !empty(aiServicesName) ? aiServicesName : '${abbrs.cognitiveServicesAccounts}${environmentName}-${uniqueSuffix}' 111 | aiHub: !empty(aiHubName) ? aiHubName : '${abbrs.cognitiveServicesAccounts}hub-${environmentName}-${uniqueSuffix}' 112 | storage: !empty(storageName) ? storageName : replace(replace('${abbrs.storageStorageAccounts}${environmentName}${uniqueSuffix}', '-', ''), '_', '') 113 | keyVault: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${environmentName}-${uniqueSuffix}' 114 | bing: !empty(bingName) ? bingName : '${abbrs.cognitiveServicesBing}${environmentName}-${uniqueSuffix}' 115 | } 116 | 117 | // Private Network Resources 118 | var dnsZones = [ 119 | 'privatelink.openai.azure.com' 120 | 'privatelink.cognitiveservices.azure.com' 121 | 'privatelink.blob.${environment().suffixes.storage}' 122 | 'privatelink.vaultcore.azure.net' 123 | 'privatelink.search.azure.com' 124 | 'privatelink.documents.azure.com' 125 | 'privatelink.api.azureml.ms' 126 | 'privatelink.notebooks.azure.net' 127 | 'privatelink.azurewebsites.net' 128 | ] 129 | 130 | var dnsZoneIds = publicNetworkAccess == 'Disabled' ? m_dns.outputs.dnsZoneIds : dnsZones 131 | var privateEndpointSubnetId = publicNetworkAccess == 'Disabled' ? m_network.outputs.privateEndpointSubnetId : '' 132 | 133 | // Deploy two resource groups 134 | resource resourceGroup 'Microsoft.Resources/resourceGroups@2023-07-01' = { 135 | name: names.resourceGroup 136 | location: location 137 | tags: union(tags, { 'azd-env-name': environmentName }) 138 | } 139 | 140 | resource dnsResourceGroup 'Microsoft.Resources/resourceGroups@2023-07-01' = if (publicNetworkAccess == 'Disabled') { 141 | name: names.dnsResourceGroup 142 | location: empty(dnsLocation) ? location : dnsLocation 143 | tags: tags 144 | } 145 | 146 | // Network module - deploys Vnet 147 | module m_network 'modules/aistudio/network.bicep' = if (publicNetworkAccess == 'Disabled') { 148 | name: 'deploy_vnet' 149 | scope: resourceGroup 150 | params: { 151 | location: empty(vnetLocation) ? location : vnetLocation 152 | vnetName: names.vnet 153 | vnetAddressPrefixes: vnetAddressPrefixes 154 | privateEndpointSubnetName: names.privateLinkSubnet 155 | privateEndpointSubnetAddressPrefix: privateEndpointSubnetAddressPrefix 156 | appSubnetName: names.appSubnet 157 | appSubnetAddressPrefix: appSubnetAddressPrefix 158 | } 159 | } 160 | 161 | // DNS module - deploys private DNS zones and links them to the Vnet 162 | module m_dns 'modules/aistudio/dns.bicep' = if (publicNetworkAccess == 'Disabled') { 163 | name: 'deploy_dns' 164 | scope: dnsResourceGroup 165 | params: { 166 | vnetId: publicNetworkAccess == 'Disabled' ? m_network.outputs.vnetId : '' 167 | vnetName: publicNetworkAccess == 'Disabled' ? m_network.outputs.vnetName : '' 168 | dnsZones: dnsZones 169 | } 170 | } 171 | 172 | module m_msi 'modules/msi.bicep' = { 173 | name: 'deploy_msi' 174 | scope: resourceGroup 175 | params: { 176 | location: empty(msiLocation) ? location : msiLocation 177 | msiName: names.msi 178 | tags: tags 179 | } 180 | } 181 | 182 | // AI Services module 183 | module m_aiservices 'modules/aistudio/aiservices.bicep' = { 184 | name: 'deploy_aiservices' 185 | scope: resourceGroup 186 | params: { 187 | location: empty(aiServicesLocation) ? location : aiServicesLocation 188 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 189 | aiServicesName: names.aiServices 190 | publicNetworkAccess: publicNetworkAccess 191 | privateEndpointSubnetId: privateEndpointSubnetId 192 | openAIPrivateDnsZoneId: dnsZoneIds[0] 193 | cognitiveServicesPrivateDnsZoneId: dnsZoneIds[1] 194 | authMode: authMode 195 | grantAccessTo: authMode == 'identity' 196 | ? [ 197 | { 198 | id: myPrincipalId 199 | type: myPrincipalType 200 | } 201 | { 202 | id: m_msi.outputs.msiPrincipalID 203 | type: 'ServicePrincipal' 204 | } 205 | ] 206 | : [] 207 | allowedIpAddresses: allowedIpAddressesArray 208 | tags: tags 209 | } 210 | } 211 | 212 | // Storage and Key Vault 213 | module m_storage 'modules/aistudio/storage.bicep' = { 214 | name: 'deploy_storage' 215 | scope: resourceGroup 216 | params: { 217 | location: empty(storageLocation) ? location : storageLocation 218 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 219 | storageName: names.storage 220 | publicNetworkAccess: publicNetworkAccess 221 | authMode: authMode 222 | privateEndpointSubnetId: privateEndpointSubnetId 223 | privateDnsZoneId: dnsZoneIds[2] 224 | grantAccessTo: authMode == 'identity' 225 | ? [ 226 | { 227 | id: myPrincipalId 228 | type: myPrincipalType 229 | } 230 | { 231 | id: m_msi.outputs.msiPrincipalID 232 | type: 'ServicePrincipal' 233 | } 234 | ] 235 | : [] 236 | tags: tags 237 | } 238 | } 239 | 240 | module m_keyVault 'modules/aistudio/keyVault.bicep' = { 241 | name: 'deploy_keyVault' 242 | scope: resourceGroup 243 | params: { 244 | location: empty(keyVaultLocation) ? location : keyVaultLocation 245 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 246 | keyVaultName: names.keyVault 247 | publicNetworkAccess: publicNetworkAccess 248 | privateEndpointSubnetId: privateEndpointSubnetId 249 | privateDnsZoneId: dnsZoneIds[3] 250 | allowedIpAddresses: allowedIpAddressesArray 251 | grantAccessTo: [ 252 | { 253 | id: myPrincipalId 254 | type: myPrincipalType 255 | } 256 | { 257 | id: m_msi.outputs.msiPrincipalID 258 | type: 'ServicePrincipal' 259 | } 260 | ] 261 | tags: tags 262 | } 263 | } 264 | 265 | // AI Hub module - deploys AI Hub and Project 266 | module m_aihub 'modules/aistudio/aihub.bicep' = if (deployAIHub) { 267 | name: 'deploy_ai' 268 | scope: resourceGroup 269 | params: { 270 | location: empty(aiServicesLocation) ? location : aiServicesLocation 271 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 272 | aiHubName: names.aiHub 273 | aiProjectName: 'cog-ai-prj-${environmentName}-${uniqueSuffix}' 274 | aiServicesName: m_aiservices.outputs.aiServicesName 275 | keyVaultName: m_keyVault.outputs.keyVaultName 276 | storageName: names.storage 277 | publicNetworkAccess: publicNetworkAccess 278 | systemDatastoresAuthMode: authMode 279 | privateEndpointSubnetId: privateEndpointSubnetId 280 | apiPrivateDnsZoneId: dnsZoneIds[6] 281 | notebookPrivateDnsZoneId: dnsZoneIds[7] 282 | deployAIProject: deployAIProject 283 | allowedIpAddresses: allowedIpAddressesArray 284 | grantAccessTo: authMode == 'identity' 285 | ? [ 286 | { 287 | id: myPrincipalId 288 | type: myPrincipalType 289 | } 290 | { 291 | id: m_msi.outputs.msiPrincipalID 292 | type: 'ServicePrincipal' 293 | } 294 | ] 295 | : [] 296 | tags: tags 297 | } 298 | } 299 | 300 | // Bing module 301 | module m_bing 'modules/bing.bicep' = { 302 | name: 'deploy_bing' 303 | scope: resourceGroup 304 | params: { 305 | bingName: names.bing 306 | keyVaultName: m_keyVault.outputs.keyVaultName 307 | tags: tags 308 | } 309 | } 310 | 311 | module m_cosmos 'modules/cosmos.bicep' = { 312 | name: 'deploy_cosmos' 313 | scope: resourceGroup 314 | params: { 315 | location: empty(cosmosLocation) ? location : cosmosLocation 316 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 317 | cosmosName: names.cosmos 318 | keyVaultName: m_keyVault.outputs.keyVaultName 319 | publicNetworkAccess: publicNetworkAccess 320 | privateEndpointSubnetId: privateEndpointSubnetId 321 | privateDnsZoneId: dnsZoneIds[5] 322 | allowedIpAddresses: allowedIpAddressesArray 323 | authMode: authMode 324 | grantAccessTo: authMode == 'identity' 325 | ? [ 326 | { 327 | id: myPrincipalId 328 | type: myPrincipalType 329 | } 330 | { 331 | id: m_msi.outputs.msiPrincipalID 332 | type: 'ServicePrincipal' 333 | } 334 | ] 335 | : [] 336 | tags: tags 337 | } 338 | } 339 | 340 | module m_gpt 'modules/gptDeployment.bicep' = { 341 | name: 'deploygpt' 342 | scope: resourceGroup 343 | params: { 344 | aiServicesName: m_aiservices.outputs.aiServicesName 345 | modelName: modelName 346 | modelVersion: modelVersion 347 | modelCapacity: modelCapacity 348 | } 349 | } 350 | 351 | module m_app 'modules/appservice.bicep' = { 352 | name: 'deploy_app' 353 | scope: resourceGroup 354 | params: { 355 | location: empty(appServiceLocation) ? location : appServiceLocation 356 | vnetLocation: empty(vnetLocation) ? location : vnetLocation 357 | appServicePlanName: names.appPlan 358 | appServiceName: names.app 359 | tags: tags 360 | publicNetworkAccess: publicNetworkAccess 361 | privateEndpointSubnetId: privateEndpointSubnetId 362 | privateDnsZoneId: dnsZoneIds[8] 363 | authMode: authMode 364 | appSubnetId: publicNetworkAccess == 'Disabled' ? m_network.outputs.appSubnetId : '' 365 | allowedIpAddresses: allowedIpAddressesArray 366 | msiID: m_msi.outputs.msiID 367 | msiClientID: m_msi.outputs.msiClientID 368 | cosmosName: m_cosmos.outputs.cosmosName 369 | deploymentName: m_gpt.outputs.modelName 370 | aiServicesName: m_aiservices.outputs.aiServicesName 371 | bingName: m_bing.outputs.bingName 372 | aiHubName: m_aihub.outputs.aiHubName 373 | aiProjectName: m_aihub.outputs.aiProjectName 374 | keyVaultName: m_keyVault.outputs.keyVaultName 375 | } 376 | } 377 | 378 | module m_bot 'modules/botservice.bicep' = { 379 | name: 'deploy_bot' 380 | scope: resourceGroup 381 | params: { 382 | location: empty(botServiceLocation) ? location : botServiceLocation 383 | botServiceName: names.bot 384 | keyVaultName: m_keyVault.outputs.keyVaultName 385 | tags: tags 386 | endpoint: 'https://${m_app.outputs.backendHostName}/api/messages' 387 | msiClientID: m_msi.outputs.msiClientID 388 | msiID: m_msi.outputs.msiID 389 | publicNetworkAccess: publicNetworkAccess 390 | } 391 | } 392 | 393 | output AZURE_TENANT_ID string = tenant().tenantId 394 | output AZURE_RESOURCE_GROUP_ID string = resourceGroup.id 395 | output AZURE_RESOURCE_GROUP_NAME string = resourceGroup.name 396 | output AI_SERVICES_ENDPOINT string = m_aiservices.outputs.aiServicesEndpoint 397 | output BACKEND_APP_NAME string = m_app.outputs.backendAppName 398 | output BACKEND_APP_HOSTNAME string = m_app.outputs.backendHostName 399 | output MSI_PRINCIPAL_ID string = m_msi.outputs.msiPrincipalID 400 | output ENABLE_AUTH bool = enableAuthentication 401 | output AUTH_MODE string = authMode 402 | 403 | output AZURE_COSMOSDB_ENDPOINT string = m_cosmos.outputs.cosmosEndpoint 404 | output AZURE_KEY_VAULT_ENDPOINT string = m_keyVault.outputs.keyVaultEndpoint 405 | output AZURE_OPENAI_API_ENDPOINT string = m_aiservices.outputs.aiServicesEndpoint 406 | output AZURE_OPENAI_API_VERSION string = '2024-07-01-preview' 407 | output AZURE_OPENAI_ASSISTANT_NAME string = 'azure-agents-python' 408 | output AZURE_OPENAI_DEPLOYMENT_NAME string = m_gpt.outputs.modelName 409 | output AZURE_OPENAI_STREAMING bool = false 410 | output AZURE_AI_PROJECT_CONNECTION_STRING string = m_aihub.outputs.aiProjectConnectionString 411 | output AZURE_BING_CONNECTION_ID string = m_bing.outputs.bingName 412 | output LLM_INSTRUCTIONS string = 'Welcome to the Travel Agent Sample! You can use this chat to:
 - Get recommendations of places to visit and things to do;
 - Upload your travel bookings and generate an itinerary;
 - Upload pictures of signs, menus and more to get information about them;
 - Ask for help with budgeting a trip;
 - And more!
To upload files, use the attachment button on the left, attach a file and hit enter to upload. You will get confirmation when the file is ready to use. You will also be prompted whether you\'d like to add it to Code Interpreter or File Search. Use Code Interpreter for mathematical operations, and File Search to use the file contents as context for your question. You may skip this step for images.' 413 | output LLM_WELCOME_MESSAGE string = 'You are a helpful travel agent who can assist with many travel-related inquiries, including:\n- Reading travel documents and putting together itineraries\n- Viewing and interpreting pictures that may be in different languages\n- Locating landmarks and suggesting places to visit\n- Budgeting and graphing cost information\n- Looking up information on the web for up to date information\nYou should do your best to respond to travel-related questions, but politely decline to help with unrelated questions.\nAny time the information you want to provide requires up-to-date sources - for example, hotels, restaurants and more - you should use the Bing Search tool and provide sources.' 414 | output MAX_TURNS int = 20 415 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "resourceGroupName": { 12 | "value": "rg-${AZURE_ENV_NAME}" 13 | }, 14 | "dnsResourceGroupName": { 15 | "value": "rg-dns-${AZURE_ENV_NAME}" 16 | }, 17 | "aiServicesName": { 18 | "value": "${AZURE_AISERVICES_NAME}" 19 | }, 20 | "aiHubName": { 21 | "value": "${AZURE_AIHUB_NAME}" 22 | }, 23 | "storageName": { 24 | "value": "${AZURE_STORAGE_NAME}" 25 | }, 26 | "keyVaultName": { 27 | "value": "${AZURE_KEYVAULT_NAME}" 28 | }, 29 | "bingName": { 30 | "value": "${AZURE_BING_NAME}" 31 | }, 32 | "botName": { 33 | "value": "${AZURE_BOT_NAME}" 34 | }, 35 | "msiName": { 36 | "value": "${AZURE_MSI_NAME}" 37 | }, 38 | "cosmosName": { 39 | "value": "${AZURE_COSMOS_NAME}" 40 | }, 41 | "appPlanName": { 42 | "value": "${AZURE_APPPLAN_NAME}" 43 | }, 44 | "appName": { 45 | "value": "${AZURE_APP_NAME}" 46 | }, 47 | "allowedIpAddresses": { 48 | "value": "${ALLOWED_IP_ADDRESSES}" 49 | }, 50 | "myPrincipalType": { 51 | "value": "${AZURE_PRINCIPAL_TYPE}" 52 | }, 53 | "myPrincipalId": { 54 | "value": "${AZURE_PRINCIPAL_ID}" 55 | }, 56 | "aiServicesLocation": { 57 | "value": "${AZURE_AISERVICES_LOCATION}" 58 | }, 59 | "cosmosLocation": { 60 | "value": "${AZURE_COSMOSDB_LOCATION}" 61 | }, 62 | "appServiceLocation": { 63 | "value": "${AZURE_APPSERVICE_LOCATION}" 64 | }, 65 | "storageLocation": { 66 | "value": "${AZURE_STORAGE_LOCATION}" 67 | }, 68 | "keyVaultLocation": { 69 | "value": "${AZURE_KEYVAULT_LOCATION}" 70 | }, 71 | "msiLocation": { 72 | "value": "${AZURE_MSI_LOCATION}" 73 | }, 74 | "vnetLocation": { 75 | "value": "${AZURE_VNET_LOCATION}" 76 | }, 77 | "dnsLocation": { 78 | "value": "${AZURE_APPSERVICE_LOCATION}" 79 | }, 80 | "tags": { 81 | "value": { 82 | "Owner": "AI Team", 83 | "Project": "GPTBot", 84 | "Environment": "Dev", 85 | "Toolkit": "Bicep" 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /infra/modules/aistudio/aihub.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | 4 | // Dependencies 5 | param aiServicesName string 6 | param storageName string 7 | param keyVaultName string 8 | param searchName string = '' 9 | 10 | // Azure AI configuration 11 | param aiHubName string 12 | param aiProjectName string 13 | 14 | // Other 15 | param tags object = {} 16 | param publicNetworkAccess string 17 | param systemDatastoresAuthMode string 18 | param privateEndpointSubnetId string 19 | param apiPrivateDnsZoneId string 20 | param notebookPrivateDnsZoneId string 21 | param grantAccessTo array 22 | param allowedIpAddresses array = [] 23 | param defaultComputeName string = '' 24 | param deployAIProject bool 25 | 26 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 27 | name: aiServicesName 28 | } 29 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { 30 | name: storageName 31 | } 32 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { 33 | name: keyVaultName 34 | } 35 | resource search 'Microsoft.Search/searchServices@2023-11-01' existing = { 36 | name: searchName 37 | } 38 | 39 | resource aiHub 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' = { 40 | name: aiHubName 41 | location: location 42 | tags: tags 43 | identity: { 44 | type: 'SystemAssigned' 45 | } 46 | properties: { 47 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess 48 | ipAllowlist: allowedIpAddresses 49 | managedNetwork: { 50 | isolationMode: publicNetworkAccess == 'Disabled' ? 'AllowOnlyApprovedOutbound' : 'Disabled' 51 | outboundRules: publicNetworkAccess == 'Disabled' && !empty(search.name) 52 | ? { 53 | 'rule-${search.name}': { 54 | type: 'PrivateEndpoint' 55 | destination: { 56 | serviceResourceId: search.id 57 | subresourceTarget: 'searchService' 58 | } 59 | } 60 | } 61 | : {} 62 | } 63 | friendlyName: aiHubName 64 | keyVault: keyVault.id 65 | storageAccount: storage.id 66 | systemDatastoresAuthMode: systemDatastoresAuthMode 67 | } 68 | kind: 'hub' 69 | 70 | resource aiServicesConnection 'connections@2024-04-01' = { 71 | name: '${aiHubName}-connection-AIServices' 72 | properties: { 73 | category: 'AIServices' 74 | target: aiServices.properties.endpoint 75 | authType: 'AAD' 76 | isSharedToAll: true 77 | metadata: { 78 | ApiType: 'Azure' 79 | ResourceId: aiServices.id 80 | ApiVersion: '2023-07-01-preview' 81 | DeploymentApiVersion: '2023-10-01-preview' 82 | Location: location 83 | } 84 | } 85 | } 86 | 87 | resource searchConnection 'connections@2024-04-01' = if (!empty(searchName)) { 88 | name: '${aiHubName}-connection-Search' 89 | properties: { 90 | category: 'CognitiveSearch' 91 | target: 'https://${search.name}.search.windows.net' 92 | authType: 'AAD' 93 | isSharedToAll: true 94 | } 95 | } 96 | 97 | resource defaultCompute 'computes@2024-04-01-preview' = if (!empty(defaultComputeName)) { 98 | name: defaultComputeName 99 | location: location 100 | tags: tags 101 | properties: { 102 | computeType: 'ComputeInstance' 103 | properties: { 104 | vmSize: 'Standard_DS11_v2' 105 | } 106 | } 107 | } 108 | } 109 | 110 | resource aiProject 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' = if (deployAIProject) { 111 | name: aiProjectName 112 | location: location 113 | tags: tags 114 | identity: { 115 | type: 'SystemAssigned' 116 | } 117 | properties: { 118 | publicNetworkAccess: publicNetworkAccess 119 | hubResourceId: aiHub.id 120 | } 121 | kind: 'Project' 122 | } 123 | 124 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 125 | name: 'pl-${aiHubName}' 126 | location: vnetLocation 127 | tags: tags 128 | properties: { 129 | subnet: { 130 | id: privateEndpointSubnetId 131 | } 132 | privateLinkServiceConnections: [ 133 | { 134 | name: 'private-endpoint-connection' 135 | properties: { 136 | privateLinkServiceId: aiHub.id 137 | groupIds: ['amlworkspace'] 138 | } 139 | } 140 | ] 141 | } 142 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 143 | name: 'zg-${aiHubName}' 144 | properties: { 145 | privateDnsZoneConfigs: [ 146 | { 147 | name: 'api' 148 | properties: { 149 | privateDnsZoneId: apiPrivateDnsZoneId 150 | } 151 | } 152 | { 153 | name: 'notebook' 154 | properties: { 155 | privateDnsZoneId: notebookPrivateDnsZoneId 156 | } 157 | } 158 | ] 159 | } 160 | } 161 | } 162 | 163 | resource aiDeveloper 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 164 | name: '64702f94-c441-49e6-a78b-ef80e0188fee' 165 | } 166 | 167 | resource aiDeveloperAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 168 | for principal in grantAccessTo: if (!empty(principal.id)) { 169 | name: guid(principal.id, aiHub.id, aiDeveloper.id) 170 | scope: aiHub 171 | properties: { 172 | roleDefinitionId: aiDeveloper.id 173 | principalId: principal.id 174 | principalType: principal.type 175 | } 176 | } 177 | ] 178 | 179 | resource aiDeveloperAccessProj 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 180 | for principal in grantAccessTo: if (!empty(principal.id)) { 181 | name: guid(principal.id, aiProject.id, aiDeveloper.id) 182 | scope: aiProject 183 | properties: { 184 | roleDefinitionId: aiDeveloper.id 185 | principalId: principal.id 186 | principalType: principal.type 187 | } 188 | } 189 | ] 190 | 191 | resource cognitiveServicesOpenAIContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 192 | name: 'a001fd3d-188f-4b5d-821b-7da978bf7442' 193 | } 194 | 195 | resource openaiAccessFromProject 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 196 | name: guid(aiProject.id, aiServices.id, cognitiveServicesOpenAIContributor.id) 197 | scope: aiServices 198 | properties: { 199 | roleDefinitionId: cognitiveServicesOpenAIContributor.id 200 | principalId: aiProject.identity.principalId 201 | principalType: 'ServicePrincipal' 202 | } 203 | } 204 | 205 | 206 | output aiHubID string = aiHub.id 207 | output aiHubName string = aiHub.name 208 | output aiProjectID string = aiProject.id 209 | output aiProjectName string = aiProject.name 210 | output aiProjectDiscoveryUrl string = aiProject.properties.discoveryUrl 211 | output aiProjectConnectionString string = '${split(aiProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiProject.name}' 212 | -------------------------------------------------------------------------------- /infra/modules/aistudio/aiservices.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param aiServicesName string 4 | param tags object = {} 5 | param privateEndpointSubnetId string 6 | param publicNetworkAccess string 7 | param openAIPrivateDnsZoneId string 8 | param cognitiveServicesPrivateDnsZoneId string 9 | param grantAccessTo array 10 | param allowedIpAddresses array = [] 11 | param authMode string 12 | 13 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' = { 14 | name: aiServicesName 15 | location: location 16 | sku: { 17 | name: 'S0' 18 | } 19 | identity: { 20 | type: 'SystemAssigned' 21 | } 22 | kind: 'AIServices' 23 | properties: { 24 | disableLocalAuth: authMode == 'accessKey' ? false : true 25 | customSubDomainName: aiServicesName 26 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess 27 | networkAcls: { 28 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny' 29 | ipRules: [ 30 | for ipAddress in allowedIpAddresses: { 31 | value: ipAddress 32 | } 33 | ] 34 | } 35 | } 36 | tags: tags 37 | } 38 | 39 | resource aiPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 40 | name: 'pl-oai-${aiServicesName}' 41 | location: vnetLocation 42 | tags: tags 43 | properties: { 44 | subnet: { 45 | id: privateEndpointSubnetId 46 | } 47 | privateLinkServiceConnections: [ 48 | { 49 | name: 'private-endpoint-connection' 50 | properties: { 51 | privateLinkServiceId: aiServices.id 52 | groupIds: ['account'] 53 | } 54 | } 55 | ] 56 | } 57 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 58 | name: 'zg-${aiServicesName}' 59 | properties: { 60 | privateDnsZoneConfigs: [ 61 | { 62 | name: 'default' 63 | properties: { 64 | privateDnsZoneId: openAIPrivateDnsZoneId 65 | } 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | 72 | resource cognitiveServicesPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 73 | name: 'pl-${aiServicesName}' 74 | location: location 75 | tags: tags 76 | properties: { 77 | subnet: { 78 | id: privateEndpointSubnetId 79 | } 80 | privateLinkServiceConnections: [ 81 | { 82 | name: 'private-endpoint-connection' 83 | properties: { 84 | privateLinkServiceId: aiServices.id 85 | groupIds: ['account'] 86 | } 87 | } 88 | ] 89 | } 90 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 91 | name: 'zg-${aiServicesName}' 92 | properties: { 93 | privateDnsZoneConfigs: [ 94 | { 95 | name: 'default' 96 | properties: { 97 | privateDnsZoneId: cognitiveServicesPrivateDnsZoneId 98 | } 99 | } 100 | ] 101 | } 102 | } 103 | } 104 | 105 | resource cognitiveServicesOpenAIContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 106 | name: 'a001fd3d-188f-4b5d-821b-7da978bf7442' 107 | } 108 | 109 | resource cognitiveServicesUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 110 | name: 'a97b65f3-24c7-4388-baec-2e87135dc908' 111 | } 112 | 113 | resource openaiAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 114 | for principal in grantAccessTo: if (!empty(principal.id)) { 115 | name: guid(principal.id, aiServices.id, cognitiveServicesOpenAIContributor.id) 116 | scope: aiServices 117 | properties: { 118 | roleDefinitionId: cognitiveServicesOpenAIContributor.id 119 | principalId: principal.id 120 | principalType: principal.type 121 | } 122 | } 123 | ] 124 | 125 | resource userAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 126 | for principal in grantAccessTo: if (!empty(principal.id)) { 127 | name: guid(principal.id, aiServices.id, cognitiveServicesUser.id) 128 | scope: aiServices 129 | properties: { 130 | roleDefinitionId: cognitiveServicesUser.id 131 | principalId: principal.id 132 | principalType: principal.type 133 | } 134 | } 135 | ] 136 | 137 | output aiServicesID string = aiServices.id 138 | output aiServicesName string = aiServices.name 139 | output aiServicesEndpoint string = aiServices.properties.endpoint 140 | output aiServicesPrincipalId string = aiServices.identity.principalId 141 | -------------------------------------------------------------------------------- /infra/modules/aistudio/dns.bicep: -------------------------------------------------------------------------------- 1 | param vnetName string 2 | param vnetId string 3 | param tags object = {} 4 | 5 | param dnsZones array 6 | 7 | resource privateDnsZones 'Microsoft.Network/privateDnsZones@2020-06-01' = [ 8 | for zone in dnsZones: { 9 | name: zone 10 | location: 'global' 11 | tags: tags 12 | properties: {} 13 | } 14 | ] 15 | 16 | resource virtualNetworkLinks 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = [ 17 | for zone in dnsZones: { 18 | name: '${zone}/${vnetName}-link' 19 | location: 'global' 20 | tags: tags 21 | properties: { 22 | registrationEnabled: false 23 | virtualNetwork: { 24 | id: vnetId 25 | } 26 | } 27 | dependsOn: [ 28 | privateDnsZones 29 | ] 30 | } 31 | ] 32 | 33 | output dnsZoneNames string[] = [for zone in dnsZones: zone] 34 | output dnsZoneIds string[] = [for (zone, index) in dnsZones: privateDnsZones[index].id] 35 | -------------------------------------------------------------------------------- /infra/modules/aistudio/keyVault.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param keyVaultName string 4 | param tags object = {} 5 | param publicNetworkAccess string 6 | param privateEndpointSubnetId string 7 | param privateDnsZoneId string 8 | param grantAccessTo array 9 | param allowedIpAddresses array = [] 10 | 11 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { 12 | name: keyVaultName 13 | location: location 14 | tags: tags 15 | properties: { 16 | createMode: 'default' 17 | enabledForDeployment: false 18 | enabledForDiskEncryption: false 19 | enabledForTemplateDeployment: false 20 | enableSoftDelete: true 21 | enableRbacAuthorization: true 22 | publicNetworkAccess: empty(allowedIpAddresses) ? publicNetworkAccess : 'Enabled' 23 | networkAcls: { 24 | bypass: 'AzureServices' 25 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny' 26 | virtualNetworkRules: [] 27 | ipRules: [ 28 | for ipAddress in allowedIpAddresses: { 29 | value: ipAddress 30 | } 31 | ] 32 | } 33 | sku: { 34 | family: 'A' 35 | name: 'standard' 36 | } 37 | tenantId: subscription().tenantId 38 | } 39 | } 40 | 41 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 42 | name: 'pl-${keyVaultName}' 43 | location: vnetLocation 44 | tags: tags 45 | properties: { 46 | subnet: { 47 | id: privateEndpointSubnetId 48 | } 49 | privateLinkServiceConnections: [ 50 | { 51 | name: 'private-endpoint-connection' 52 | properties: { 53 | privateLinkServiceId: keyVault.id 54 | groupIds: ['vault'] 55 | } 56 | } 57 | ] 58 | } 59 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 60 | name: 'zg-${keyVaultName}' 61 | properties: { 62 | privateDnsZoneConfigs: [ 63 | { 64 | name: 'default' 65 | properties: { 66 | privateDnsZoneId: privateDnsZoneId 67 | } 68 | } 69 | ] 70 | } 71 | } 72 | } 73 | 74 | resource secretsUser 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 75 | name: '4633458b-17de-408a-b874-0445c86b69e6' 76 | } 77 | 78 | resource secretsUserAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 79 | for principal in grantAccessTo: if (!empty(principal.id)) { 80 | name: guid(principal.id, keyVault.id, secretsUser.id) 81 | scope: keyVault 82 | properties: { 83 | roleDefinitionId: secretsUser.id 84 | principalId: principal.id 85 | principalType: principal.type 86 | } 87 | } 88 | ] 89 | 90 | 91 | output keyVaultID string = keyVault.id 92 | output keyVaultName string = keyVault.name 93 | output keyVaultEndpoint string = keyVault.properties.vaultUri 94 | -------------------------------------------------------------------------------- /infra/modules/aistudio/network.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetName string 3 | param vnetAddressPrefixes array 4 | param privateEndpointSubnetName string 5 | param privateEndpointSubnetAddressPrefix string 6 | param appSubnetName string 7 | param appSubnetAddressPrefix string 8 | param tags object = {} 9 | 10 | resource vnet 'Microsoft.Network/virtualNetworks@2020-11-01' = { 11 | name: vnetName 12 | location: location 13 | tags: tags 14 | properties: { 15 | addressSpace: { 16 | addressPrefixes: vnetAddressPrefixes 17 | } 18 | subnets: [ 19 | { 20 | name: privateEndpointSubnetName 21 | properties: { 22 | addressPrefix: privateEndpointSubnetAddressPrefix 23 | } 24 | } 25 | { 26 | name: appSubnetName 27 | properties: { 28 | addressPrefix: appSubnetAddressPrefix 29 | delegations: [ 30 | { 31 | name: 'default' 32 | properties: { 33 | serviceName: 'Microsoft.Web/serverFarms' 34 | } 35 | } 36 | ] 37 | } 38 | } 39 | ] 40 | } 41 | } 42 | 43 | output vnetId string = vnet.id 44 | output vnetName string = vnet.name 45 | output privateEndpointSubnetId string = vnet.properties.subnets[0].id 46 | output appSubnetId string = vnet.properties.subnets[1].id 47 | -------------------------------------------------------------------------------- /infra/modules/aistudio/searchService.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param searchName string 4 | param tags object = {} 5 | param publicNetworkAccess string 6 | param privateEndpointSubnetId string 7 | param privateDnsZoneId string 8 | param allowedIpAddresses array = [] 9 | 10 | resource search 'Microsoft.Search/searchServices@2024-06-01-preview' = { 11 | name: searchName 12 | location: location 13 | tags: tags 14 | sku: { 15 | name: 'standard' 16 | } 17 | identity: { 18 | type: 'SystemAssigned' 19 | } 20 | properties: { 21 | networkRuleSet: { 22 | bypass: 'AzureServices' 23 | ipRules: [ 24 | for ipAddress in allowedIpAddresses: { 25 | value: ipAddress 26 | } 27 | ] 28 | } 29 | disableLocalAuth: true 30 | replicaCount: 1 31 | partitionCount: 1 32 | hostingMode: 'default' 33 | publicNetworkAccess: publicNetworkAccess 34 | } 35 | } 36 | 37 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 38 | name: 'pl-${searchName}' 39 | location: vnetLocation 40 | tags: tags 41 | properties: { 42 | subnet: { 43 | id: privateEndpointSubnetId 44 | } 45 | privateLinkServiceConnections: [ 46 | { 47 | name: 'private-endpoint-connection' 48 | properties: { 49 | privateLinkServiceId: search.id 50 | groupIds: ['searchService'] 51 | } 52 | } 53 | ] 54 | } 55 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 56 | name: 'zg-${searchName}' 57 | properties: { 58 | privateDnsZoneConfigs: [ 59 | { 60 | name: 'default' 61 | properties: { 62 | privateDnsZoneId: privateDnsZoneId 63 | } 64 | } 65 | ] 66 | } 67 | } 68 | } 69 | 70 | output searchID string = search.id 71 | output searchPrincipalId string = search.identity.principalId 72 | output searchName string = search.name 73 | -------------------------------------------------------------------------------- /infra/modules/aistudio/sharedPrivateLinks.bicep: -------------------------------------------------------------------------------- 1 | param searchName string 2 | 3 | param storageName string 4 | param aiServicesName string 5 | param grantAccessTo array 6 | 7 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 8 | name: aiServicesName 9 | } 10 | 11 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { 12 | name: storageName 13 | } 14 | 15 | resource search 'Microsoft.Search/searchServices@2024-06-01-preview' existing = { 16 | name: searchName 17 | 18 | resource linkToStorage 'sharedPrivateLinkResources' = { 19 | name: 'link-to-storage-account' 20 | properties: { 21 | groupId: 'blob' 22 | privateLinkResourceId: storage.id 23 | requestMessage: 'Requested Private Endpoint Connection from Search Service ${searchName}' 24 | } 25 | } 26 | resource linkToAI 'sharedPrivateLinkResources' = { 27 | name: 'link-to-ai-service' 28 | properties: { 29 | groupId: 'openai_account' 30 | privateLinkResourceId: aiServices.id 31 | requestMessage: 'Requested Private Endpoint Connection from Search Service ${searchName}' 32 | } 33 | dependsOn: [ 34 | linkToStorage 35 | ] 36 | } 37 | } 38 | 39 | resource searchServiceContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 40 | name: '7ca78c08-252a-4471-8644-bb5ff32d4ba0' 41 | } 42 | 43 | resource searchIndexDataContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 44 | name: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' 45 | } 46 | 47 | resource serviceAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 48 | for principal in grantAccessTo: if (!empty(principal.id)) { 49 | name: guid(principal.id, search.id, searchServiceContributor.id) 50 | scope: search 51 | properties: { 52 | roleDefinitionId: searchServiceContributor.id 53 | principalId: principal.id 54 | principalType: principal.type 55 | } 56 | } 57 | ] 58 | 59 | resource indexAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 60 | for principal in grantAccessTo: if (!empty(principal.id)) { 61 | name: guid(principal.id, search.id, searchIndexDataContributor.id) 62 | scope: search 63 | properties: { 64 | roleDefinitionId: searchIndexDataContributor.id 65 | principalId: principal.id 66 | principalType: principal.type 67 | } 68 | } 69 | ] 70 | -------------------------------------------------------------------------------- /infra/modules/aistudio/storage.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param storageName string 4 | param tags object = {} 5 | param publicNetworkAccess string 6 | param authMode string 7 | param privateEndpointSubnetId string 8 | param privateDnsZoneId string 9 | param grantAccessTo array = [] 10 | 11 | resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { 12 | name: storageName 13 | location: location 14 | tags: tags 15 | sku: { 16 | name: 'Standard_LRS' 17 | } 18 | kind: 'StorageV2' 19 | properties: { 20 | isLocalUserEnabled: authMode == 'accessKey' 21 | allowSharedKeyAccess: authMode == 'accessKey' 22 | accessTier: 'Hot' 23 | encryption: { 24 | keySource: 'Microsoft.Storage' 25 | services: { 26 | blob: { 27 | enabled: true 28 | keyType: 'Account' 29 | } 30 | file: { 31 | enabled: true 32 | keyType: 'Account' 33 | } 34 | } 35 | } 36 | minimumTlsVersion: 'TLS1_2' 37 | networkAcls: { 38 | bypass: 'AzureServices' 39 | defaultAction: publicNetworkAccess == 'Enabled' ? 'Allow' : 'Deny' 40 | } 41 | supportsHttpsTrafficOnly: true 42 | } 43 | } 44 | 45 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 46 | name: 'pl-${storageName}' 47 | location: vnetLocation 48 | tags: tags 49 | properties: { 50 | subnet: { 51 | id: privateEndpointSubnetId 52 | } 53 | privateLinkServiceConnections: [ 54 | { 55 | name: 'private-endpoint-connection' 56 | properties: { 57 | privateLinkServiceId: storage.id 58 | groupIds: ['blob'] 59 | } 60 | } 61 | ] 62 | } 63 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 64 | name: 'zg-${storageName}' 65 | properties: { 66 | privateDnsZoneConfigs: [ 67 | { 68 | name: 'default' 69 | properties: { 70 | privateDnsZoneId: privateDnsZoneId 71 | } 72 | } 73 | ] 74 | } 75 | } 76 | } 77 | resource storageBlobDataContributor 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 78 | name: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' 79 | } 80 | 81 | resource writerAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 82 | for principal in grantAccessTo: if (!empty(principal.id)) { 83 | name: guid(principal.id, storage.id, storageBlobDataContributor.id) 84 | scope: storage 85 | properties: { 86 | roleDefinitionId: storageBlobDataContributor.id 87 | principalId: principal.id 88 | principalType: principal.type 89 | } 90 | } 91 | ] 92 | 93 | output storageID string = storage.id 94 | output storageName string = storage.name 95 | -------------------------------------------------------------------------------- /infra/modules/appinsights.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param logAnalyticsWorkspaceName string 3 | param appInsightsName string 4 | param sku string = 'PerGB2018' 5 | param kind string = 'web' 6 | param tags object = {} 7 | param publicNetworkAccess string 8 | param authMode string 9 | 10 | 11 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = { 12 | name: logAnalyticsWorkspaceName 13 | location: location 14 | properties: { 15 | sku: { 16 | name: sku 17 | } 18 | retentionInDays: 30 19 | publicNetworkAccessForIngestion: publicNetworkAccess 20 | publicNetworkAccessForQuery: publicNetworkAccess 21 | } 22 | } 23 | 24 | resource appInsights 'Microsoft.Insights/components@2020-02-02' = { 25 | name: appInsightsName 26 | location: location 27 | tags: tags 28 | kind: kind 29 | properties: { 30 | DisableLocalAuth: authMode == 'accessKey' ? false : true 31 | Application_Type: kind 32 | WorkspaceResourceId: logAnalyticsWorkspace.id 33 | Flow_Type: 'Bluefield' 34 | } 35 | } 36 | 37 | output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id 38 | output appInsightsId string = appInsights.id 39 | output appInsightsInstrumentationKey string = appInsights.properties.InstrumentationKey 40 | -------------------------------------------------------------------------------- /infra/modules/appservice.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param appServicePlanName string 4 | param appServiceName string 5 | param msiID string 6 | param msiClientID string 7 | param sku string = 'P0v3' 8 | param tags object = {} 9 | param deploymentName string 10 | 11 | param aiServicesName string 12 | param bingName string 13 | param cosmosName string 14 | param aiHubName string 15 | param aiProjectName string 16 | param keyVaultName string 17 | 18 | param authMode string 19 | param publicNetworkAccess string 20 | param privateEndpointSubnetId string 21 | param appSubnetId string 22 | param privateDnsZoneId string 23 | param allowedIpAddresses array = [] 24 | 25 | var allowedIpRestrictions = [ 26 | for allowedIpAddressesArray in allowedIpAddresses: { 27 | ipAddress: '${allowedIpAddressesArray}/32' 28 | action: 'Allow' 29 | priority: 300 30 | } 31 | ] 32 | 33 | var ipSecurityRestrictions = concat(allowedIpRestrictions, [ 34 | { action: 'Allow', ipAddress: 'AzureBotService', priority: 100, tag: 'ServiceTag' } 35 | // Allow Teams Messaging IPs 36 | { action: 'Allow', ipAddress: '13.107.64.0/18', priority: 200 } 37 | { action: 'Allow', ipAddress: '52.112.0.0/14', priority: 201 } 38 | { action: 'Allow', ipAddress: '52.120.0.0/14', priority: 202 } 39 | { action: 'Allow', ipAddress: '52.238.119.141/32', priority: 203 } 40 | ]) 41 | 42 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 43 | name: aiServicesName 44 | } 45 | 46 | resource aiProject 'Microsoft.MachineLearningServices/workspaces@2024-04-01-preview' existing = { 47 | name: aiProjectName 48 | } 49 | 50 | resource bingAccount 'Microsoft.Bing/accounts@2020-06-10' existing = { 51 | name: bingName 52 | } 53 | 54 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' existing = { 55 | name: cosmosName 56 | } 57 | 58 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { 59 | name: keyVaultName 60 | } 61 | 62 | resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { 63 | name: appServicePlanName 64 | location: location 65 | tags: tags 66 | sku: { 67 | name: sku 68 | capacity: 1 69 | } 70 | properties: { 71 | reserved: true 72 | } 73 | kind: 'linux' 74 | } 75 | 76 | resource backend 'Microsoft.Web/sites@2023-12-01' = { 77 | name: appServiceName 78 | location: location 79 | tags: union(tags, { 'azd-service-name': 'azure-agents-app' }) 80 | identity: { 81 | type: 'UserAssigned' 82 | userAssignedIdentities: { 83 | '${msiID}': {} 84 | } 85 | } 86 | properties: { 87 | serverFarmId: appServicePlan.id 88 | httpsOnly: true 89 | virtualNetworkSubnetId: !empty(appSubnetId) ? appSubnetId : null 90 | keyVaultReferenceIdentity: msiID 91 | vnetRouteAllEnabled: true 92 | siteConfig: { 93 | keyVaultReferenceIdentity: msiID 94 | vnetRouteAllEnabled: true 95 | httpLoggingEnabled: true 96 | logsDirectorySizeLimit: 35 97 | ipSecurityRestrictions: ipSecurityRestrictions 98 | publicNetworkAccess: 'Enabled' 99 | ipSecurityRestrictionsDefaultAction: 'Allow' 100 | scmIpSecurityRestrictionsDefaultAction: 'Allow' 101 | http20Enabled: true 102 | linuxFxVersion: 'PYTHON|3.10' 103 | webSocketsEnabled: true 104 | appCommandLine: 'gunicorn app:app' 105 | alwaysOn: true 106 | appSettings: [ 107 | { 108 | name: 'MicrosoftAppType' 109 | value: 'UserAssignedMSI' 110 | } 111 | { 112 | name: 'MicrosoftAppId' 113 | value: msiClientID 114 | } 115 | { 116 | name: 'MicrosoftAppTenantId' 117 | value: tenant().tenantId 118 | } 119 | { 120 | name: 'AZURE_AI_PROJECT_CONNECTION_STRING' 121 | value: '${split(aiProject.properties.discoveryUrl, '/')[2]};${subscription().subscriptionId};${resourceGroup().name};${aiProjectName}' 122 | } 123 | { 124 | name: 'SSO_ENABLED' 125 | value: 'false' 126 | } 127 | { 128 | name: 'SSO_CONFIG_NAME' 129 | value: '' 130 | } 131 | { 132 | name: 'SSO_MESSAGE_TITLE' 133 | value: 'Please sign in to continue.' 134 | } 135 | { 136 | name: 'SSO_MESSAGE_PROMPT' 137 | value: 'Sign in' 138 | } 139 | { 140 | name: 'SSO_MESSAGE_SUCCESS' 141 | value: 'User logged in successfully! Please repeat your question.' 142 | } 143 | { 144 | name: 'SSO_MESSAGE_FAILED' 145 | value: 'Log in failed. Type anything to retry.' 146 | } 147 | { 148 | name: 'AZURE_OPENAI_API_ENDPOINT' 149 | value: aiServices.properties.endpoint 150 | } 151 | { 152 | name: 'AZURE_OPENAI_API_VERSION' 153 | value: '2024-05-01-preview' 154 | } 155 | { 156 | name: 'AZURE_OPENAI_DEPLOYMENT_NAME' 157 | value: deploymentName 158 | } 159 | { 160 | name: 'AZURE_OPENAI_ASSISTANT_ID' 161 | value: 'YOUR_ASSISTANT_ID' 162 | } 163 | { 164 | name: 'AZURE_OPENAI_STREAMING' 165 | value: 'true' 166 | } 167 | { 168 | name: 'AZURE_OPENAI_API_KEY' 169 | value: authMode == 'accessKey' ? aiServices.listKeys().key1 : '' 170 | } 171 | { 172 | name: 'AZURE_BING_API_ENDPOINT' 173 | value: 'https://api.bing.microsoft.com/' 174 | } 175 | { 176 | name: 'AZURE_BING_API_KEY' 177 | value: bingAccount.listKeys().key1 178 | } 179 | { 180 | name: 'AZURE_COSMOSDB_ENDPOINT' 181 | value: cosmos.properties.documentEndpoint 182 | } 183 | { 184 | name: 'AZURE_COSMOSDB_DATABASE_ID' 185 | value: 'GenAIBot' 186 | } 187 | { 188 | name: 'AZURE_COSMOSDB_CONTAINER_ID' 189 | value: 'Conversations' 190 | } 191 | { 192 | name: 'AZURE_COSMOS_AUTH_KEY' 193 | value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=AZURE-COSMOS-AUTH-KEY)' 194 | } 195 | { 196 | name: 'AZURE_DIRECT_LINE_SECRET' 197 | value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=AZURE-DIRECT-LINE-SECRET)' 198 | } 199 | { 200 | name: 'MAX_TURNS' 201 | value: '10' 202 | } 203 | { 204 | name: 'LLM_WELCOME_MESSAGE' 205 | value: 'Welcome to the Travel Agent Sample! You can use this chat to:
 - Get recommendations of places to visit and things to do;
 - Upload your travel bookings and generate an itinerary;
 - Upload pictures of signs, menus and more to get information about them;
 - Ask for help with budgeting a trip;
 - And more!
To upload files, use the attachment button on the left, attach a file and hit enter to upload. You will get confirmation when the file is ready to use. You will also be prompted whether you\'d like to add it to Code Interpreter or File Search. Use Code Interpreter for mathematical operations, and File Search to use the file contents as context for your question. You may skip this step for images.' 206 | } 207 | { 208 | name: 'LLM_INSTRUCTIONS' 209 | value: 'You are a helpful travel agent who can assist with many travel-related inquiries, including:\n- Reading travel documents and putting together itineraries\n- Viewing and interpreting pictures that may be in different languages\n- Locating landmarks and suggesting places to visit\n- Budgeting and graphing cost information\n- Looking up information on the web for up to date information\nYou should do your best to respond to travel-related questions, but politely decline to help with unrelated questions.\nAny time the information you want to provide requires up-to-date sources - for example, hotels, restaurants and more - you should use the Bing Search tool and provide sources.' 210 | } 211 | { 212 | name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' 213 | value: 'true' 214 | } 215 | { 216 | name: 'ENABLE_ORYX_BUILD' 217 | value: 'true' 218 | } 219 | { 220 | name: 'DEBUG' 221 | value: 'true' 222 | } 223 | { 224 | name:'AZURE_KEY_VAULT_ENDPOINT' 225 | value: keyVault.properties.vaultUri 226 | } 227 | ] 228 | } 229 | } 230 | } 231 | 232 | // resource backendAppPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 233 | // name: 'pl-${appServiceName}' 234 | // location: location 235 | // tags: tags 236 | // properties: { 237 | // subnet: { 238 | // id: privateEndpointSubnetId 239 | // } 240 | // privateLinkServiceConnections: [ 241 | // { 242 | // name: 'private-endpoint-connection' 243 | // properties: { 244 | // privateLinkServiceId: backend.id 245 | // groupIds: ['sites'] 246 | // } 247 | // } 248 | // ] 249 | // } 250 | // resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 251 | // name: 'zg-${appServiceName}' 252 | // properties: { 253 | // privateDnsZoneConfigs: [ 254 | // { 255 | // name: 'default' 256 | // properties: { 257 | // privateDnsZoneId: privateDnsZoneId 258 | // } 259 | // } 260 | // ] 261 | // } 262 | // } 263 | // } 264 | 265 | output backendAppName string = backend.name 266 | output backendHostName string = backend.properties.defaultHostName 267 | -------------------------------------------------------------------------------- /infra/modules/bing.bicep: -------------------------------------------------------------------------------- 1 | param bingName string 2 | param keyVaultName string 3 | param tags object = {} 4 | // param privateEndpointSubnetId string 5 | // param publicNetworkAccess string 6 | // param bingPrivateDnsZoneId string 7 | // param grantAccessTo array 8 | // param allowedIpAddresses array = [] 9 | // param authMode string 10 | 11 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { 12 | name: keyVaultName 13 | 14 | resource secret 'secrets' = { 15 | name: 'BING-API-KEY' 16 | properties: { 17 | value: bing.listKeys().key1 18 | } 19 | } 20 | } 21 | resource bing 'Microsoft.Bing/accounts@2020-06-10' = { 22 | name: bingName 23 | location: 'global' 24 | tags: tags 25 | sku: { 26 | name: 'S1' 27 | } 28 | kind: 'Bing.Search.v7' 29 | } 30 | 31 | output bingID string = bing.id 32 | output bingName string = bing.name 33 | output bingApiEndpoint string = bing.properties.endpoint 34 | -------------------------------------------------------------------------------- /infra/modules/botservice.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param botServiceName string 3 | param keyVaultName string 4 | param endpoint string 5 | param msiID string 6 | param msiClientID string 7 | param sku string = 'F0' 8 | param kind string = 'azurebot' 9 | param tags object = {} 10 | param publicNetworkAccess string 11 | 12 | 13 | resource botservice 'Microsoft.BotService/botServices@2022-09-15' = { 14 | name: botServiceName 15 | location: location 16 | tags: tags 17 | sku: { 18 | name: sku 19 | } 20 | kind: kind 21 | properties: { 22 | displayName: botServiceName 23 | endpoint: endpoint 24 | msaAppMSIResourceId: msiID 25 | msaAppId: msiClientID 26 | msaAppType: 'UserAssignedMSI' 27 | msaAppTenantId: tenant().tenantId 28 | publicNetworkAccess: 'Enabled' 29 | disableLocalAuth: true 30 | } 31 | 32 | resource directline 'channels' = { 33 | name: 'DirectLineChannel' 34 | properties: { 35 | channelName: 'DirectLineChannel' 36 | } 37 | } 38 | } 39 | 40 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { 41 | name: keyVaultName 42 | 43 | resource secret 'secrets' = { 44 | name: 'AZURE-DIRECT-LINE-SECRET' 45 | properties: { 46 | value: botservice::directline.listChannelWithKeys().setting.sites[0].key 47 | } 48 | } 49 | } 50 | 51 | output name string = botservice.name 52 | -------------------------------------------------------------------------------- /infra/modules/cosmos.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param vnetLocation string = location 3 | param cosmosName string 4 | param keyVaultName string 5 | param tags object = {} 6 | param publicNetworkAccess string 7 | param privateEndpointSubnetId string 8 | param privateDnsZoneId string 9 | param grantAccessTo array = [] 10 | param allowedIpAddresses array = [] 11 | param authMode string 12 | 13 | resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { 14 | name: cosmosName 15 | location: location 16 | tags: tags 17 | kind: 'GlobalDocumentDB' 18 | properties: { 19 | locations: [ 20 | { 21 | locationName: location 22 | failoverPriority: 0 23 | isZoneRedundant: false 24 | } 25 | ] 26 | databaseAccountOfferType: 'Standard' 27 | publicNetworkAccess: !empty(allowedIpAddresses) ? 'Enabled' : publicNetworkAccess 28 | networkAclBypass: 'AzureServices' 29 | ipRules: [ 30 | for ipAddress in allowedIpAddresses: { 31 | ipAddressOrRange: ipAddress 32 | } 33 | ] 34 | disableLocalAuth: authMode == 'accessKey' ? false : true 35 | } 36 | 37 | resource db 'sqlDatabases' = { 38 | name: 'GenAIBot' 39 | properties: { 40 | resource: { 41 | id: 'GenAIBot' 42 | } 43 | } 44 | 45 | resource col 'containers' = { 46 | name: 'Conversations' 47 | properties: { 48 | resource: { 49 | id: 'Conversations' 50 | partitionKey: { 51 | paths: ['/id'] 52 | kind: 'Hash' 53 | } 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = if (publicNetworkAccess == 'Disabled') { 61 | name: 'pl-${cosmosName}' 62 | location: vnetLocation 63 | tags: tags 64 | properties: { 65 | subnet: { 66 | id: privateEndpointSubnetId 67 | } 68 | privateLinkServiceConnections: [ 69 | { 70 | name: 'private-endpoint-connection' 71 | properties: { 72 | privateLinkServiceId: cosmos.id 73 | groupIds: ['Sql'] 74 | } 75 | } 76 | ] 77 | } 78 | resource privateDnsZoneGroup 'privateDnsZoneGroups' = { 79 | name: 'zg-${cosmosName}' 80 | properties: { 81 | privateDnsZoneConfigs: [ 82 | { 83 | name: 'default' 84 | properties: { 85 | privateDnsZoneId: privateDnsZoneId 86 | } 87 | } 88 | ] 89 | } 90 | } 91 | } 92 | 93 | resource cosmosAccountReader 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { 94 | name: 'fbdf93bf-df7d-467e-a4d2-9458aa1360c8' 95 | } 96 | resource cosmosDataContributor 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2021-10-15' existing = { 97 | name: '00000000-0000-0000-0000-000000000002' 98 | parent: cosmos 99 | } 100 | 101 | resource accountReaderAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = [ 102 | for principal in grantAccessTo: if (!empty(principal.id)) { 103 | name: guid(principal.id, cosmos.id, cosmosAccountReader.id) 104 | scope: cosmos 105 | properties: { 106 | roleDefinitionId: cosmosAccountReader.id 107 | principalId: principal.id 108 | principalType: principal.type 109 | } 110 | } 111 | ] 112 | 113 | resource writerAccess 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = [ 114 | for principal in grantAccessTo: if (!empty(principal.id)) { 115 | name: guid(principal.id, cosmos.id, cosmosDataContributor.id) 116 | parent: cosmos 117 | properties: { 118 | roleDefinitionId: cosmosDataContributor.id 119 | principalId: principal.id 120 | scope: cosmos.id 121 | } 122 | } 123 | ] 124 | 125 | resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { 126 | name: keyVaultName 127 | 128 | resource secret 'secrets' = { 129 | name: 'AZURE-COSMOS-AUTH-KEY' 130 | properties: { 131 | value: cosmos.listKeys().primaryMasterKey 132 | } 133 | } 134 | } 135 | 136 | output cosmosID string = cosmos.id 137 | output cosmosName string = cosmos.name 138 | output cosmosEndpoint string = cosmos.properties.documentEndpoint 139 | -------------------------------------------------------------------------------- /infra/modules/gptDeployment.bicep: -------------------------------------------------------------------------------- 1 | param aiServicesName string 2 | param modelName string 3 | param modelVersion string 4 | param modelCapacity int = 10 5 | 6 | resource aiServices 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { 7 | name: aiServicesName 8 | 9 | resource gptdeployment 'deployments' = if (startsWith(modelName, 'gpt')) { 10 | name: modelName 11 | properties: { 12 | model: { 13 | format: 'OpenAI' 14 | name: modelName 15 | version: modelVersion 16 | } 17 | } 18 | sku: { 19 | capacity: modelCapacity 20 | name: 'Standard' 21 | } 22 | } 23 | } 24 | 25 | output modelName string = aiServices::gptdeployment.name 26 | output modelVersion string = aiServices::gptdeployment.properties.model.version 27 | -------------------------------------------------------------------------------- /infra/modules/msi.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param msiName string 3 | param tags object = {} 4 | 5 | resource msi 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 6 | name: msiName 7 | location: location 8 | tags: tags 9 | } 10 | 11 | output msiID string = msi.id 12 | output msiName string = msi.name 13 | output msiClientID string = msi.properties.clientId 14 | output msiPrincipalID string = msi.properties.principalId 15 | -------------------------------------------------------------------------------- /media/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/media/architecture.png -------------------------------------------------------------------------------- /scripts/setupSso.ps1: -------------------------------------------------------------------------------- 1 | Write-Host "Loading azd .env file from current environment..." 2 | 3 | $envValues = azd env get-values 4 | $envValues.Split("`n") | ForEach-Object { 5 | $key, $value = $_.Split('=') 6 | $value = $value.Trim('"') 7 | Set-Variable -Name $key -Value $value -Scope Global 8 | } 9 | 10 | if ($ENABLE_AUTH -ne "true") { 11 | return 12 | } 13 | 14 | # If App Registration was not created, create it 15 | if ($CLIENT_ID -eq $null) { 16 | Write-Host "Creating app registration..." 17 | $APP = (az ad app create --display-name $BACKEND_APP_NAME --web-redirect-uris "https://$BACKEND_APP_NAME.azurewebsites.net/.auth/login/aad/callback" "https://token.botframework.com/.auth/web/redirect" --enable-id-token-issuance --required-resource-accesses '[{ 18 | \"resourceAppId\": \"00000003-0000-0000-c000-000000000000\", 19 | \"resourceAccess\": [ 20 | { 21 | \"id\": \"37f7f235-527c-4136-accd-4a02d197296e\", 22 | \"type\": \"Scope\" 23 | } 24 | ] 25 | }]' | ConvertFrom-Json) 26 | $APP_ID = $APP.id 27 | $CLIENT_ID = $APP.appId 28 | } 29 | 30 | # If a federated identity doesn't exist, create it 31 | 32 | $uuid_no_hyphens = $AZURE_TENANT_ID -replace "-", "" 33 | $uuid_reordered = $uuid_no_hyphens.Substring(6, 2) + $uuid_no_hyphens.Substring(4, 2) + $uuid_no_hyphens.Substring(2, 2) + $uuid_no_hyphens.Substring(0, 2) + 34 | $uuid_no_hyphens.Substring(10, 2) + $uuid_no_hyphens.Substring(8, 2) + 35 | $uuid_no_hyphens.Substring(14, 2) + $uuid_no_hyphens.Substring(12, 2) + 36 | $uuid_no_hyphens.Substring(16, 16) 37 | $uuid_binary = [System.Convert]::FromHexString($uuid_reordered) 38 | $B64_AZURE_TENANT_ID = [Convert]::ToBase64String($uuid_binary) -replace '\+', '-' -replace '/', '_' -replace '=', '' 39 | 40 | $FED_ID = (az ad app federated-credential list --id $CLIENT_ID | ConvertFrom-Json)[0].id 41 | Write-Host "Federated identity: $FED_ID" 42 | echo "{ 43 | `"audiences`": [`"api://AzureADTokenExchange`"], 44 | `"description`": `"`", 45 | `"issuer`": `"https://login.microsoftonline.com/$AZURE_TENANT_ID/v2.0`", 46 | `"name`": `"default`", 47 | `"subject`": `"/eid1/c/pub/t/$B64_AZURE_TENANT_ID/a/9ExAW52n_ky4ZiS_jhpJIQ/$MSI_PRINCIPAL_ID`" 48 | }" | Out-File tmp.json 49 | if ($FED_ID -eq $null) { 50 | Write-Host "Creating federated identity..." 51 | az ad app federated-credential create --id $CLIENT_ID --parameters tmp.json | Out-Null 52 | } else { 53 | Write-Host "Federated identity already exists. Skipping..." 54 | } 55 | rm tmp.json 56 | 57 | # Set up authorization for the app 58 | az webapp auth config-version upgrade -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME 59 | az webapp auth update -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME --enabled $true --action RedirectToLoginPage --redirect-provider azureactivedirectory --excluded-paths "[/api/messages]" 60 | az webapp auth microsoft update -g $AZURE_RESOURCE_GROUP_NAME -n $BACKEND_APP_NAME --allowed-token-audiences "https://$BACKEND_APP_NAME.azurewebsites.net/.auth/login/aad/callback" --client-id $CLIENT_ID --issuer "https://sts.windows.net/$AZURE_TENANT_ID/" -------------------------------------------------------------------------------- /scripts/setupSso.sh: -------------------------------------------------------------------------------- 1 | echo "Loading azd .env file from current environment..." 2 | 3 | while IFS='=' read -r key value; do 4 | value=$(echo "$value" | sed 's/^"//' | sed 's/"$//') 5 | export "$key=$value" 6 | done < web.Application: 43 | app = web.Application(middlewares=[aiohttp_error_middleware]) 44 | app.add_routes(messages_routes(adapter, bot)) 45 | app.add_routes(directline_routes(secret_client)) 46 | app.add_routes(file_routes(agents_client)) 47 | app.add_routes(static_routes()) 48 | return app 49 | 50 | config = DefaultConfig() 51 | 52 | # Create adapter. 53 | # See https://aka.ms/about-bot-adapter to learn more about how bots work. 54 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) 55 | 56 | # Set up service authentication 57 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId")) 58 | 59 | # Key Vault 60 | secret_client = SecretClient(vault_url=os.getenv("AZURE_KEY_VAULT_ENDPOINT"), credential=credential) 61 | 62 | # Azure AI Services 63 | aoai_client = AzureOpenAI( 64 | api_version=os.getenv("AZURE_OPENAI_API_VERSION"), 65 | azure_endpoint=os.getenv("AZURE_OPENAI_API_ENDPOINT"), 66 | api_key=os.getenv("AZURE_OPENAI_API_KEY"), 67 | azure_ad_token_provider=get_bearer_token_provider( 68 | credential, 69 | "https://cognitiveservices.azure.com/.default" 70 | ) 71 | ) 72 | 73 | project_client = AIProjectClient.from_connection_string( 74 | credential=credential, 75 | conn_str=os.getenv("AZURE_AI_PROJECT_CONNECTION_STRING") 76 | ) 77 | agents_client = project_client.agents 78 | 79 | bing_client = BingClient(os.getenv("AZURE_BING_API_KEY")) 80 | graph_client = GraphClient() 81 | 82 | # Conversation history storage 83 | storage = None 84 | if os.getenv("AZURE_COSMOSDB_ENDPOINT"): 85 | auth_key=os.getenv("AZURE_COSMOSDB_AUTH_KEY", secret_client.get_secret("AZURE-COSMOS-AUTH-KEY").value) 86 | storage = CosmosDbPartitionedStorage( 87 | CosmosDbPartitionedConfig( 88 | cosmos_db_endpoint=os.getenv("AZURE_COSMOSDB_ENDPOINT"), 89 | database_id=os.getenv("AZURE_COSMOSDB_DATABASE_ID"), 90 | container_id=os.getenv("AZURE_COSMOSDB_CONTAINER_ID"), 91 | credential=credential, 92 | ) 93 | ) 94 | else: 95 | storage = MemoryStorage() 96 | 97 | # Create conversation and user state 98 | user_state = UserState(storage) 99 | conversation_state = ConversationState(storage) 100 | 101 | dialog = LoginDialog() 102 | 103 | assistant_id = create_or_update_agent(agents_client, os.getenv("AZURE_OPENAI_ASSISTANT_NAME")) 104 | 105 | # Create the bot 106 | bot = AssistantBot( 107 | conversation_state, user_state, 108 | aoai_client, 109 | agents_client, 110 | assistant_id, 111 | bing_client, 112 | graph_client, 113 | dialog 114 | ) 115 | app = create_app(adapter, bot, agents_client, secret_client) 116 | 117 | if __name__ == "__main__": 118 | web.run_app(app, host="localhost", port=3978) -------------------------------------------------------------------------------- /src/azure_ai_projects-1.0.0b1-py3-none-any.whl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/azure_ai_projects-1.0.0b1-py3-none-any.whl -------------------------------------------------------------------------------- /src/bots/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from .assistant_bot import AssistantBot 5 | 6 | __all__ = ["AssistantBot"] 7 | -------------------------------------------------------------------------------- /src/bots/assistant_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | import os 5 | import io 6 | import json 7 | import base64 8 | import urllib.request 9 | 10 | from azure.ai.projects.operations import AgentsOperations 11 | 12 | from botbuilder.core import ConversationState, TurnContext, UserState, MessageFactory 13 | from botbuilder.schema import ChannelAccount, CardAction, ActionTypes 14 | from botbuilder.dialogs import Dialog 15 | 16 | from openai import AzureOpenAI 17 | 18 | from data_models import ConversationData, Attachment, mime_type 19 | from bots.state_management_bot import StateManagementBot 20 | from services.bing import BingClient 21 | from services.graph import GraphClient 22 | 23 | class AssistantBot(StateManagementBot): 24 | 25 | def __init__( 26 | self, 27 | conversation_state: ConversationState, 28 | user_state: UserState, 29 | aoai_client: AzureOpenAI, 30 | agents_client: AgentsOperations, 31 | agent_id: str, 32 | bing_client: BingClient, 33 | graph_client: GraphClient, 34 | dialog: Dialog 35 | ): 36 | super().__init__(conversation_state, user_state, dialog) 37 | self.aoai_client = aoai_client 38 | self.chat_client = aoai_client.chat 39 | self.agents_client = agents_client 40 | self.bing_client = bing_client 41 | self.graph_client = graph_client 42 | 43 | self.deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME") 44 | self.instructions = os.getenv("LLM_INSTRUCTIONS") 45 | self.welcome_message = os.getenv("LLM_WELCOME_MESSAGE", "Hello and welcome to the Assistant Bot Python!") 46 | self.agent_id = agent_id 47 | self.streaming = os.getenv("AZURE_OPENAI_STREAMING", False) 48 | 49 | async def on_members_added_activity(self, members_added: list[ChannelAccount], turn_context: TurnContext): 50 | for member in members_added: 51 | if member.id != turn_context.activity.recipient.id: 52 | await turn_context.send_activity(self.welcome_message) 53 | await self.handle_login(turn_context) 54 | 55 | async def on_message_activity(self, turn_context: TurnContext): 56 | 57 | # Enforce login 58 | loggedIn = await self.handle_login(turn_context) 59 | if not loggedIn: 60 | return False 61 | 62 | # Load conversation state 63 | conversation_data = await self.conversation_data_accessor.get(turn_context, ConversationData([])) 64 | 65 | # Create a new thread if one does not exist 66 | if conversation_data.thread_id is None: 67 | thread = self.agents_client.create_thread() 68 | conversation_data.thread_id = thread.id 69 | 70 | # Delete thread if user asks 71 | if turn_context.activity.text == 'clear': 72 | self.agents_client.delete_thread(conversation_data.thread_id) 73 | conversation_data.thread_id = None 74 | conversation_data.attachments = [] 75 | conversation_data.history = [] 76 | await turn_context.send_activity('Conversation cleared!') 77 | return True 78 | 79 | # Check if this is a file upload and process it 80 | files_uploaded = await self.handle_file_uploads(turn_context, conversation_data.thread_id, conversation_data) 81 | 82 | # Return early if there is no text in the message 83 | if (files_uploaded or turn_context.activity.text == None): 84 | return 85 | 86 | # Check if this is a file upload follow up - user selects a file upload option 87 | if (turn_context.activity.text.startswith(":")): 88 | # Get upload metadata 89 | tool = turn_context.activity.text.split(':').pop().strip() 90 | # Get file from attachments 91 | attachment = conversation_data.attachments[-1] 92 | # Add file upload to relevant tool 93 | with urllib.request.urlopen(attachment.url) as f: 94 | bytes = io.BytesIO(f.read()) 95 | bytes.name = attachment.name 96 | file_response = self.agents_client.upload_file(file=bytes, purpose="assistants") 97 | # Send the file to the assistant 98 | tools = [] 99 | if tool == "Code Interpreter": 100 | tools.append({ 101 | "type": "code_interpreter" 102 | }) 103 | if tool == "File Search": 104 | tools.append({ 105 | "type": "file_search" 106 | }) 107 | self.agents_client.create_message( 108 | thread_id=conversation_data.thread_id, 109 | role="user", 110 | content=f"File uploaded: {attachment.name}", 111 | attachments=[{ 112 | "file_id": file_response.id, 113 | "tools": tools 114 | }] 115 | ) 116 | # Send feedback to user 117 | await turn_context.send_activity(MessageFactory.text(f"File added to {tool} successfully!")) 118 | return True 119 | 120 | # Add user message to history 121 | conversation_data.add_turn("user", turn_context.activity.text) 122 | 123 | # Send user message to thread 124 | self.agents_client.create_message( 125 | thread_id=conversation_data.thread_id, 126 | role="user", 127 | content=turn_context.activity.text 128 | ) 129 | 130 | # Run thread 131 | run = self.agents_client.create_stream( 132 | thread_id=conversation_data.thread_id, 133 | assistant_id=self.agent_id, 134 | instructions=self.instructions 135 | ) 136 | 137 | # Process run streaming 138 | await self.process_run_streaming(run, conversation_data, turn_context) 139 | 140 | return True 141 | 142 | async def process_run_streaming(self, run, conversation_data, turn_context, stream_id = None): 143 | # Start streaming response 144 | current_message = "" 145 | tool_outputs = [] 146 | current_run_id = "" 147 | activity_id = "" 148 | stream_sequence = 1 149 | activity_id = await self.send_interim_message(turn_context, "Typing...", stream_sequence, stream_id, "typing") 150 | 151 | for event in run: 152 | event_type = event[0] 153 | event_data = event[1] 154 | if event_type == "thread.run.failed": 155 | current_message = event_data.last_error.message 156 | break 157 | if event_type == "thread.run.created": 158 | current_run_id = event_data.id 159 | if event_type == "thread.run.requires_action": 160 | tool_calls = event_data.required_action.submit_tool_outputs.tool_calls 161 | for tool_call in tool_calls: 162 | arguments = json.loads(tool_call.function.arguments) 163 | if tool_call.function.name == "image_query": 164 | response = await self.image_query(conversation_data, arguments["query"], arguments["image_name"]) 165 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response}) 166 | elif tool_call.function.name == "bing_query": 167 | response = await self.bing_query(conversation_data, arguments["query"], arguments["type"]) 168 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response}) 169 | elif tool_call.function.name == "schedule_event": 170 | response = await self.schedule_event(conversation_data, turn_context.activity.token, arguments["subject"], arguments["start"], arguments["end"]) 171 | tool_outputs.append({"tool_call_id": tool_call.id, "output": response}) 172 | else: 173 | tool_outputs.append({"tool_call_id": tool_call.id, "output": "Tool not found"}) 174 | 175 | if event_type == "thread.message.delta": 176 | deltaBlock = event_data.delta.content[0] 177 | if deltaBlock.type == "text": 178 | current_message += deltaBlock.text.value 179 | stream_sequence += 1 180 | # Flush content every 50 messages 181 | if (stream_sequence % 50 == 0): 182 | await self.send_interim_message(turn_context, current_message, stream_sequence, activity_id, "typing") 183 | 184 | elif deltaBlock.type == "image_file": 185 | current_message += f"![{deltaBlock.image_file.file_id}](/api/files/{deltaBlock.image_file.file_id})" 186 | 187 | messages = self.agents_client.get_messages(thread_id=conversation_data.thread_id).messages 188 | # Recursively process the run with the tool outputs 189 | if len(tool_outputs) > 0: 190 | new_run = self.agents_client.submit_tool_outputs_to_stream(thread_id=conversation_data.thread_id, run_id=current_run_id, tool_outputs=tool_outputs) 191 | await self.process_run_streaming(new_run, conversation_data, turn_context, activity_id) 192 | return 193 | response = current_message 194 | 195 | # Add assistant message to history 196 | conversation_data.add_turn("assistant", response) 197 | 198 | # Respond back to user 199 | stream_sequence += 1 200 | await self.send_interim_message(turn_context, current_message, stream_sequence, activity_id, "message") 201 | 202 | 203 | # Helper to handle file uploads from user 204 | async def handle_file_uploads(self, turn_context: TurnContext, thread_id: str, conversation_data: ConversationData): 205 | files_uploaded = False 206 | # Check if incoming message has attached files 207 | if turn_context.activity.attachments is not None: 208 | for attachment in turn_context.activity.attachments: 209 | if attachment.content_url == None: 210 | continue 211 | files_uploaded = True 212 | download_url = attachment.content_url 213 | if attachment.content and "downloadUrl" in attachment.content: 214 | download_url = attachment.content["downloadUrl"] 215 | # Add file to attachments in case we need to reference it in Function Calling 216 | conversation_data.attachments.append(Attachment( 217 | name = attachment.name, 218 | content_type = mime_type(attachment.name), 219 | url = download_url 220 | )) 221 | 222 | # Add file upload notice to conversation history, frontend, and assistant 223 | conversation_data.add_turn("user", f"File uploaded: {attachment.name}") 224 | await turn_context.send_activity(MessageFactory.text(f"File uploaded: {attachment.name}")) 225 | self.agents_client.create_message(thread_id=thread_id,role="user",content=f"File uploaded: {attachment.name}",) 226 | # Ask whether to add file to a tool 227 | await turn_context.send_activity(MessageFactory.suggested_actions( 228 | [ 229 | CardAction(title= ":Code Interpreter", type= ActionTypes.im_back, value= ":Code Interpreter"), 230 | CardAction( title= ":File Search", type= ActionTypes.im_back, value= ":File Search"), 231 | ], 232 | "Add to a tool? (ignore if not needed)", 233 | )) 234 | 235 | 236 | # Return True if files were uploaded 237 | return files_uploaded 238 | 239 | async def image_query(self, conversation_data: ConversationData, query: str, image_name: str): 240 | # Find image in attachments by name 241 | image = next(filter(lambda a: a.name == image_name.split("/")[-1], conversation_data.attachments)) 242 | 243 | # Read image.url 244 | with urllib.request.urlopen(image.url) as f: 245 | # get file as base64 246 | bytes = base64.b64encode(f.read()).decode() 247 | 248 | # Send image to assistant 249 | response = self.chat_client.completions.create( 250 | model=self.deployment, 251 | messages=[ 252 | {"role": "user", "content": [ 253 | {"type": "text", "text": query}, 254 | {"type": "image_url", "image_url": { 255 | "url": f"data:{image.content_type};base64,{bytes}"} 256 | } 257 | ]} 258 | ] 259 | ) 260 | return response.choices[0].message.content 261 | 262 | async def bing_query(self, conversation_data: ConversationData, query: str, type: str): 263 | return self.bing_client.query(query, type) 264 | 265 | async def schedule_event(self, conversation_data: ConversationData, token: str, subject: str, start: str, end: str): 266 | return self.graph_client.schedule_event(token, subject, start, end) 267 | 268 | -------------------------------------------------------------------------------- /src/bots/state_management_bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | import os 4 | import jwt 5 | from botbuilder.core import ActivityHandler, ConversationState, TurnContext, UserState, MessageFactory 6 | from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus 7 | from botframework.connector.auth.user_token_client import UserTokenClient 8 | 9 | class StateManagementBot(ActivityHandler): 10 | def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): 11 | self.conversation_state = conversation_state 12 | self.user_state = user_state 13 | self.conversation_data_accessor = self.conversation_state.create_property("ConversationData") 14 | self.dialog = dialog 15 | self.sso_enabled = os.getenv("SSO_ENABLED", False) 16 | if (self.sso_enabled == "false"): 17 | self.sso_enabled = False 18 | self.sso_config_name = os.getenv("SSO_CONFIG_NAME", "default") 19 | 20 | async def on_turn(self, turn_context: TurnContext): 21 | await super().on_turn(turn_context) 22 | # Save any state changes. The load happened during the execution of the Dialog. 23 | await self.conversation_state.save_changes(turn_context) 24 | await self.user_state.save_changes(turn_context) 25 | 26 | async def handle_login(self, turn_context: TurnContext): 27 | if not self.sso_enabled: 28 | return True 29 | if turn_context.activity.text == 'logout': 30 | await self.handle_logout(turn_context) 31 | return False 32 | 33 | user_profile_accessor = self.user_state.create_property("UserProfile") 34 | user_profile = await user_profile_accessor.get(turn_context, lambda: {}) 35 | 36 | user_token_client = turn_context.turn_state.get(UserTokenClient.__name__, None) 37 | 38 | try: 39 | user_token = await user_token_client.get_user_token(turn_context.activity.from_property.id, self.sso_config_name, turn_context.activity.channel_id, None) 40 | decoded_token = jwt.decode(user_token.token, options={"verify_signature": False}) 41 | turn_context.activity.token = user_token.token 42 | user_profile["name"] = decoded_token.get("name") 43 | return True 44 | except Exception as error: 45 | dialog_set = DialogSet(self.conversation_state.create_property("DialogState")) 46 | dialog_set.add(self.dialog) 47 | dialog_context = await dialog_set.create_context(turn_context) 48 | results = await dialog_context.continue_dialog() 49 | if results.status == DialogTurnStatus.Empty: 50 | await dialog_context.begin_dialog(self.dialog.id) 51 | return False 52 | 53 | async def handle_logout(self, turn_context): 54 | user_token_client = turn_context.turn_state.get(UserTokenClient.__name__, None) 55 | await user_token_client.sign_out_user(turn_context.activity.from_property.id, self.sso_config_name, turn_context.activity.channel_id) 56 | await turn_context.send_activity("Signed out") 57 | 58 | async def send_interim_message( 59 | self, 60 | turn_context, 61 | interim_message, 62 | stream_sequence, 63 | stream_id, 64 | stream_type 65 | ): 66 | stream_supported = self.streaming and turn_context.activity.channel_id == "directline" 67 | update_supported = self.streaming and turn_context.activity.channel_id == "msteams" 68 | # If we can neither stream or update, return null 69 | if stream_type == "typing" and not stream_supported and not update_supported: 70 | return None 71 | # If we can update messages, do so 72 | if update_supported: 73 | if stream_id == None: 74 | create_activity = await turn_context.send_activity(interim_message) 75 | return create_activity.id 76 | else: 77 | update_message = MessageFactory.text(interim_message) 78 | update_message.id = stream_id 79 | update_message.type = "message" 80 | update_activity = await turn_context.update_activity(update_message) 81 | return update_activity.id 82 | # If we can stream messages, do so 83 | channel_data = { 84 | "streamId": stream_id, 85 | "streamSequence": stream_sequence, 86 | "streamType": "streaming" if stream_type == "typing" else "final" 87 | } 88 | message = MessageFactory.text(interim_message) 89 | message.channel_data = channel_data if stream_supported else None 90 | message.type = stream_type 91 | activity = await turn_context.send_activity(message) 92 | return activity.id -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. 4 | 5 | import os 6 | 7 | """ Bot Configuration """ 8 | 9 | 10 | class DefaultConfig: 11 | """ Bot Configuration """ 12 | APP_ID = os.environ.get("MicrosoftAppId", "") 13 | APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") 14 | APP_TYPE = os.environ.get("MicrosoftAppType", "MultiTenant") 15 | APP_TENANTID = os.environ.get("MicrosoftAppTenantId", "") -------------------------------------------------------------------------------- /src/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = 'aiohttp.pytest_plugin' -------------------------------------------------------------------------------- /src/data_models/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from .conversation_data import ConversationData, ConversationTurn, Attachment 5 | from .mime_type import mime_type 6 | 7 | __all__ = ["ConversationData", "ConversationTurn", "Attachment", "mime_type"] 8 | -------------------------------------------------------------------------------- /src/data_models/conversation_data.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | class ConversationTurn: 5 | def __init__( 6 | self, 7 | role: str = None, 8 | content: str = None 9 | ): 10 | self.role = role 11 | self.content = content 12 | 13 | class Attachment: 14 | def __init__( 15 | self, 16 | name: str = None, 17 | content_type: str = None, 18 | url: str = None 19 | ): 20 | self.name = name 21 | self.content_type = content_type 22 | self.url = url 23 | 24 | class ConversationData: 25 | def __init__( 26 | self, 27 | history: list[ConversationTurn], 28 | max_turns: int = 10, 29 | thread_id: str = None, 30 | ): 31 | self.thread_id = thread_id 32 | self.history = history 33 | self.max_turns = max_turns 34 | self.attachments = [] 35 | 36 | def add_turn(self, role: str, content: str): 37 | self.history.append(ConversationTurn(role, content)) 38 | if len(self.history) > self.max_turns: 39 | self.history.pop(0) -------------------------------------------------------------------------------- /src/data_models/mime_type.py: -------------------------------------------------------------------------------- 1 | types = { 2 | "c": "text/x-c", 3 | "cpp": "text/x-c++", 4 | "cs": "text/x-csharp", 5 | "css": "text/css", 6 | "csv": "text/csv", 7 | "doc": "application/msword", 8 | "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 9 | "gif": "image/gif", 10 | "go": "text/x-golang", 11 | "html": "text/html", 12 | "java": "text/x-java", 13 | "jpg": "image/jpeg", 14 | "jpeg": "image/jpeg", 15 | "js": "text/javascript", 16 | "json": "application/json", 17 | "md": "text/markdown", 18 | "pdf": "application/pdf", 19 | "php": "text/x-php", 20 | "png": "image/png", 21 | "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", 22 | "py": "text/x-python", 23 | "py": "text/x-script.python", 24 | "rb": "text/x-ruby", 25 | "sh": "application/x-sh", 26 | "tex": "text/x-tex", 27 | "ts": "application/typescript", 28 | "txt": "text/plain", 29 | "webp": "image/webp", 30 | "xls": "application/vnd.ms-excel", 31 | "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 32 | } 33 | 34 | def mime_type(filename): 35 | ext = filename.split('.').pop() 36 | return types[ext] or "application/octet-stream" -------------------------------------------------------------------------------- /src/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from .login_dialog import LoginDialog 5 | 6 | __all__ = ["LoginDialog"] 7 | -------------------------------------------------------------------------------- /src/dialogs/login_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | import os 5 | from botbuilder.dialogs import ( 6 | WaterfallDialog, 7 | WaterfallStepContext, 8 | DialogTurnResult, 9 | ComponentDialog 10 | ) 11 | from botbuilder.dialogs.prompts import OAuthPrompt, OAuthPromptSettings, ConfirmPrompt 12 | 13 | 14 | class LoginDialog(ComponentDialog): 15 | def __init__(self): 16 | super(LoginDialog, self).__init__(LoginDialog.__name__) 17 | self.connection_name = os.getenv("SSO_CONFIG_NAME", "default") 18 | self.login_success_message = os.getenv("SSO_MESSAGE_SUCCESS", "Login success") 19 | self.login_failed_message = os.getenv("SSO_MESSAGE_FAILED", "Login failed") 20 | 21 | self.add_dialog( 22 | OAuthPrompt( 23 | OAuthPrompt.__name__, 24 | OAuthPromptSettings( 25 | connection_name=self.connection_name, 26 | text=os.getenv("SSO_MESSAGE_TITLE"), 27 | title=os.getenv("SSO_MESSAGE_PROMPT"), 28 | timeout=300000, 29 | ), 30 | ) 31 | ) 32 | 33 | self.add_dialog( 34 | WaterfallDialog( 35 | "WaterfallDialog", 36 | [ 37 | self.prompt_step, 38 | self.login_step 39 | ], 40 | ) 41 | ) 42 | 43 | self.initial_dialog_id = "WaterfallDialog" 44 | 45 | async def prompt_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: 46 | return await step_context.begin_dialog(OAuthPrompt.__name__) 47 | 48 | async def login_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: 49 | # Get the token from the previous step. Note that we could also have gotten the 50 | # token directly from the prompt itself. There is an example of this in the next method. 51 | token_response = step_context.result 52 | if token_response: 53 | await step_context.context.send_activity(self.login_success_message) 54 | return await step_context.end_dialog() 55 | 56 | await step_context.context.send_activity(self.login_failed_message) 57 | return await step_context.end_dialog() -------------------------------------------------------------------------------- /src/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | max_requests = 1000 4 | max_requests_jitter = 50 5 | log_file = "-" 6 | bind = "0.0.0.0:8000" 7 | 8 | timeout = 600 9 | # https://learn.microsoft.com/en-us/troubleshoot/azure/app-service/web-apps-performance-faqs#why-does-my-request-time-out-after-230-seconds 10 | 11 | num_cpus = multiprocessing.cpu_count() 12 | workers = (num_cpus * 2) + 1 13 | # workers = 1 14 | worker_class = "aiohttp.GunicornWebWorker" 15 | port = 8000 -------------------------------------------------------------------------------- /src/public/images/BotServices-Translucent.svg: -------------------------------------------------------------------------------- 1 | Asset 5 2 | -------------------------------------------------------------------------------- /src/public/images/BotServices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/public/images/BotServices.png -------------------------------------------------------------------------------- /src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Web Chat Direct Line Token Demo 11 | 12 | 13 | 14 | 15 | 69 | 70 | 71 | 72 | 73 |
74 |
75 |
76 |
77 |
78 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/public/webchat.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/public/webchat.js.gz -------------------------------------------------------------------------------- /src/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | ; asyncio_mode = auto 3 | asyncio_default_fixture_loop_scope = function -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.5 2 | aiohttp-compress==0.2.1 3 | azure-core==1.31.0 4 | azure-cosmos==4.8.0 5 | azure-identity==1.19.0 6 | azure-keyvault-secrets==4.9.0 7 | azure-search==1.0.0b2 8 | azure-search-documents==11.5.1 9 | botbuilder-core==4.16.1 10 | botbuilder-dialogs==4.16.1 11 | botbuilder-integration-aiohttp==4.16.1 12 | python-dotenv==1.0.1 13 | pytest-aiohttp==1.0.5 14 | openai==1.55.3 15 | azure_ai_projects-1.0.0b1-py3-none-any.whl -------------------------------------------------------------------------------- /src/routes/api/directline.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | import os 5 | import requests 6 | from aiohttp import web 7 | from aiohttp.web import Request, Response, json_response 8 | from azure.keyvault.secrets import SecretClient 9 | 10 | def directline_routes(secret_client: SecretClient): 11 | direct_line_secret = os.getenv('AZURE_DIRECT_LINE_SECRET', secret_client.get_secret("AZURE-DIRECT-LINE-SECRET").value) 12 | async def get_directline_token(req: Request) -> Response: 13 | user_id = f"dl_{os.urandom(16).hex()}" 14 | headers = { 15 | "Authorization": f"Bearer {direct_line_secret}", 16 | "Content-type": "application/json" 17 | } 18 | body = { 19 | "User": { "Id": user_id } 20 | } 21 | response = requests.post("https://directline.botframework.com/v3/directline/tokens/generate", headers=headers, json=body) 22 | token_response = response 23 | return json_response(token_response.json(), status=token_response.status_code) 24 | 25 | 26 | return [ 27 | web.get("/api/directline/token", get_directline_token) 28 | ] 29 | -------------------------------------------------------------------------------- /src/routes/api/files.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from aiohttp import web 5 | from aiohttp.web import Request, Response, StreamResponse 6 | from azure.ai.projects.operations import AgentsOperations 7 | 8 | 9 | def file_routes(agents_client: AgentsOperations): 10 | async def get_assistant_file(req: Request) -> Response: 11 | file_id = req.match_info['file_id'] 12 | content = agents_client.get_file_content(file_id) 13 | response = StreamResponse() 14 | response.content_type = 'image/png' 15 | await response.prepare(req) 16 | for bytes in content: 17 | await response.write(bytes) 18 | return response 19 | 20 | 21 | return [ 22 | web.get(r"/api/files/{file_id:.*}", get_assistant_file) 23 | ] 24 | -------------------------------------------------------------------------------- /src/routes/api/messages.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | import sys 5 | import traceback 6 | from http import HTTPStatus 7 | from aiohttp import web 8 | from aiohttp.web import Request, Response, json_response 9 | from botbuilder.core import ( 10 | ActivityHandler, 11 | TurnContext 12 | ) 13 | from botbuilder.integration.aiohttp import CloudAdapter 14 | from botbuilder.schema import Activity 15 | 16 | # Catch-all for errors. 17 | async def on_error(context: TurnContext, error: Exception): 18 | print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) 19 | traceback.print_exc() 20 | 21 | # Send a message to the user 22 | await context.send_activity("The bot encountered an error or bug.") 23 | await context.send_activity( 24 | "To continue to run this bot, please fix the bot source code." 25 | ) 26 | await context.send_activity(str(error)) 27 | 28 | def messages_routes(adapter: CloudAdapter, bot: ActivityHandler): 29 | # Listen for incoming requests on /api/messages. 30 | async def messages(req: Request) -> Response: 31 | # Parse incoming request 32 | if "application/json" in req.headers["Content-Type"]: 33 | body = await req.json() 34 | else: 35 | return Response(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE) 36 | activity = Activity().deserialize(body) 37 | auth_header = req.headers["Authorization"] if "Authorization" in req.headers else "" 38 | 39 | # Route received a request to adapter for processing 40 | response = await adapter.process_activity(auth_header, activity, bot.on_turn) 41 | if response: 42 | return json_response(data=response.body, status=response.status) 43 | return Response(status=HTTPStatus.OK) 44 | 45 | # Set the error handler on the Adapter. 46 | adapter.on_turn_error = on_error 47 | 48 | return [ 49 | web.post("/api/messages", messages) 50 | ] -------------------------------------------------------------------------------- /src/routes/static/static.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. All rights reserved. 2 | # Licensed under the MIT License. 3 | 4 | from aiohttp import web 5 | from aiohttp.web import FileResponse 6 | 7 | def static_routes(): 8 | return [ 9 | web.get("/", lambda _: FileResponse("public/index.html")), 10 | web.static("/public", "public"), 11 | ] -------------------------------------------------------------------------------- /src/services/bing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | class BingClient(): 5 | def __init__(self, api_key: str, endpoint="https://api.bing.microsoft.com/v7.0/search"): 6 | self.endpoint = endpoint 7 | self.headers = {"Ocp-Apim-Subscription-Key": api_key} 8 | pass 9 | 10 | def query(self, query: str, type: str): 11 | params = {"q": query, "textDecorations": True, "textFormat": "HTML"} 12 | response = requests.get(self.endpoint, headers=self.headers, params=params) 13 | response.raise_for_status() 14 | search_results = response.json() 15 | return json.dumps(search_results) -------------------------------------------------------------------------------- /src/services/cosmos.py: -------------------------------------------------------------------------------- 1 | """Implements a CosmosDB based storage provider using partitioning for a bot. 2 | """ 3 | 4 | # Copyright (c) Microsoft Corporation. All rights reserved. 5 | # Licensed under the MIT License. 6 | from typing import Dict, List 7 | from threading import Lock, Semaphore 8 | import json 9 | 10 | from azure.cosmos import documents, PartitionKey, DatabaseProxy 11 | from azure.identity import ChainedTokenCredential 12 | from jsonpickle.pickler import Pickler 13 | from jsonpickle.unpickler import Unpickler 14 | import azure.cosmos.cosmos_client as cosmos_client # pylint: disable=no-name-in-module,import-error 15 | import azure.cosmos.errors as cosmos_errors # pylint: disable=no-name-in-module,import-error 16 | from botbuilder.core.storage import Storage 17 | 18 | # Copyright (c) Microsoft Corporation. All rights reserved. 19 | # Licensed under the MIT License. 20 | from hashlib import sha256 21 | import warnings 22 | from botbuilder.core.storage import Storage 23 | 24 | 25 | class CosmosDbPartitionedConfig: 26 | """The class for partitioned CosmosDB configuration for the Azure Bot Framework.""" 27 | 28 | def __init__( 29 | self, 30 | cosmos_db_endpoint: str = None, 31 | credential: ChainedTokenCredential = None, 32 | database_id: str = None, 33 | container_id: str = None, 34 | cosmos_client_options: dict = None, 35 | container_throughput: int = 400, 36 | key_suffix: str = "", 37 | compatibility_mode: bool = False, 38 | **kwargs, 39 | ): 40 | """Create the Config object. 41 | 42 | :param cosmos_db_endpoint: The CosmosDB endpoint. 43 | :param auth_key: The authentication key for Cosmos DB. 44 | :param database_id: The database identifier for Cosmos DB instance. 45 | :param container_id: The container identifier. 46 | :param cosmos_client_options: The options for the CosmosClient. Currently only supports connection_policy and 47 | consistency_level 48 | :param container_throughput: The throughput set when creating the Container. Defaults to 400. 49 | :param key_suffix: The suffix to be added to every key. The keySuffix must contain only valid ComosDb 50 | key characters. (e.g. not: '\\', '?', '/', '#', '*') 51 | :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb 52 | max key length of 255. 53 | :return CosmosDbPartitionedConfig: 54 | """ 55 | self.__config_file = kwargs.get("filename") 56 | if self.__config_file: 57 | kwargs = json.load(open(self.__config_file)) 58 | self.cosmos_db_endpoint = cosmos_db_endpoint or kwargs.get("cosmos_db_endpoint") 59 | self.credential = credential or kwargs.get("credential") 60 | self.database_id = database_id or kwargs.get("database_id") 61 | self.container_id = container_id or kwargs.get("container_id") 62 | self.cosmos_client_options = cosmos_client_options or kwargs.get( 63 | "cosmos_client_options", {} 64 | ) 65 | self.container_throughput = container_throughput or kwargs.get( 66 | "container_throughput" 67 | ) 68 | self.key_suffix = key_suffix or kwargs.get("key_suffix") 69 | self.compatibility_mode = compatibility_mode or kwargs.get("compatibility_mode") 70 | 71 | 72 | class CosmosDbPartitionedStorage(Storage): 73 | """A CosmosDB based storage provider using partitioning for a bot.""" 74 | 75 | def __init__(self, config: CosmosDbPartitionedConfig): 76 | """Create the storage object. 77 | 78 | :param config: 79 | """ 80 | super(CosmosDbPartitionedStorage, self).__init__() 81 | self.config = config 82 | self.client = None 83 | self.database = None 84 | self.container = None 85 | self.compatability_mode_partition_key = False 86 | # Lock used for synchronizing container creation 87 | self.__lock = Lock() 88 | if config.key_suffix is None: 89 | config.key_suffix = "" 90 | if not config.key_suffix.__eq__(""): 91 | if config.compatibility_mode: 92 | raise Exception( 93 | "compatibilityMode cannot be true while using a keySuffix." 94 | ) 95 | suffix_escaped = CosmosDbKeyEscape.sanitize_key(config.key_suffix) 96 | if not suffix_escaped.__eq__(config.key_suffix): 97 | raise Exception( 98 | f"Cannot use invalid Row Key characters: {config.key_suffix} in keySuffix." 99 | ) 100 | 101 | async def read(self, keys: List[str]) -> Dict[str, object]: 102 | """Read storeitems from storage. 103 | 104 | :param keys: 105 | :return dict: 106 | """ 107 | if not keys: 108 | raise Exception("Keys are required when reading") 109 | 110 | await self.initialize() 111 | 112 | store_items = {} 113 | 114 | for key in keys: 115 | try: 116 | escaped_key = CosmosDbKeyEscape.sanitize_key( 117 | key, self.config.key_suffix, self.config.compatibility_mode 118 | ) 119 | read_item_response = self.container.read_item( 120 | self.__item_link(escaped_key), self.__get_partition_key(escaped_key) 121 | ) 122 | document_store_item = read_item_response 123 | if document_store_item: 124 | store_items[document_store_item["realId"]] = self.__create_si( 125 | document_store_item 126 | ) 127 | # When an item is not found a CosmosException is thrown, but we want to 128 | # return an empty collection so in this instance we catch and do not rethrow. 129 | # Throw for any other exception. 130 | except cosmos_errors.HttpResponseError as err: 131 | if ( 132 | err.status_code 133 | == cosmos_errors.http_constants.StatusCodes.NOT_FOUND 134 | ): 135 | continue 136 | raise err 137 | except Exception as err: 138 | raise err 139 | return store_items 140 | 141 | async def write(self, changes: Dict[str, object]): 142 | """Save storeitems to storage. 143 | 144 | :param changes: 145 | :return: 146 | """ 147 | if changes is None: 148 | raise Exception("Changes are required when writing") 149 | if not changes: 150 | return 151 | 152 | await self.initialize() 153 | 154 | for key, change in changes.items(): 155 | e_tag = None 156 | if isinstance(change, dict): 157 | e_tag = change.get("e_tag", None) 158 | elif hasattr(change, "e_tag"): 159 | e_tag = change.e_tag 160 | doc = { 161 | "id": CosmosDbKeyEscape.sanitize_key( 162 | key, self.config.key_suffix, self.config.compatibility_mode 163 | ), 164 | "realId": key, 165 | "document": self.__create_dict(change), 166 | } 167 | if e_tag == "": 168 | raise Exception("cosmosdb_storage.write(): etag missing") 169 | 170 | access_condition = { 171 | "accessCondition": {"type": "IfMatch", "condition": e_tag} 172 | } 173 | options = ( 174 | access_condition if e_tag != "*" and e_tag and e_tag != "" else None 175 | ) 176 | try: 177 | self.container.upsert_item( 178 | doc 179 | ) 180 | except cosmos_errors.HttpResponseError as err: 181 | raise err 182 | except Exception as err: 183 | raise err 184 | 185 | async def delete(self, keys: List[str]): 186 | """Remove storeitems from storage. 187 | 188 | :param keys: 189 | :return: 190 | """ 191 | await self.initialize() 192 | 193 | for key in keys: 194 | escaped_key = CosmosDbKeyEscape.sanitize_key( 195 | key, self.config.key_suffix, self.config.compatibility_mode 196 | ) 197 | try: 198 | self.container.delete_item( 199 | self.__item_link(escaped_key) 200 | ) 201 | except cosmos_errors.HttpResponseError as err: 202 | if ( 203 | err.status_code 204 | == cosmos_errors.http_constants.StatusCodes.NOT_FOUND 205 | ): 206 | continue 207 | raise err 208 | except Exception as err: 209 | raise err 210 | 211 | async def initialize(self): 212 | if not self.container: 213 | if not self.client: 214 | self.client = cosmos_client.CosmosClient( 215 | self.config.cosmos_db_endpoint, 216 | credential=self.config.credential 217 | ) 218 | 219 | if not self.database: 220 | with self.__lock: 221 | self.database = DatabaseProxy(self.client.client_connection, self.config.database_id) 222 | self.__get_or_create_container() 223 | 224 | def __get_or_create_container(self): 225 | with self.__lock: 226 | if not self.container: 227 | self.container = self.database.create_container_if_not_exists( 228 | self.config.container_id, 229 | partition_key=PartitionKey(["/id"], kind=documents.PartitionKind.Hash), 230 | offer_throughput=self.config.container_throughput, 231 | ) 232 | 233 | def __get_partition_key(self, key: str) -> str: 234 | return None if self.compatability_mode_partition_key else key 235 | 236 | @staticmethod 237 | def __create_si(result) -> object: 238 | """Create an object from a result out of CosmosDB. 239 | 240 | :param result: 241 | :return object: 242 | """ 243 | # get the document item from the result and turn into a dict 244 | doc = result.get("document") 245 | # read the e_tag from Cosmos 246 | if result.get("_etag"): 247 | doc["e_tag"] = result["_etag"] 248 | 249 | result_obj = Unpickler().restore(doc) 250 | 251 | # create and return the object 252 | return result_obj 253 | 254 | @staticmethod 255 | def __create_dict(store_item: object) -> Dict: 256 | """Return the dict of an object. 257 | 258 | This eliminates non_magic attributes and the e_tag. 259 | 260 | :param store_item: 261 | :return dict: 262 | """ 263 | # read the content 264 | json_dict = Pickler().flatten(store_item) 265 | if "e_tag" in json_dict: 266 | del json_dict["e_tag"] 267 | 268 | # loop through attributes and write and return a dict 269 | return json_dict 270 | 271 | def __item_link(self, identifier) -> str: 272 | """Return the item link of a item in the container. 273 | 274 | :param identifier: 275 | :return str: 276 | """ 277 | return identifier 278 | 279 | @property 280 | def __container_link(self) -> str: 281 | """Return the container link in the database. 282 | 283 | :param: 284 | :return str: 285 | """ 286 | return self.config.container_id 287 | 288 | @property 289 | def __database_link(self) -> str: 290 | """Return the database link. 291 | 292 | :return str: 293 | """ 294 | return self.config.database_id 295 | 296 | 297 | """Implements a CosmosDB based storage provider. 298 | """ 299 | class CosmosDbConfig: 300 | """The class for CosmosDB configuration for the Azure Bot Framework.""" 301 | 302 | def __init__( 303 | self, 304 | endpoint: str = None, 305 | masterkey: str = None, 306 | database: str = None, 307 | container: str = None, 308 | partition_key: str = None, 309 | database_creation_options: dict = None, 310 | container_creation_options: dict = None, 311 | **kwargs, 312 | ): 313 | """Create the Config object. 314 | 315 | :param endpoint: 316 | :param masterkey: 317 | :param database: 318 | :param container: 319 | :param filename: 320 | :return CosmosDbConfig: 321 | """ 322 | self.__config_file = kwargs.get("filename") 323 | if self.__config_file: 324 | kwargs = json.load(open(self.__config_file)) 325 | self.endpoint = endpoint or kwargs.get("endpoint") 326 | self.masterkey = masterkey or kwargs.get("masterkey") 327 | self.database = database or kwargs.get("database", "bot_db") 328 | self.container = container or kwargs.get("container", "bot_container") 329 | self.partition_key = partition_key or kwargs.get("partition_key") 330 | self.database_creation_options = database_creation_options or kwargs.get( 331 | "database_creation_options" 332 | ) 333 | self.container_creation_options = container_creation_options or kwargs.get( 334 | "container_creation_options" 335 | ) 336 | 337 | 338 | class CosmosDbKeyEscape: 339 | @staticmethod 340 | def sanitize_key( 341 | key: str, key_suffix: str = "", compatibility_mode: bool = True 342 | ) -> str: 343 | """Return the sanitized key. 344 | 345 | Replace characters that are not allowed in keys in Cosmos. 346 | 347 | :param key: The provided key to be escaped. 348 | :param key_suffix: The string to add a the end of all RowKeys. 349 | :param compatibility_mode: True if keys should be truncated in order to support previous CosmosDb 350 | max key length of 255. This behavior can be overridden by setting 351 | cosmosdb_partitioned_config.compatibility_mode to False. 352 | :return str: 353 | """ 354 | # forbidden characters 355 | bad_chars = ["\\", "?", "/", "#", "\t", "\n", "\r", "*"] 356 | # replace those with with '*' and the 357 | # Unicode code point of the character and return the new string 358 | key = "".join(map(lambda x: "*" + str(ord(x)) if x in bad_chars else x, key)) 359 | 360 | if key_suffix is None: 361 | key_suffix = "" 362 | 363 | return CosmosDbKeyEscape.truncate_key(f"{key}{key_suffix}", compatibility_mode) 364 | 365 | @staticmethod 366 | def truncate_key(key: str, compatibility_mode: bool = True) -> str: 367 | max_key_len = 255 368 | 369 | if not compatibility_mode: 370 | return key 371 | 372 | if len(key) > max_key_len: 373 | aux_hash = sha256(key.encode("utf-8")) 374 | aux_hex = aux_hash.hexdigest() 375 | 376 | key = key[0 : max_key_len - len(aux_hex)] + aux_hex 377 | 378 | return key -------------------------------------------------------------------------------- /src/services/graph.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | class GraphClient(): 5 | def __init__(self, endpoint="https://graph.microsoft.com/v1.0"): 6 | self.endpoint = endpoint 7 | pass 8 | 9 | def schedule_event(self, token: str, subject: str, start: str, end: str): 10 | body = { 11 | "subject": subject, 12 | "start": { 13 | "dateTime": start, 14 | "timeZone": "UTC" 15 | }, 16 | "end": { 17 | "dateTime": end, 18 | "timeZone": "UTC" 19 | } 20 | } 21 | response = requests.post(f"{self.endpoint}/me/events", headers={"Authorization": f"Bearer {token}"}, json=body) 22 | response.raise_for_status() 23 | search_results = response.json() 24 | return json.dumps(search_results) -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/azureai-travel-agent-python/effffd2e06166e942aeda1a51182ca65b5898a06/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/test_bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import pytest 4 | from dotenv import load_dotenv 5 | from unittest.mock import MagicMock 6 | from botbuilder.core import ConversationState, UserState, MemoryStorage, TurnContext 7 | from botbuilder.schema import Attachment as BotAttachment, ChannelAccount 8 | from azure.identity import DefaultAzureCredential, get_bearer_token_provider 9 | from azure.ai.projects import AIProjectClient 10 | from openai import AzureOpenAI 11 | 12 | from bots import AssistantBot 13 | from services.bing import BingClient 14 | from services.graph import GraphClient 15 | from dialogs import LoginDialog 16 | from data_models import Attachment 17 | from utils import create_or_update_agent 18 | 19 | current_directory = os.path.dirname(__file__) 20 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId")) 21 | load_dotenv() 22 | 23 | @pytest.fixture() 24 | async def turn_context(loop): 25 | return MagicMock(spec=TurnContext) 26 | 27 | @pytest.fixture() 28 | async def aoai_client(loop): 29 | aoai_client = AzureOpenAI( 30 | api_version=os.getenv("AZURE_OPENAI_API_VERSION"), 31 | azure_endpoint=os.getenv("AZURE_OPENAI_API_ENDPOINT"), 32 | api_key=os.getenv("AZURE_OPENAI_API_KEY"), 33 | azure_ad_token_provider=get_bearer_token_provider( 34 | credential, 35 | "https://cognitiveservices.azure.com/.default" 36 | ) 37 | ) 38 | # aoai_client = MagicMock(spec=AzureOpenAI) 39 | return aoai_client 40 | 41 | project_client = AIProjectClient.from_connection_string( 42 | credential=credential, 43 | conn_str=os.getenv("AZURE_AI_PROJECT_CONNECTION_STRING") 44 | ) 45 | agents_client = project_client.agents 46 | agent_id = create_or_update_agent(agents_client, os.getenv("AZURE_OPENAI_AGENT_NAME")) 47 | 48 | @pytest.fixture() 49 | async def bot(aoai_client, turn_context): 50 | _bot = AssistantBot( 51 | conversation_state=ConversationState(MemoryStorage()), 52 | user_state=UserState(MemoryStorage()), 53 | aoai_client=aoai_client, 54 | agents_client=agents_client, 55 | agent_id=agent_id, 56 | bing_client=BingClient(os.getenv("AZURE_BING_API_KEY")), 57 | graph_client=GraphClient(), 58 | dialog=LoginDialog() 59 | ) 60 | conversation_data = await _bot.conversation_data_accessor.get(turn_context) 61 | conversation_data.thread_id = None 62 | return _bot 63 | 64 | async def test_welcome_message(bot, turn_context): 65 | await bot.on_members_added_activity([ChannelAccount(id='user1')], turn_context) 66 | turn_context.send_activity.assert_called_with(os.getenv("LLM_WELCOME_MESSAGE")) 67 | 68 | async def test_text_message(bot, turn_context): 69 | turn_context.activity.text = "This is a test. Please respond with \"Test succeeded\"" 70 | await bot.on_message_activity(turn_context) 71 | assert "Test succeeded" in turn_context.send_activity.mock_calls[0][1][0].text 72 | 73 | async def test_teams_message(bot, turn_context): 74 | # Teams messages contain HTML attachments of the same message, which should be ignored 75 | turn_context.activity.text = "This is a test. Please respond with \"Test succeeded\"" 76 | turn_context.activity.attachments = [BotAttachment(content_type="text/html", content="This is a test. Please respond with \"Test succeeded\"", content_url=None)] 77 | await bot.on_message_activity(turn_context) 78 | assert "Test succeeded" in turn_context.send_activity.mock_calls[0][1][0].text 79 | 80 | async def test_streaming_response(bot, turn_context): 81 | turn_context.activity.text = "Please write a paragraph on AI" 82 | turn_context.activity.channel_id = "directline" 83 | await bot.on_message_activity(turn_context) 84 | 85 | assert len(turn_context.send_activity.mock_calls) > 1 86 | # Each new message should contain the previous 87 | for i in range(1, len(turn_context.send_activity.mock_calls)): 88 | assert len(turn_context.send_activity.mock_calls[i][1][0].text) > len(turn_context.send_activity.mock_calls[i-1][1][0].text) 89 | assert turn_context.send_activity.mock_calls[i-1][1][0].text == "Typing..." or \ 90 | turn_context.send_activity.mock_calls[i][1][0].text.startswith(turn_context.send_activity.mock_calls[i-1][1][0].text) 91 | 92 | async def test_updating_response(bot, turn_context): 93 | turn_context.activity.text = "Please write a paragraph on AI" 94 | turn_context.activity.channel_id = "msteams" 95 | await bot.on_message_activity(turn_context) 96 | 97 | assert len(turn_context.update_activity.mock_calls) > 1 98 | # Each new message should contain the previous 99 | for i in range(1, len(turn_context.update_activity.mock_calls)): 100 | assert len(turn_context.update_activity.mock_calls[i][1][0].text) > len(turn_context.update_activity.mock_calls[i-1][1][0].text) 101 | assert turn_context.update_activity.mock_calls[i-1][1][0].text == "Typing..." or \ 102 | turn_context.update_activity.mock_calls[i][1][0].text.startswith(turn_context.update_activity.mock_calls[i-1][1][0].text) 103 | 104 | async def test_image_response(bot, turn_context): 105 | turn_context.activity.text = "Please plot numbers 1-10" 106 | await bot.on_message_activity(turn_context) 107 | # Should contain an image markdown link in any position 108 | assert re.search(r"!\[.*\]\(.*\)", turn_context.send_activity.mock_calls[0][1][0].text) 109 | 110 | async def test_clear_message(bot, turn_context, aoai_client): 111 | conversation_data = await bot.conversation_data_accessor.get(turn_context) 112 | conversation_data.history = ["message_1", "message_2"] 113 | turn_context.activity.text = "clear" 114 | await bot.on_message_activity(turn_context) 115 | # Should clear the thread id and history from the conversation state 116 | assert conversation_data.thread_id is None 117 | assert len(conversation_data.history) == 0 118 | 119 | async def test_file_upload(bot, turn_context, aoai_client): 120 | attachment = MagicMock(spec=BotAttachment) 121 | attachment.name = "file_name.txt" 122 | attachment.content_type = "file_type" 123 | attachment.content_url = "file_url" 124 | attachment.content = None 125 | turn_context.activity.attachments = [attachment] 126 | await bot.on_message_activity(turn_context) 127 | 128 | async def test_teams_upload(bot, turn_context, aoai_client): 129 | attachment = MagicMock(spec=BotAttachment) 130 | attachment.name = "file_name.txt" 131 | attachment.content_type = "file_type" 132 | attachment.content_url = "file_url" 133 | attachment.content = { 134 | "downloadUrl": f"file://{current_directory}/../../data/ContosoBenefits.pdf", 135 | } 136 | turn_context.activity.attachments = [attachment] 137 | await bot.on_message_activity(turn_context) 138 | 139 | async def test_tool_selection(bot, turn_context, aoai_client): 140 | conversation_data = await bot.conversation_data_accessor.get(turn_context) 141 | conversation_data.attachments = [Attachment(name="file_name.txt", content_type="file_type", url=f"file://{current_directory}/../../data/ContosoBenefits.pdf")] 142 | turn_context.activity.text = ":Code Interpreter" 143 | await bot.on_message_activity(turn_context) 144 | 145 | async def test_image_query(bot, turn_context, aoai_client): 146 | attachment = BotAttachment( 147 | name="fork.jpg", 148 | content_type="image/jpeg", 149 | content_url=f"file://{current_directory}/../../data/fork.jpeg" 150 | ) 151 | turn_context.activity.attachments = [attachment] 152 | await bot.on_message_activity(turn_context) 153 | assert "File uploaded: fork.jpg" in turn_context.send_activity.mock_calls[0][1][0].text 154 | assert "Add to a tool?" in turn_context.send_activity.mock_calls[1][1][0].text 155 | conversation_data = await bot.conversation_data_accessor.get(turn_context) 156 | conversation_data.attachments = [Attachment(name="fork.jpg", content_type="image/jpeg", url=f"file://{current_directory}/../../data/fork.jpg")] 157 | turn_context.activity.attachments = [] 158 | turn_context.activity.text = "What's in this image?" 159 | await bot.on_message_activity(turn_context) 160 | assert "fork" in turn_context.send_activity.mock_calls[2][1][0].text 161 | 162 | async def test_file_search(bot, turn_context, aoai_client): 163 | attachment = BotAttachment( 164 | name="ContosoBenefits.pdf", 165 | content_type="image/jpeg", 166 | content_url=f"file://{current_directory}/../../data/ContosoBenefits.pdf" 167 | ) 168 | turn_context.activity.attachments = [attachment] 169 | await bot.on_message_activity(turn_context) 170 | assert "File uploaded: ContosoBenefits.pdf" in turn_context.send_activity.mock_calls[0][1][0].text 171 | assert "Add to a tool?" in turn_context.send_activity.mock_calls[1][1][0].text 172 | conversation_data = await bot.conversation_data_accessor.get(turn_context) 173 | conversation_data.attachments = [Attachment(name="ContosoBenefits.pdf", content_type="application/pdf", url=f"file://{current_directory}/../../data/ContosoBenefits.pdf")] 174 | turn_context.activity.attachments = [] 175 | turn_context.activity.text = ":File Search" 176 | await bot.on_message_activity(turn_context) 177 | assert "added to File Search" in turn_context.send_activity.mock_calls[2][1][0].text 178 | turn_context.activity.text = "What is my dental care coverage limit?" 179 | await bot.on_message_activity(turn_context) 180 | assert "1000" or "1,000" in turn_context.send_activity.mock_calls[3][1][0].text 181 | -------------------------------------------------------------------------------- /src/tests/test_directline.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from unittest.mock import MagicMock 4 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication 5 | from azure.identity import DefaultAzureCredential 6 | from azure.keyvault.secrets import SecretClient 7 | from azure.ai.projects.operations import AgentsOperations 8 | 9 | from config import DefaultConfig 10 | from bots import AssistantBot 11 | from app import create_app 12 | 13 | 14 | @pytest.fixture() 15 | async def client(aiohttp_client): 16 | config = DefaultConfig() 17 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) 18 | bot = MagicMock(spec=AssistantBot) 19 | agents_client = MagicMock(spec=AgentsOperations) 20 | credential = DefaultAzureCredential(managed_identity_client_id=os.getenv("MicrosoftAppId")) 21 | secret_client = SecretClient(vault_url=os.getenv("AZURE_KEY_VAULT_ENDPOINT"), credential=credential) 22 | return await aiohttp_client(create_app(adapter, bot, agents_client, secret_client)) 23 | 24 | async def test_directline_token(client): 25 | resp = await client.get('/api/directline/token') 26 | assert resp.status == 200 27 | data = await resp.json() 28 | assert 'conversationId' in data 29 | assert 'token' in data 30 | assert data['token'].startswith('eyJ') -------------------------------------------------------------------------------- /src/tests/test_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import pytest 4 | from unittest.mock import MagicMock 5 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication 6 | from azure.keyvault.secrets import SecretClient 7 | from azure.ai.projects.operations import AgentsOperations 8 | from openai.resources.files import Files 9 | 10 | from config import DefaultConfig 11 | from bots import AssistantBot 12 | from app import create_app 13 | 14 | current_directory = os.path.dirname(__file__) 15 | 16 | @pytest.fixture() 17 | async def client(aiohttp_client): 18 | config = DefaultConfig() 19 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) 20 | bot = MagicMock(spec=AssistantBot) 21 | agents_client = MagicMock(spec=AgentsOperations) 22 | secrets_client = MagicMock(spec=SecretClient) 23 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client)) 24 | 25 | async def test_file_download(client): 26 | resp = await client.get('/api/files/MY_FILE') 27 | # resp = await client.get('/api/directline/token') 28 | # assert aoai_client.files.content.assert_called_with('my-file-id') 29 | assert resp.status == 200 -------------------------------------------------------------------------------- /src/tests/test_messages.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock 3 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication 4 | from botbuilder.core import TurnContext 5 | from azure.keyvault.secrets import SecretClient 6 | from azure.ai.projects.operations import AgentsOperations 7 | 8 | from config import DefaultConfig 9 | from bots import AssistantBot 10 | from app import create_app 11 | 12 | 13 | config = DefaultConfig() 14 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) 15 | bot = MagicMock(spec=AssistantBot) 16 | agents_client = MagicMock(spec=AgentsOperations) 17 | secrets_client = MagicMock(spec=SecretClient) 18 | @pytest.fixture() 19 | async def client(aiohttp_client): 20 | bot.reset_mock() 21 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client)) 22 | 23 | async def test_welcome_message(client): 24 | bot.configure_mock(**{"on_turn.return_value": "Mock Response"}) 25 | resp = await client.post('/api/messages', json={'type': 'conversationUpdate', 'membersAdded': [{'id': '8c4406d0-9306-11ef-a822-c7d38543b543', 'name': 'Bot'}, {'id': '7c3792c0-c8a3-4df1-9ee8-f5c8b99b53f3', 'name': 'User'}], 'membersRemoved': [], 'channelId': 'emulator', 'conversation': {'id': 'bcabaf70-9311-11ef-a822-c7d38543b543|livechat'}, 'id': 'bcbf3770-9311-11ef-b0e8-69aa58527b92', 'localTimestamp': '2024-10-25T17:43:06-03:00', 'recipient': {'id': '8c4406d0-9306-11ef-a822-c7d38543b543', 'name': 'Bot', 'role': 'bot'}, 'timestamp': '2024-10-25T20:43:06.214Z', 'from': {'id': '7c3792c0-c8a3-4df1-9ee8-f5c8b99b53f3', 'name': 'User', 'role': 'user'}, 'locale': 'en-US', 'serviceUrl': 'http://localhost:55301'}) 26 | assert resp.status == 200 27 | 28 | async def test_invalid_content(client): 29 | resp = await client.post('/api/messages', headers={"Content-Type": "text/plain"}, data="Hello, World!") 30 | assert resp.status == 415 31 | 32 | async def test_adapter_on_error(client): 33 | mockContext = MagicMock(spec=TurnContext) 34 | await adapter.on_turn_error(mockContext, Exception("Test Exception")) 35 | mockContext.send_activity.assert_called_with("Test Exception") -------------------------------------------------------------------------------- /src/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock 3 | from botbuilder.core import TurnContext 4 | from data_models import ConversationData 5 | 6 | @pytest.fixture() 7 | def turn_context(): 8 | return MagicMock(spec=TurnContext) 9 | 10 | def test_add_conversation_turn(turn_context): 11 | conversation_data = ConversationData([], max_turns=6) 12 | conversation_data.add_turn("user", "This is a test.") 13 | conversation_data.add_turn("assistant", "This is a response.") 14 | assert len(conversation_data.history) == 2 15 | conversation_data.add_turn("user", "This is a test.") 16 | conversation_data.add_turn("assistant", "This is a response.") 17 | assert len(conversation_data.history) == 4 18 | conversation_data.add_turn("user", "This is a test.") 19 | conversation_data.add_turn("assistant", "This is a response.") 20 | assert len(conversation_data.history) == 6 21 | # Caps out at max context length 22 | conversation_data.add_turn("user", "This is a test.") 23 | assert len(conversation_data.history) == 6 24 | conversation_data.add_turn("assistant", "This is a response.") 25 | assert len(conversation_data.history) == 6 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/tests/test_sample.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock 3 | from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication 4 | from azure.keyvault.secrets import SecretClient 5 | from azure.ai.projects.operations import AgentsOperations 6 | 7 | from config import DefaultConfig 8 | from bots import AssistantBot 9 | from app import create_app 10 | 11 | 12 | @pytest.fixture() 13 | async def client(aiohttp_client): 14 | config = DefaultConfig() 15 | adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) 16 | bot = MagicMock(spec=AssistantBot) 17 | agents_client = MagicMock(spec=AgentsOperations) 18 | secrets_client = MagicMock(spec=SecretClient) 19 | return await aiohttp_client(create_app(adapter, bot, agents_client, secrets_client)) 20 | 21 | async def test_homepage_has_reference(client): 22 | resp = await client.get('/') 23 | assert resp.status == 200 24 | text = await resp.text() 25 | assert 'https://github.com/microsoft/BotFramework-WebChat' in text -------------------------------------------------------------------------------- /src/tools/BingQuery.txt: -------------------------------------------------------------------------------- 1 | { 2 | "type": "function", 3 | "function": { 4 | "name": "bing_query", 5 | "description": "Search the internet by text using Bing. Terms and conditions require you to explicitly say results were pulled from the web, and list links the links associated with any information you provide. Before using this function, the assistant should always ask whether the user would like it to search the web.", 6 | "parameters": { 7 | "type": "object", 8 | "properties": { 9 | "query": { 10 | "type": "string", 11 | "description": "The query to pass into Bing" 12 | }, 13 | "type": { 14 | "type": "string", 15 | "enum": [ 16 | "webpages", 17 | "images", 18 | "videos", 19 | "news" 20 | ], 21 | "description": "The result type you are looking for. One of \"webpages\",\"images\",\"videos\",\"news\". If no news are returned, you may try webpages as a fallback." 22 | } 23 | }, 24 | "required": [ 25 | "query", 26 | "type" 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/tools/ImageQuery.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "function", 3 | "function": { 4 | "name": "image_query", 5 | "description": "Get information about an image that was uploaded. Valid only for png, jpg/jpeg, gif and webp files.", 6 | "parameters": { 7 | "type": "object", 8 | "properties": { 9 | "query": { 10 | "type": "string", 11 | "description": "The question about the image" 12 | }, 13 | "image_name": { 14 | "type": "string", 15 | "description": "The name of the image file, not including the directory" 16 | } 17 | }, 18 | "required": [ 19 | "query", "image_name" 20 | ] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/tools/ScheduleEvent.txt: -------------------------------------------------------------------------------- 1 | { 2 | "type": "function", 3 | "function": { 4 | "name": "schedule_event", 5 | "description": "Schedules an event on the calendar", 6 | "parameters": { 7 | "type": "object", 8 | "properties": { 9 | "subject": { 10 | "type": "string", 11 | "description": "The title of the event" 12 | }, 13 | "start": { 14 | "type": "string", 15 | "description": "Start time of the event in ISO 8601 format, e.g. 2020-01-01T12:00:00Z" 16 | }, 17 | "end": { 18 | "type": "string", 19 | "description": "End time of the event in ISO 8601 format, e.g. 2020-01-01T12:00:00Z" 20 | } 21 | }, 22 | "required": [ 23 | "subject", 24 | "start", 25 | "end" 26 | ] 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from azure.ai.projects.operations import AgentsOperations 4 | from azure.ai.projects.models import CodeInterpreterTool, FileSearchTool, BingGroundingTool 5 | 6 | def create_or_update_agent( 7 | agents_client: AgentsOperations, 8 | agent_name: str 9 | ) -> str: 10 | # Create agent if it doesn't exist 11 | agents = agents_client.list_agents(limit=100) 12 | 13 | options = { 14 | "name": agent_name, 15 | "model": os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"), 16 | "instructions": os.getenv("LLM_INSTRUCTIONS"), 17 | "tools": [ 18 | *CodeInterpreterTool().definitions, 19 | *FileSearchTool().definitions, 20 | # *BingGroundingTool(connection_id=os.getenv("AZURE_BING_CONNECTION_ID")).definitions 21 | ], 22 | "headers": {"x-ms-enable-preview": "true"} 23 | } 24 | 25 | for tool in os.listdir("tools"): 26 | if tool.endswith(".json"): 27 | with open(f"tools/{tool}", "r") as f: 28 | options["tools"].append(json.loads(f.read())) 29 | if agents.has_more: 30 | raise Exception("Too many agents") 31 | for agent in agents.data: 32 | if agent.name == os.getenv("AZURE_OPENAI_AGENT_NAME"): 33 | options["assistant_id"] = agent.id 34 | agent = agents_client.update_agent(**options) 35 | break 36 | if "assistant_id" not in options: 37 | agent = agents_client.create_agent(**options) 38 | options["assistant_id"] = agent.id 39 | return options["assistant_id"] --------------------------------------------------------------------------------