├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yaml ├── .env.sample ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml └── workflows │ ├── azure-bicep-validate.yaml │ ├── azure-dev.yaml │ └── python-check.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json └── settings.json ├── LICENSE.md ├── README.md ├── SECURITY.md ├── azure.yaml ├── docker-compose.yaml ├── docs ├── README.md ├── arch_diagram.png ├── deploy_existing.md ├── local_ollama.md └── screenshot_chatapp.png ├── infra ├── aca.bicep ├── core │ ├── ai │ │ └── cognitiveservices.bicep │ ├── cache │ │ ├── redis-access.bicep │ │ ├── redis-diagnostics.bicep │ │ └── redis.bicep │ ├── host │ │ ├── container-app-upsert.bicep │ │ ├── container-app.bicep │ │ ├── container-apps-environment.bicep │ │ ├── container-apps.bicep │ │ └── container-registry.bicep │ ├── monitor │ │ └── loganalytics.bicep │ └── security │ │ ├── keyvault-access.bicep │ │ ├── keyvault-secret.bicep │ │ ├── keyvault.bicep │ │ ├── registry-access.bicep │ │ └── role.bicep ├── getkey.sh ├── main.bicep ├── main.parameters.json └── secrets.bicep ├── pyproject.toml ├── requirements-dev.txt ├── scripts ├── auth_common.py ├── auth_init.ps1 ├── auth_init.py ├── auth_init.sh ├── auth_update.ps1 ├── auth_update.py ├── auth_update.sh └── requirements.txt ├── src ├── .dockerignore ├── Dockerfile ├── __init__.py ├── gunicorn.conf.py ├── pyproject.toml ├── quartapp │ ├── __init__.py │ ├── chat.py │ ├── static │ │ └── styles.css │ └── templates │ │ ├── base.html │ │ ├── identity │ │ └── login.html │ │ └── index.html └── requirements.txt └── tests ├── __init__.py ├── conftest.py ├── mock_cred.py ├── snapshots └── test_app │ ├── test_chat_stream_text │ └── result.json │ └── test_chat_stream_text_history │ └── result.json └── test_app.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG IMAGE=bullseye 2 | FROM mcr.microsoft.com/devcontainers/${IMAGE} 3 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openai-chat-app-entra-auth-local", 3 | "dockerComposeFile": "docker-compose.yaml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "forwardPorts": [50505, 6379], 7 | "features": { 8 | "ghcr.io/devcontainers/features/docker-in-docker:latest": { 9 | "dockerDashComposeVersion": "v2" 10 | }, 11 | "ghcr.io/azure/azure-dev/azd:latest": {} 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "ms-azuretools.azure-dev", 17 | "ms-azuretools.vscode-bicep", 18 | "ms-python.python", 19 | "GitHub.vscode-github-actions" 20 | ] 21 | } 22 | }, 23 | "postCreateCommand": "python3 -m pip install -r requirements-dev.txt && python3 -m pip install -e src", 24 | "remoteUser": "vscode", 25 | "hostRequirements": { 26 | "memory": "8gb" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | args: 9 | IMAGE: python:3.12 10 | 11 | volumes: 12 | - ..:/workspace:cached 13 | 14 | # Overrides default command so things don't shut down after the process ends. 15 | command: sleep infinity 16 | 17 | # Runs app on the same network as the redis container, allows "forwardPorts" in devcontainer.json function. 18 | network_mode: host 19 | 20 | cache: 21 | image: redis:7-alpine 22 | restart: unless-stopped 23 | ports: 24 | - '6379:6379' 25 | network_mode: host 26 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # The .env file for local development should be auto-generated by `azd up` - 2 | # see README for more detailed instructions. 3 | AZURE_OPENAI_ENDPOINT= 4 | # Azure OpenAI API version 5 | AZURE_OPENAI_API_VERSION="2024-02-15-preview" 6 | # Name of the Azure OpenAI GPT deployment (different from the model name) 7 | AZURE_OPENAI_CHATGPT_DEPLOYMENT= 8 | # Microsoft Entra authority URL 9 | AZURE_AUTH_AUTHORITY= 10 | # Microsoft Entra app registration client ID 11 | AZURE_AUTH_CLIENT_ID= 12 | # Microsoft Entra app registration client secret name (stored in Azure Key Vault) 13 | AZURE_AUTH_CLIENT_SECRET_NAME="AZURE-AUTH-CLIENT-SECRET" 14 | # Azure Key Vault name 15 | AZURE_KEY_VAULT_NAME= 16 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | 4 | 5 | 6 | ## Does this introduce a breaking change? 7 | 8 | When developers merge from main and run the server, azd up, or azd deploy, will this produce an error? 9 | If you're not sure, try it out on an old environment. 10 | 11 | ``` 12 | [ ] Yes 13 | [ ] No 14 | ``` 15 | 16 | ## Type of change 17 | 18 | ``` 19 | [ ] Bugfix 20 | [ ] Feature 21 | [ ] Code style update (formatting, local variables) 22 | [ ] Refactoring (no functional changes, no api changes) 23 | [ ] Documentation content changes 24 | [ ] Other... Please describe: 25 | ``` 26 | 27 | ## Code quality checklist 28 | 29 | See [CONTRIBUTING.md](https://github.com/Azure-Samples/rag-postgres-openai-python/blob/main/CONTRIBUTING.md#submit-pr) for more details. 30 | 31 | - [ ] The current tests all pass (`python -m pytest`). 32 | - [ ] I added tests that prove my fix is effective or that my feature works 33 | - [ ] I ran `python -m pytest --cov` to verify 100% coverage of added lines 34 | - [ ] I ran `python -m mypy` to check for type errors 35 | - [ ] I either used the pre-commit hooks or ran `ruff` manually on my code. 36 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 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 all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: monthly 12 | groups: 13 | github-actions: 14 | patterns: 15 | - "*" 16 | - package-ecosystem: "pip-compile" # See documentation for possible values 17 | directory: "/" # Location of package manifests 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/azure-bicep-validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate bicep scripts 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**/*.bicep" 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - "**/*.bicep" 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Azure CLI script 23 | uses: azure/CLI@v2 24 | with: 25 | inlineScript: az config set bicep.use_binary_from_path=false && az bicep build -f infra/main.bicep 26 | 27 | - name: Run Microsoft Security DevOps Analysis 28 | uses: microsoft/security-devops-action@preview 29 | id: msdo 30 | continue-on-error: true 31 | with: 32 | tools: templateanalyzer 33 | 34 | - name: Upload alerts to Security tab 35 | uses: github/codeql-action/upload-sarif@v3 36 | if: github.repository_owner == 'Azure-Samples' 37 | with: 38 | sarif_file: ${{ steps.msdo.outputs.sarifFile }} 39 | -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Azure 2 | on: 3 | workflow_dispatch: 4 | push: 5 | # Run when commits are pushed to mainline branch (main or master) 6 | # Set this to the mainline branch you are using 7 | branches: 8 | - main 9 | 10 | # GitHub Actions workflow to deploy to Azure using azd 11 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config` 12 | 13 | # Set up permissions for deploying with secretless Azure federated credentials 14 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 24 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 25 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 26 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 27 | # project specific 28 | AZURE_AUTH_TENANT_ID: ${{ vars.AZURE_AUTH_TENANT_ID }} 29 | AZURE_AUTH_CLIENT_ID: ${{ vars.AZURE_AUTH_CLIENT_ID }} 30 | AZURE_OPENAI_RESOURCE: ${{ vars.AZURE_OPENAI_RESOURCE }} 31 | AZURE_OPENAI_RESOURCE_GROUP: ${{ vars.AZURE_OPENAI_RESOURCE_GROUP }} 32 | AZURE_OPENAI_RESOURCE_GROUP_LOCATION: ${{ vars.AZURE_OPENAI_RESOURCE_GROUP_LOCATION }} 33 | AZURE_OPENAI_SKU_NAME: ${{ vars.AZURE_OPENAI_SKU_NAME }} 34 | AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION }} 35 | CREATE_ROLE_FOR_USER: ${{ vars.CREATE_ROLE_FOR_USER }} 36 | SERVICE_ACA_RESOURCE_EXISTS: ${{ vars.SERVICE_ACA_RESOURCE_EXISTS }} 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | 41 | - name: Install azd 42 | uses: Azure/setup-azd@v2.0.0 43 | 44 | - name: Setup python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: 3.12 48 | architecture: x64 49 | 50 | - name: Install Python requirements for hooks 51 | run: | 52 | python -m pip install --upgrade pip 53 | pip install -r scripts/requirements.txt 54 | 55 | - name: Log in with Azure (Federated Credentials) 56 | if: ${{ env.AZURE_CLIENT_ID != '' }} 57 | run: | 58 | azd auth login ` 59 | --client-id "$Env:AZURE_CLIENT_ID" ` 60 | --federated-credential-provider "github" ` 61 | --tenant-id "$Env:AZURE_TENANT_ID" 62 | shell: pwsh 63 | 64 | - name: Log in with Azure (Client Credentials) 65 | if: ${{ env.AZURE_CREDENTIALS != '' }} 66 | run: | 67 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 68 | Write-Host "::add-mask::$($info.clientSecret)" 69 | 70 | azd auth login ` 71 | --client-id "$($info.clientId)" ` 72 | --client-secret "$($info.clientSecret)" ` 73 | --tenant-id "$($info.tenantId)" 74 | shell: pwsh 75 | env: 76 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 77 | 78 | - name: Provision Infrastructure 79 | run: | 80 | azd env set CREATE_ROLE_FOR_USER false --no-prompt 81 | azd provision --no-prompt 82 | env: 83 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 84 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 85 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 86 | 87 | - name: Deploy Application 88 | run: azd deploy --no-prompt 89 | env: 90 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 91 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 92 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 93 | -------------------------------------------------------------------------------- /.github/workflows/python-check.yaml: -------------------------------------------------------------------------------- 1 | name: Test Python code 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths-ignore: 7 | - "**.md" 8 | - ".devcontainer/**" 9 | - ".github/**" 10 | pull_request: 11 | branches: [ main ] 12 | paths-ignore: 13 | - "**.md" 14 | - ".devcontainer/**" 15 | - ".github/**" 16 | workflow_call: 17 | 18 | jobs: 19 | test_package: 20 | name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: ["ubuntu-20.04"] 26 | python_version: ["3.10", "3.11", "3.12"] 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Setup python 30 | uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python_version }} 33 | architecture: x64 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install -r requirements-dev.txt 38 | - name: Lint with ruff 39 | run: python3 -m ruff check . 40 | - name: Check formatting with black 41 | run: python3 -m black . --check --verbose 42 | - name: Run tests with pytest 43 | run: python3 -m pytest 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .azure 3 | .pyc 4 | __pycache__/ 5 | .coverage 6 | .idea/ 7 | .venv/ 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.1.13 10 | hooks: 11 | - id: ruff 12 | - repo: https://github.com/psf/black 13 | rev: 23.12.1 14 | hooks: 15 | - id: black 16 | args: ['--config=./pyproject.toml'] 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Quart", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "quart", 12 | "env": { 13 | "QUART_APP": "src.quartapp", 14 | "QUART_ENV": "development", 15 | "QUART_DEBUG": "0" 16 | }, 17 | "args": [ 18 | "run", 19 | "--no-reload", 20 | "-p 50505" 21 | ], 22 | "console": "integratedTerminal", 23 | "jinja": true, 24 | "justMyCode": false 25 | }, 26 | { 27 | "name": "Python: Tests", 28 | "type": "debugpy", 29 | "request": "launch", 30 | "program": "${file}", 31 | "purpose": ["debug-test"], 32 | "console": "integratedTerminal", 33 | "env": { "PYTEST_ADDOPTS": "--no-cov" } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "files.exclude": { 8 | ".ruff_cache": true, 9 | ".pytest_cache": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAI Chat Application with Microsoft Entra Authentication (Python) 2 | 3 | 4 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/openai-chat-app-entra-auth-local) 5 | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/openai-chat-app-entra-auth-local) 6 | 7 | This repository includes a Python app that uses Azure OpenAI to generate responses to user messages, and Microsoft Entra for user authentication. 8 | 9 | The project includes all the infrastructure and configuration needed to setup Microsoft Entra authentication, provision Azure OpenAI resources (with keyless access), and deploy the app to [Azure Container Apps](https://learn.microsoft.com/azure/container-apps/overview) using the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview). 10 | 11 | We recommend first going through the [deployment steps](#deployment) before running this app locally, 12 | since the local app needs credentials for Microsoft Entra and Azure OpenAI to work properly. 13 | 14 | ## Features 15 | 16 | * A Python [Quart](https://quart.palletsprojects.com/en/latest/) backend that uses the [identity](https://pypi.org/project/identity/) and [msal](https://pypi.org/project/msal/) packages to authenticate users with Microsoft Entra, and the [openai](https://pypi.org/project/openai/) package to generate responses to user messages. Sessions are stored in Redis. 17 | * A basic HTML/JS frontend that streams responses from the backend using [JSON Lines](http://jsonlines.org/) over a [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). 18 | * [Bicep files](https://docs.microsoft.com/azure/azure-resource-manager/bicep/) for provisioning Azure resources, including an Azure OpenAI resource, Azure Container Apps, Azure Container Registry, Azure Cache for Redis, and Azure Log Analytics. 19 | * Python scripts that use the [msgraph-sdk](https://pypi.org/project/msgraph-sdk/) package to create a Microsoft Entra application and service principal, and to grant the service principal permissions to the application. 20 | 21 | ![Screenshot of the chat app](docs/screenshot_chatapp.png) 22 | 23 | ## Opening the project 24 | 25 | You have a few options for getting started with this template. 26 | The quickest way to get started is GitHub Codespaces, since it will setup all the tools for you, but you can also [set it up locally](#local-environment). 27 | 28 | ### GitHub Codespaces 29 | 30 | You can run this template virtually by using GitHub Codespaces. The button will open a web-based VS Code instance in your browser: 31 | 32 | 1. Open the template (this may take several minutes): 33 | 34 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/openai-chat-app-entra-auth-local) 35 | 36 | 2. Open a terminal window 37 | 3. Continue with the [deployment steps](#deployment) 38 | 39 | 40 | ### VS Code Dev Containers 41 | 42 | A related option is VS Code Dev Containers, which will open the project in your local VS Code using the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers): 43 | 44 | 1. Start Docker Desktop (install it if not already installed) 45 | 2. Open the project: 46 | 47 | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/openai-chat-app-entra-auth-local) 48 | 49 | 3. In the VS Code window that opens, once the project files show up (this may take several minutes), open a terminal window. 50 | 4. Continue with the [deployment steps](#deployment) 51 | 52 | ### Local Environment 53 | 54 | If you're not using one of the above options for opening the project, then you'll need to: 55 | 56 | 1. Make sure the following tools are installed: 57 | 58 | * [Azure Developer CLI (azd)](https://aka.ms/install-azd) 59 | * [Python 3.10+](https://www.python.org/downloads/) 60 | * [Redis](https://redis.io/download) 61 | * [Docker Desktop](https://www.docker.com/products/docker-desktop/) 62 | * [Git](https://git-scm.com/downloads) 63 | 64 | 2. Download the project code: 65 | 66 | ```shell 67 | azd init -t openai-chat-app-entra-auth-local 68 | ``` 69 | 70 | 3. Open the project folder 71 | 4. Create a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) and activate it. 72 | 5. Install required Python packages: 73 | 74 | ```shell 75 | pip install -r requirements-dev.txt 76 | ``` 77 | 78 | 6. Install the app as an editable package: 79 | 80 | ```shell 81 | python3 -m pip install -e src 82 | ``` 83 | 84 | 7. Start a redis server: 85 | 86 | ```shell 87 | brew services start redis 88 | ``` 89 | 90 | 7. Continue with the [deployment steps](#deployment) 91 | 92 | ## Deployment 93 | 94 | Once you've opened the project in [Codespaces](#github-codespaces), in [Dev Containers](#vs-code-dev-containers), or [locally](#local-environment), you can deploy it to Azure. 95 | 96 | ### Azure account setup 97 | 98 | 1. Sign up for a [free Azure account](https://azure.microsoft.com/free/) and create an Azure Subscription. 99 | 2. Request access to Azure OpenAI Service by completing the form at [https://aka.ms/oai/access](https://aka.ms/oai/access) and awaiting approval. 100 | 3. Check that you have the necessary permissions: 101 | 102 | * Your Azure account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [Role Based Access Control Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview), [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator), or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner). If you don't have subscription-level permissions, you must be granted [RBAC](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview) for an existing resource group and [deploy to that existing group](docs/deploy_existing.md#resource-group). 103 | * Your Azure account also needs `Microsoft.Resources/deployments/write` permissions on the subscription level. 104 | 105 | ### Deployment with Azure Developer CLI 106 | 107 | 1. Sign in to your Azure account: 108 | 109 | ```shell 110 | azd auth login 111 | ``` 112 | 113 | If you have any issues with that command, you may also want to try `azd auth login --use-device-code`. 114 | 115 | 1. If you will be setting up Entra authentication in a different tenant, login with that tenant as well: 116 | 117 | ```shell 118 | azd auth login --tenant-id AUTH-TENANT-ID 119 | ``` 120 | 121 | 1. Create a new azd environment: 122 | 123 | ```shell 124 | azd env new 125 | ``` 126 | 127 | This will create a folder under `.azure/` in your project to store the configuration for this deployment. You may have multiple azd environments if desired. 128 | 129 | 1. Set the `AZURE_AUTH_TENANT_ID` azd environment variable to whichever tenant ID you want to use for Entra authentication: 130 | 131 | ```shell 132 | azd env set AZURE_AUTH_TENANT_ID your-tenant-id 133 | ``` 134 | 135 | 1. Provision and deploy all the resources: 136 | 137 | ```shell 138 | azd up 139 | ``` 140 | 141 | It will prompt you to provide an `azd` environment name (like "chat-app") and select a subscription from your Azure account. Then it will provision the resources in your account and deploy the latest code. Provisioning may take ~30 minutes, especially given the time taken to provision an Azure Cache for Redis. If you get an error with deployment, changing the location can help, as there may be availability constraints for the OpenAI resource. 142 | 143 | 1. When `azd` has finished deploying, you'll see an endpoint URI in the command output. Visit that URI, and you should see the chat app! 🎉 144 | 1. When you've made any changes to the app code, you can just run: 145 | 146 | ```shell 147 | azd deploy 148 | ``` 149 | 150 | ### CI/CD pipeline 151 | 152 | This project includes a Github workflow for deploying the resources to Azure 153 | on every push to main. That workflow requires several Azure-related authentication secrets 154 | to be stored as Github action secrets. To set that up, run: 155 | 156 | ```shell 157 | azd pipeline config 158 | ``` 159 | 160 | ## Local development 161 | 162 | Assuming you've run the steps in [Opening the project](#opening-the-project) and have run `azd up`, you can now run the Quart app locally using the development server: 163 | 164 | ```shell 165 | python -m quart --app src.quartapp run --port 50505 --reload 166 | ``` 167 | 168 | This will start the app on port 50505, and you can access it at `http://localhost:50505`. 169 | 170 | To save costs during development, you may point the app at a [local LLM server](docs/local_ollama.md). 171 | 172 | ## Costs 173 | 174 | Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage. 175 | The majority of the Azure resources used in this infrastructure are on usage-based pricing tiers. 176 | However, Azure Container Registry has a fixed cost per registry per day. 177 | 178 | You can try the [Azure pricing calculator](https://azure.com/e/2176802ea14941e4959eae8ad335aeb5) for the resources: 179 | 180 | - Azure OpenAI Service: S0 tier, ChatGPT model. Pricing is based on token count. [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) 181 | - Azure Container App: Consumption tier with 0.5 CPU, 1GiB memory/storage. Pricing is based on resource allocation, and each month allows for a certain amount of free usage. [Pricing](https://azure.microsoft.com/pricing/details/container-apps/) 182 | - Azure Container Registry: Basic tier. [Pricing](https://azure.microsoft.com/pricing/details/container-registry/) 183 | - Azure Cache for Redis: Basic tier. [Pricing](https://azure.microsoft.com/pricing/details/cache/) 184 | - Log analytics: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/) 185 | 186 | ⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use, 187 | either by deleting the resource group in the Portal or running `azd down`. 188 | 189 | 190 | ## Security Guidelines 191 | 192 | This template uses [Managed Identity](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview) for authenticating to the Azure services used (Azure OpenAI, Azure Cache for Redis). It uses an [Azure Key Vault](https://learn.microsoft.com/azure/key-vault/general/basic-concepts) to store the client secret for the Microsoft Entra application. 193 | 194 | Additionally, we have added a [GitHub Action](https://github.com/microsoft/security-devops-action) that scans the infrastructure-as-code files and generates a report containing any detected issues. To ensure continued best practices in your own repository, we recommend that anyone creating solutions based on our templates ensure that the [Github secret scanning](https://docs.github.com/code-security/secret-scanning/about-secret-scanning) setting is enabled. 195 | 196 | You may want to consider additional security measures, such as: 197 | 198 | * Protecting the Azure Cache for Redis instance with a firewall and/or [Virtual Network](https://learn.microsoft.com/azure/azure-cache-for-redis/cache-network-isolation). 199 | * Protecting the Azure Container Apps instance with a [firewall](https://learn.microsoft.com/azure/container-apps/waf-app-gateway) and/or [Virtual Network](https://learn.microsoft.com/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli). 200 | * Using [certificates](https://learn.microsoft.com/entra/identity/authentication/how-to-certificate-based-authentication) instead of client secrets for the Microsoft Entra application. 201 | 202 | ## Resources 203 | 204 | * [OpenAI Chat App with Managed Identity](https://github.com/Azure-Samples/openai-chat-app-quickstart): Similar to this project, but doesn't include Microsoft Entra authentication. 205 | * [RAG chat with Azure AI Search + Python](https://github.com/Azure-Samples/azure-search-openai-demo/): A more advanced chat app that uses Azure AI Search to ground responses in domain knowledge. 206 | * [Develop Python apps that use Azure AI services](https://learn.microsoft.com/azure/developer/python/azure-ai-for-python-developers) 207 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: openai-chat-app-with-userauth-msal 4 | metadata: 5 | template: openai-chat-app-with-userauth-msal@0.1.0-beta 6 | services: 7 | aca: 8 | project: ./src 9 | language: py 10 | host: containerapp 11 | hooks: 12 | preprovision: 13 | posix: 14 | shell: sh 15 | run: ./scripts/auth_init.sh; 16 | interactive: true 17 | continueOnError: false 18 | windows: 19 | shell: pwsh 20 | run: ./scripts/auth_init.ps1; 21 | interactive: true 22 | continueOnError: false 23 | postprovision: 24 | posix: 25 | shell: sh 26 | run: ./scripts/auth_update.sh; azd env get-values > .env; 27 | interactive: true 28 | continueOnError: false 29 | windows: 30 | shell: pwsh 31 | run: ./scripts/auth_update.ps1; $output = azd env get-values; Add-Content -Path .env -Value $output; 32 | interactive: true 33 | continueOnError: false 34 | pipeline: 35 | variables: 36 | - AZURE_AUTH_TENANT_ID 37 | - AZURE_AUTH_CLIENT_ID 38 | - AZURE_OPENAI_RESOURCE 39 | - AZURE_OPENAI_RESOURCE_GROUP 40 | - AZURE_OPENAI_RESOURCE_GROUP_LOCATION 41 | - AZURE_OPENAI_SKU_NAME 42 | - AZURE_OPENAI_API_VERSION 43 | - CREATE_ROLE_FOR_USER 44 | - SERVICE_ACA_RESOURCE_EXISTS 45 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: ./src 5 | env_file: 6 | - .env 7 | ports: 8 | - 50505:50505 9 | volumes: 10 | - ./src:/code 11 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: OpenAI Chat Application with Microsoft Entra Authentication 3 | description: A simple chat application that integrates Microsoft Entra for user authentication. Designed for deployment on Azure Container Apps with the Azure Developer CLI. 4 | languages: 5 | - azdeveloper 6 | - python 7 | - bicep 8 | - html 9 | products: 10 | - azure 11 | - azure-container-apps 12 | - azure-openai 13 | - azure-container-registry 14 | - entra-id 15 | page_type: sample 16 | urlFragment: openai-chat-app-entra-auth-local 17 | --- 18 | 19 | 20 | 21 | This repository includes a Python app that uses Azure OpenAI to generate responses to user messages, and Microsoft Entra for user authentication. 22 | 23 | The project includes all the infrastructure and configuration needed to setup Microsoft Entra authentication, provision Azure OpenAI resources (with keyless access), and deploy the app to [Azure Container Apps](https://learn.microsoft.com/azure/container-apps/overview) using the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview). 24 | 25 | 26 | For instructions on deploying this project to Azure, please refer to the [README on GitHub](https://github.com/Azure-Samples/openai-chat-app-entra-auth-local/?tab=readme-ov-file). 27 | -------------------------------------------------------------------------------- /docs/arch_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-chat-app-entra-auth-local/5148af1db0ffd98973a649df7cfb710a4fd22904/docs/arch_diagram.png -------------------------------------------------------------------------------- /docs/deploy_existing.md: -------------------------------------------------------------------------------- 1 | 2 | # Deploying with existing Azure resources 3 | 4 | If you already have existing Azure resources, or if you want to specify the exact name of new Azure Resource, you can do so by setting `azd` environment values. 5 | You should set these values before running `azd up`. Once you've set them, return to the [deployment steps](../README.md#deployment). 6 | 7 | * [Resource group](#resource-group) 8 | * [OpenAI resource](#openai-resource) 9 | 10 | ## Resource group 11 | 12 | 1. Run `azd env set AZURE_RESOURCE_GROUP {Name of existing resource group}` 13 | 1. Run `azd env set AZURE_LOCATION {Location of existing resource group}` 14 | 15 | ## OpenAI resource 16 | 17 | If you already have an OpenAI resource and would like to re-use it, run `azd env set` to specify the values for the existing OpenAI resource. 18 | 19 | ```shell 20 | azd env set AZURE_OPENAI_RESOURCE {name of OpenAI resource} 21 | azd env set AZURE_OPENAI_RESOURCE_GROUP {name of resource group that it's inside} 22 | azd env set AZURE_OPENAI_RESOURCE_GROUP_LOCATION {location for that group} 23 | azd env set AZURE_OPENAI_SKU_NAME {name of the SKU, defaults to "S0"} 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/local_ollama.md: -------------------------------------------------------------------------------- 1 | # Running against a local LLM server 2 | 3 | You may want to save costs by developing against a local LLM server, such as 4 | [llamafile](https://github.com/Mozilla-Ocho/llamafile/). Note that a local LLM 5 | will generally be slower and not as sophisticated. 6 | 7 | Once you've got your local LLM running and serving an OpenAI-compatible endpoint, define `LOCAL_OPENAI_ENDPOINT` in your `.env` file. 8 | 9 | For example, to point at a local llamafile server running on its default port: 10 | 11 | ```shell 12 | LOCAL_OPENAI_ENDPOINT="http://localhost:8080/v1" 13 | ``` 14 | 15 | If you're running inside a dev container, use this local URL instead: 16 | 17 | ```shell 18 | LOCAL_OPENAI_ENDPOINT="http://host.docker.internal:8080/v1" 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/screenshot_chatapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-chat-app-entra-auth-local/5148af1db0ffd98973a649df7cfb710a4fd22904/docs/screenshot_chatapp.png -------------------------------------------------------------------------------- /infra/aca.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param identityName string 6 | param containerAppsEnvironmentName string 7 | param containerRegistryName string 8 | param serviceName string = 'aca' 9 | param exists bool 10 | param openAiDeploymentName string 11 | param openAiEndpoint string 12 | param openAiApiVersion string 13 | param keyVaultName string 14 | param authClientSecretName string 15 | param authClientId string 16 | param authAuthority string 17 | param redisHost string 18 | 19 | resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 20 | name: identityName 21 | location: location 22 | } 23 | 24 | module app 'core/host/container-app-upsert.bicep' = { 25 | name: '${serviceName}-container-app-module' 26 | params: { 27 | name: name 28 | location: location 29 | tags: union(tags, { 'azd-service-name': serviceName }) 30 | identityName: acaIdentity.name 31 | exists: exists 32 | containerAppsEnvironmentName: containerAppsEnvironmentName 33 | containerRegistryName: containerRegistryName 34 | env: [ 35 | { 36 | name: 'AZURE_OPENAI_CHATGPT_DEPLOYMENT' 37 | value: openAiDeploymentName 38 | } 39 | { 40 | name: 'AZURE_OPENAI_ENDPOINT' 41 | value: openAiEndpoint 42 | } 43 | { 44 | name: 'AZURE_OPENAI_API_VERSION' 45 | value: openAiApiVersion 46 | } 47 | { 48 | name: 'RUNNING_IN_PRODUCTION' 49 | value: 'true' 50 | } 51 | { 52 | name: 'AZURE_CLIENT_ID' 53 | value: acaIdentity.properties.clientId 54 | } 55 | { 56 | name: 'AZURE_AUTH_CLIENT_SECRET_NAME' 57 | value: authClientSecretName 58 | } 59 | { 60 | name: 'AZURE_AUTH_CLIENT_ID' 61 | value: authClientId 62 | } 63 | { 64 | name: 'AZURE_AUTH_AUTHORITY' 65 | value: authAuthority 66 | } 67 | { 68 | name: 'AZURE_KEY_VAULT_NAME' 69 | value: keyVaultName 70 | } 71 | { 72 | name: 'AZURE_REDIS_USER' 73 | value: acaIdentity.properties.principalId 74 | } 75 | { 76 | name: 'AZURE_REDIS_HOST' 77 | value: redisHost 78 | } 79 | ] 80 | targetPort: 50505 81 | } 82 | } 83 | 84 | output SERVICE_ACA_IDENTITY_PRINCIPAL_ID string = acaIdentity.properties.principalId 85 | output SERVICE_ACA_NAME string = app.outputs.name 86 | output SERVICE_ACA_URI string = app.outputs.uri 87 | output SERVICE_ACA_IMAGE_NAME string = app.outputs.imageName 88 | -------------------------------------------------------------------------------- /infra/core/ai/cognitiveservices.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param customSubDomainName string = name 6 | param deployments array = [] 7 | param kind string = 'OpenAI' 8 | param publicNetworkAccess string = 'Enabled' 9 | param sku object = { 10 | name: 'S0' 11 | } 12 | 13 | resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { 14 | name: name 15 | location: location 16 | tags: tags 17 | kind: kind 18 | properties: { 19 | customSubDomainName: customSubDomainName 20 | publicNetworkAccess: publicNetworkAccess 21 | } 22 | sku: sku 23 | } 24 | 25 | @batchSize(1) 26 | resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { 27 | parent: account 28 | name: deployment.name 29 | properties: { 30 | model: deployment.model 31 | raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null 32 | } 33 | sku: contains(deployment, 'sku') ? deployment.sku : { 34 | name: 'Standard' 35 | capacity: 20 36 | } 37 | }] 38 | 39 | output endpoint string = account.properties.endpoint 40 | output id string = account.id 41 | output name string = account.name 42 | output skuName string = account.sku.name 43 | output key string = account.listKeys().key1 44 | -------------------------------------------------------------------------------- /infra/core/cache/redis-access.bicep: -------------------------------------------------------------------------------- 1 | param redisCacheName string 2 | 3 | @description('Specify name of Built-In access policy to use as assignment.') 4 | @allowed([ 5 | 'Data Owner' 6 | 'Data Contributor' 7 | 'Data Reader' 8 | ]) 9 | param builtInAccessPolicyName string = 'Data Contributor' 10 | 11 | param principalId string 12 | 13 | param accessPolicyAlias string 14 | 15 | resource redisCache 'Microsoft.Cache/Redis@2023-08-01' existing = { 16 | name: redisCacheName 17 | } 18 | 19 | resource redisCacheBuiltInAccessPolicyAssignment 'Microsoft.Cache/redis/accessPolicyAssignments@2023-08-01' = { 20 | name: '${redisCacheName}-assignment-${uniqueString(principalId)}' 21 | parent: redisCache 22 | properties: { 23 | accessPolicyName: builtInAccessPolicyName 24 | objectId: principalId 25 | objectIdAlias: accessPolicyAlias 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /infra/core/cache/redis-diagnostics.bicep: -------------------------------------------------------------------------------- 1 | param cacheName string = '' 2 | 3 | @description('Resource ID of log analytics workspace.') 4 | param diagnosticWorkspaceId string 5 | 6 | @description('Optional. The name of logs that will be streamed. "allLogs" includes all possible logs for the resource. Set to `[]` to disable log collection.') 7 | param diagnosticLogCategoriesToEnable array = [ 8 | 'allLogs' 9 | ] 10 | 11 | @description('Optional. The name of metrics that will be streamed.') 12 | param diagnosticMetricsToEnable array = [ 13 | 'AllMetrics' 14 | ] 15 | 16 | var diagnosticsLogs = [ 17 | { 18 | categoryGroup: 'allLogs' 19 | enabled: true 20 | } 21 | ] 22 | 23 | var diagnosticsMetrics = [ 24 | for metric in diagnosticMetricsToEnable: { 25 | category: metric 26 | timeGrain: null 27 | enabled: true 28 | } 29 | ] 30 | 31 | resource redisCache 'Microsoft.Cache/Redis@2023-08-01' existing = { 32 | name: cacheName 33 | } 34 | 35 | resource cache_diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { 36 | name: '${cacheName}-diagnostics' 37 | scope: redisCache 38 | properties: { 39 | workspaceId: diagnosticWorkspaceId 40 | metrics: diagnosticsMetrics 41 | logs: diagnosticsLogs 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /infra/core/cache/redis.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | resource redisCache 'Microsoft.Cache/Redis@2023-08-01' = { 6 | name: name 7 | location: location 8 | tags: tags 9 | properties: { 10 | enableNonSslPort: false 11 | minimumTlsVersion: '1.2' 12 | sku: { 13 | capacity: 1 14 | family: 'C' 15 | name: 'Basic' 16 | } 17 | redisConfiguration: { 18 | 'aad-enabled': 'true' 19 | } 20 | } 21 | } 22 | 23 | output name string = redisCache.name 24 | output id string = redisCache.id 25 | output hostName string = redisCache.properties.hostName 26 | -------------------------------------------------------------------------------- /infra/core/host/container-app-upsert.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param containerAppsEnvironmentName string 6 | param containerName string = 'main' 7 | param containerRegistryName string 8 | 9 | @description('Minimum number of replicas to run') 10 | @minValue(1) 11 | param containerMinReplicas int = 1 12 | @description('Maximum number of replicas to run') 13 | @minValue(1) 14 | param containerMaxReplicas int = 10 15 | 16 | param secrets array = [] 17 | param env array = [] 18 | param external bool = true 19 | param targetPort int = 80 20 | param exists bool 21 | 22 | @description('User assigned identity name') 23 | param identityName string 24 | 25 | @description('Enabled Ingress for container app') 26 | param ingressEnabled bool = true 27 | 28 | // Dapr Options 29 | @description('Enable Dapr') 30 | param daprEnabled bool = false 31 | @description('Dapr app ID') 32 | param daprAppId string = containerName 33 | @allowed([ 'http', 'grpc' ]) 34 | @description('Protocol used by Dapr to connect to the app, e.g. http or grpc') 35 | param daprAppProtocol string = 'http' 36 | 37 | @description('CPU cores allocated to a single container instance, e.g. 0.5') 38 | param containerCpuCoreCount string = '0.5' 39 | 40 | @description('Memory allocated to a single container instance, e.g. 1Gi') 41 | param containerMemory string = '1.0Gi' 42 | 43 | resource existingApp 'Microsoft.App/containerApps@2022-03-01' existing = if (exists) { 44 | name: name 45 | } 46 | 47 | module app 'container-app.bicep' = { 48 | name: '${deployment().name}-update' 49 | params: { 50 | name: name 51 | location: location 52 | tags: tags 53 | identityName: identityName 54 | ingressEnabled: ingressEnabled 55 | containerName: containerName 56 | containerAppsEnvironmentName: containerAppsEnvironmentName 57 | containerRegistryName: containerRegistryName 58 | containerCpuCoreCount: containerCpuCoreCount 59 | containerMemory: containerMemory 60 | containerMinReplicas: containerMinReplicas 61 | containerMaxReplicas: containerMaxReplicas 62 | daprEnabled: daprEnabled 63 | daprAppId: daprAppId 64 | daprAppProtocol: daprAppProtocol 65 | secrets: secrets 66 | external: external 67 | env: env 68 | imageName: exists ? existingApp.properties.template.containers[0].image : '' 69 | targetPort: targetPort 70 | } 71 | } 72 | 73 | output defaultDomain string = app.outputs.defaultDomain 74 | output imageName string = app.outputs.imageName 75 | output name string = app.outputs.name 76 | output uri string = app.outputs.uri 77 | -------------------------------------------------------------------------------- /infra/core/host/container-app.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param containerAppsEnvironmentName string 6 | param containerName string = 'main' 7 | param containerRegistryName string 8 | 9 | @description('Minimum number of replicas to run') 10 | @minValue(1) 11 | param containerMinReplicas int = 1 12 | @description('Maximum number of replicas to run') 13 | @minValue(1) 14 | param containerMaxReplicas int = 10 15 | 16 | param secrets array = [] 17 | param env array = [] 18 | param external bool = true 19 | param imageName string 20 | param targetPort int = 80 21 | 22 | @description('User assigned identity name') 23 | param identityName string 24 | 25 | @description('Enabled Ingress for container app') 26 | param ingressEnabled bool = true 27 | 28 | // Dapr Options 29 | @description('Enable Dapr') 30 | param daprEnabled bool = false 31 | @description('Dapr app ID') 32 | param daprAppId string = containerName 33 | @allowed([ 'http', 'grpc' ]) 34 | @description('Protocol used by Dapr to connect to the app, e.g. http or grpc') 35 | param daprAppProtocol string = 'http' 36 | 37 | @description('CPU cores allocated to a single container instance, e.g. 0.5') 38 | param containerCpuCoreCount string = '0.5' 39 | 40 | @description('Memory allocated to a single container instance, e.g. 1Gi') 41 | param containerMemory string = '1.0Gi' 42 | 43 | resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { 44 | name: identityName 45 | } 46 | 47 | module containerRegistryAccess '../security/registry-access.bicep' = { 48 | name: '${deployment().name}-registry-access' 49 | params: { 50 | containerRegistryName: containerRegistryName 51 | principalId: userIdentity.properties.principalId 52 | } 53 | } 54 | 55 | resource app 'Microsoft.App/containerApps@2022-03-01' = { 56 | name: name 57 | location: location 58 | tags: tags 59 | // It is critical that the identity is granted ACR pull access before the app is created 60 | // otherwise the container app will throw a provision error 61 | // This also forces us to use an user assigned managed identity since there would no way to 62 | // provide the system assigned identity with the ACR pull access before the app is created 63 | dependsOn: [ containerRegistryAccess ] 64 | identity: { 65 | type: 'UserAssigned' 66 | userAssignedIdentities: { '${userIdentity.id}': {} } 67 | } 68 | properties: { 69 | managedEnvironmentId: containerAppsEnvironment.id 70 | configuration: { 71 | activeRevisionsMode: 'single' 72 | ingress: ingressEnabled ? { 73 | external: external 74 | targetPort: targetPort 75 | transport: 'auto' 76 | } : null 77 | dapr: daprEnabled ? { 78 | enabled: true 79 | appId: daprAppId 80 | appProtocol: daprAppProtocol 81 | appPort: ingressEnabled ? targetPort : 0 82 | } : { enabled: false } 83 | secrets: secrets 84 | registries: [ 85 | { 86 | server: '${containerRegistry.name}.azurecr.io' 87 | identity: userIdentity.id 88 | } 89 | ] 90 | } 91 | template: { 92 | containers: [ 93 | { 94 | image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' 95 | name: containerName 96 | env: env 97 | resources: { 98 | cpu: json(containerCpuCoreCount) 99 | memory: containerMemory 100 | } 101 | } 102 | ] 103 | scale: { 104 | minReplicas: containerMinReplicas 105 | maxReplicas: containerMaxReplicas 106 | } 107 | } 108 | } 109 | } 110 | 111 | resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-03-01' existing = { 112 | name: containerAppsEnvironmentName 113 | } 114 | 115 | // 2022-02-01-preview needed for anonymousPullEnabled 116 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = { 117 | name: containerRegistryName 118 | } 119 | 120 | output defaultDomain string = containerAppsEnvironment.properties.defaultDomain 121 | output imageName string = imageName 122 | output name string = app.name 123 | output uri string = 'https://${app.properties.configuration.ingress.fqdn}' 124 | -------------------------------------------------------------------------------- /infra/core/host/container-apps-environment.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param daprEnabled bool = false 6 | param logAnalyticsWorkspaceName string 7 | param applicationInsightsName string = '' 8 | 9 | resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-03-01' = { 10 | name: name 11 | location: location 12 | tags: tags 13 | properties: { 14 | appLogsConfiguration: { 15 | destination: 'log-analytics' 16 | logAnalyticsConfiguration: { 17 | customerId: logAnalyticsWorkspace.properties.customerId 18 | sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey 19 | } 20 | } 21 | daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' 22 | } 23 | } 24 | 25 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { 26 | name: logAnalyticsWorkspaceName 27 | } 28 | 29 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)){ 30 | name: applicationInsightsName 31 | } 32 | 33 | output defaultDomain string = containerAppsEnvironment.properties.defaultDomain 34 | output name string = containerAppsEnvironment.name 35 | -------------------------------------------------------------------------------- /infra/core/host/container-apps.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param containerAppsEnvironmentName string 6 | param containerRegistryName string 7 | param logAnalyticsWorkspaceName string 8 | param applicationInsightsName string = '' 9 | 10 | module containerAppsEnvironment 'container-apps-environment.bicep' = { 11 | name: '${name}-container-apps-environment' 12 | params: { 13 | name: containerAppsEnvironmentName 14 | location: location 15 | tags: tags 16 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 17 | applicationInsightsName: applicationInsightsName 18 | } 19 | } 20 | 21 | module containerRegistry 'container-registry.bicep' = { 22 | name: '${name}-container-registry' 23 | params: { 24 | name: containerRegistryName 25 | location: location 26 | tags: tags 27 | } 28 | } 29 | 30 | output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain 31 | output environmentName string = containerAppsEnvironment.outputs.name 32 | output registryLoginServer string = containerRegistry.outputs.loginServer 33 | output registryName string = containerRegistry.outputs.name 34 | -------------------------------------------------------------------------------- /infra/core/host/container-registry.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param adminUserEnabled bool = true 6 | param anonymousPullEnabled bool = false 7 | param dataEndpointEnabled bool = false 8 | param encryption object = { 9 | status: 'disabled' 10 | } 11 | param networkRuleBypassOptions string = 'AzureServices' 12 | param publicNetworkAccess string = 'Enabled' 13 | param sku object = { 14 | name: 'Basic' 15 | } 16 | param zoneRedundancy string = 'Disabled' 17 | 18 | @description('The log analytics workspace id used for logging & monitoring') 19 | param workspaceId string = '' 20 | 21 | // 2022-02-01-preview needed for anonymousPullEnabled 22 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' = { 23 | name: name 24 | location: location 25 | tags: tags 26 | sku: sku 27 | properties: { 28 | adminUserEnabled: adminUserEnabled 29 | anonymousPullEnabled: anonymousPullEnabled 30 | dataEndpointEnabled: dataEndpointEnabled 31 | encryption: encryption 32 | networkRuleBypassOptions: networkRuleBypassOptions 33 | publicNetworkAccess: publicNetworkAccess 34 | zoneRedundancy: zoneRedundancy 35 | } 36 | } 37 | 38 | // TODO: Update diagnostics to be its own module 39 | // Blocking issue: https://github.com/Azure/bicep/issues/622 40 | // Unable to pass in a `resource` scope or unable to use string interpolation in resource types 41 | resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { 42 | name: 'registry-diagnostics' 43 | scope: containerRegistry 44 | properties: { 45 | workspaceId: workspaceId 46 | logs: [ 47 | { 48 | category: 'ContainerRegistryRepositoryEvents' 49 | enabled: true 50 | } 51 | { 52 | category: 'ContainerRegistryLoginEvents' 53 | enabled: true 54 | } 55 | ] 56 | metrics: [ 57 | { 58 | category: 'AllMetrics' 59 | enabled: true 60 | timeGrain: 'PT1M' 61 | } 62 | ] 63 | } 64 | } 65 | 66 | output loginServer string = containerRegistry.properties.loginServer 67 | output name string = containerRegistry.name 68 | -------------------------------------------------------------------------------- /infra/core/monitor/loganalytics.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { 6 | name: name 7 | location: location 8 | tags: tags 9 | properties: any({ 10 | retentionInDays: 30 11 | features: { 12 | searchVersion: 1 13 | } 14 | sku: { 15 | name: 'PerGB2018' 16 | } 17 | }) 18 | } 19 | 20 | output id string = logAnalytics.id 21 | output name string = logAnalytics.name 22 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns an Azure Key Vault access policy.' 2 | param name string = 'add' 3 | 4 | param keyVaultName string 5 | param permissions object = { secrets: [ 'get', 'list' ] } 6 | param principalId string 7 | 8 | resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-07-01' = { 9 | parent: keyVault 10 | name: name 11 | properties: { 12 | accessPolicies: [ { 13 | objectId: principalId 14 | tenantId: subscription().tenantId 15 | permissions: permissions 16 | } ] 17 | } 18 | } 19 | 20 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 21 | name: keyVaultName 22 | } 23 | -------------------------------------------------------------------------------- /infra/core/security/keyvault-secret.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates or updates a secret in an Azure Key Vault.' 2 | param name string 3 | param tags object = {} 4 | param keyVaultName string 5 | param contentType string = 'string' 6 | @description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') 7 | @secure() 8 | param secretValue string 9 | 10 | param enabled bool = true 11 | param exp int = 0 12 | param nbf int = 0 13 | 14 | resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { 15 | name: name 16 | tags: tags 17 | parent: keyVault 18 | properties: { 19 | attributes: { 20 | enabled: enabled 21 | exp: exp 22 | nbf: nbf 23 | } 24 | contentType: contentType 25 | value: secretValue 26 | } 27 | } 28 | 29 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { 30 | name: keyVaultName 31 | } 32 | -------------------------------------------------------------------------------- /infra/core/security/keyvault.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { 6 | name: name 7 | location: location 8 | tags: tags 9 | properties: { 10 | tenantId: subscription().tenantId 11 | sku: { family: 'A', name: 'standard' } 12 | enableRbacAuthorization: true 13 | } 14 | } 15 | 16 | output endpoint string = keyVault.properties.vaultUri 17 | output name string = keyVault.name 18 | -------------------------------------------------------------------------------- /infra/core/security/registry-access.bicep: -------------------------------------------------------------------------------- 1 | param containerRegistryName string 2 | param principalId string 3 | 4 | var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') 5 | 6 | resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 7 | scope: containerRegistry // Use when specifying a scope that is different than the deployment scope 8 | name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) 9 | properties: { 10 | roleDefinitionId: acrPullRole 11 | principalType: 'ServicePrincipal' 12 | principalId: principalId 13 | } 14 | } 15 | 16 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = { 17 | name: containerRegistryName 18 | } 19 | -------------------------------------------------------------------------------- /infra/core/security/role.bicep: -------------------------------------------------------------------------------- 1 | param principalId string 2 | 3 | @allowed([ 4 | 'Device' 5 | 'ForeignGroup' 6 | 'Group' 7 | 'ServicePrincipal' 8 | 'User' 9 | ]) 10 | param principalType string = 'ServicePrincipal' 11 | param roleDefinitionId string 12 | 13 | resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 14 | name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) 15 | properties: { 16 | principalId: principalId 17 | principalType: principalType 18 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /infra/getkey.sh: -------------------------------------------------------------------------------- 1 | if [ -z "$(az account show)" ]; then 2 | echo "You are not logged in. Please run 'az login' or 'az login --use-device-code' first." 3 | exit 1 4 | fi 5 | 6 | echo "Logged in, using this subscription:" 7 | az account show --query "{subscriptionId:id, name:name}" 8 | echo "If that is not the correct subscription, please run 'az account set --subscription \"\"'" 9 | 10 | echo "Getting environment variables from .env file..." 11 | openAiService=$(grep "AZURE_OPENAI_RESOURCE=" .env | cut -d '=' -f2 | tr -d '"') 12 | resourceGroupName=$(grep "AZURE_OPENAI_RESOURCE_GROUP=" .env | cut -d '=' -f2 | tr -d '"') 13 | 14 | echo "Getting OpenAI key from $openAiService in resourceGroup $resourceGroupName..." 15 | openAiKey=$(az cognitiveservices account keys list --name $openAiService --resource-group $resourceGroupName --query key1 --output tsv) 16 | 17 | echo "AZURE_OPENAI_KEY=\"$openAiKey\"" >> .env 18 | 19 | echo "OpenAI key has been saved to .env file." 20 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name which is used to generate a short unique hash for each resource') 6 | param name string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | @description('Id of the user or app to assign application roles') 13 | param principalId string = '' 14 | 15 | @description('Flag to decide where to create RBAC roles for current user') 16 | param createRoleForUser bool = true 17 | 18 | param acaExists bool = false 19 | 20 | param openAiResourceName string = '' 21 | param openAiResourceGroupName string = '' 22 | param openAiResourceGroupLocation string = '' 23 | param openAiSkuName string = '' 24 | param openAiDeploymentCapacity int = 30 25 | param openAiApiVersion string = '' 26 | 27 | param authTenantId string 28 | param authClientId string = '' 29 | @secure() 30 | param authClientSecret string = '' 31 | param authClientSecretName string = 'AZURE-AUTH-CLIENT-SECRET' 32 | 33 | param runningOnGh bool = false 34 | 35 | var resourceToken = toLower(uniqueString(subscription().id, name, location)) 36 | var tags = { 'azd-env-name': name } 37 | 38 | resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { 39 | name: '${name}-rg' 40 | location: location 41 | tags: tags 42 | } 43 | 44 | resource openAiResourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' existing = if (!empty(openAiResourceGroupName)) { 45 | name: !empty(openAiResourceGroupName) ? openAiResourceGroupName : resourceGroup.name 46 | } 47 | 48 | var prefix = '${name}-${resourceToken}' 49 | 50 | var openAiDeploymentName = 'chatgpt' 51 | module openAi 'core/ai/cognitiveservices.bicep' = { 52 | name: 'openai' 53 | scope: openAiResourceGroup 54 | params: { 55 | name: !empty(openAiResourceName) ? openAiResourceName : '${resourceToken}-cog' 56 | location: !empty(openAiResourceGroupLocation) ? openAiResourceGroupLocation : location 57 | tags: tags 58 | sku: { 59 | name: !empty(openAiSkuName) ? openAiSkuName : 'S0' 60 | } 61 | deployments: [ 62 | { 63 | name: openAiDeploymentName 64 | model: { 65 | format: 'OpenAI' 66 | name: 'gpt-35-turbo' 67 | version: '0613' 68 | } 69 | sku: { 70 | name: 'Standard' 71 | capacity: openAiDeploymentCapacity 72 | } 73 | } 74 | ] 75 | } 76 | } 77 | 78 | module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = { 79 | name: 'loganalytics' 80 | scope: resourceGroup 81 | params: { 82 | name: '${prefix}-loganalytics' 83 | location: location 84 | tags: tags 85 | } 86 | } 87 | 88 | module redisCache 'core/cache/redis.bicep' = { 89 | name: 'redis' 90 | scope: resourceGroup 91 | params: { 92 | name: '${prefix}-redis' 93 | location: location 94 | tags: tags 95 | } 96 | } 97 | 98 | module redisAccessBackend 'core/cache/redis-access.bicep' = { 99 | name: 'redis-access-for-backend' 100 | scope: resourceGroup 101 | params: { 102 | redisCacheName: redisCache.outputs.name 103 | principalId: aca.outputs.SERVICE_ACA_IDENTITY_PRINCIPAL_ID 104 | accessPolicyAlias: 'Backend' 105 | } 106 | } 107 | 108 | module redisBackendUser 'core/cache/redis-access.bicep' = if (createRoleForUser) { 109 | name: 'redis-access-for-user' 110 | scope: resourceGroup 111 | params: { 112 | redisCacheName: redisCache.outputs.name 113 | principalId: principalId 114 | accessPolicyAlias: 'User' 115 | } 116 | } 117 | 118 | module redisDiagnostics 'core/cache/redis-diagnostics.bicep' = { 119 | name: 'redis-diagnostics' 120 | scope: resourceGroup 121 | params: { 122 | cacheName: redisCache.outputs.name 123 | diagnosticWorkspaceId: logAnalyticsWorkspace.outputs.id 124 | } 125 | } 126 | 127 | // Container apps host (including container registry) 128 | module containerApps 'core/host/container-apps.bicep' = { 129 | name: 'container-apps' 130 | scope: resourceGroup 131 | params: { 132 | name: 'app' 133 | location: location 134 | tags: tags 135 | containerAppsEnvironmentName: '${prefix}-containerapps-env' 136 | containerRegistryName: '${replace(prefix, '-', '')}registry' 137 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.outputs.name 138 | } 139 | } 140 | 141 | // Container app frontend 142 | var authAuthority = '${environment().authentication.loginEndpoint}${authTenantId}' 143 | module aca 'aca.bicep' = { 144 | name: 'aca' 145 | scope: resourceGroup 146 | params: { 147 | name: replace('${take(prefix,19)}-ca', '--', '-') 148 | location: location 149 | tags: tags 150 | identityName: '${prefix}-id-aca' 151 | containerAppsEnvironmentName: containerApps.outputs.environmentName 152 | containerRegistryName: containerApps.outputs.registryName 153 | openAiDeploymentName: openAiDeploymentName 154 | openAiEndpoint: openAi.outputs.endpoint 155 | openAiApiVersion: openAiApiVersion 156 | keyVaultName: keyVault.outputs.name 157 | authClientId: authClientId 158 | authClientSecretName: authClientSecretName 159 | authAuthority: authAuthority 160 | redisHost: redisCache.outputs.hostName 161 | exists: acaExists 162 | } 163 | } 164 | 165 | module openAiRoleUser 'core/security/role.bicep' = if (createRoleForUser) { 166 | scope: openAiResourceGroup 167 | name: 'openai-role-user' 168 | params: { 169 | principalId: principalId 170 | roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' 171 | principalType: 'User' 172 | } 173 | } 174 | 175 | module openAiRoleBackend 'core/security/role.bicep' = { 176 | scope: openAiResourceGroup 177 | name: 'openai-role-backend' 178 | params: { 179 | principalId: aca.outputs.SERVICE_ACA_IDENTITY_PRINCIPAL_ID 180 | roleDefinitionId: '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' 181 | principalType: 'ServicePrincipal' 182 | } 183 | } 184 | 185 | module keyVault 'core/security/keyvault.bicep' = { 186 | name: 'keyvault' 187 | scope: resourceGroup 188 | params: { 189 | name: '${replace(take(prefix, 17), '-', '')}-vault' 190 | location: location 191 | } 192 | } 193 | 194 | module userKeyVaultAccess 'core/security/role.bicep' = { 195 | name: 'user-keyvault-access' 196 | scope: resourceGroup 197 | params: { 198 | principalId: principalId 199 | principalType: runningOnGh ? 'ServicePrincipal' : 'User' 200 | roleDefinitionId: '00482a5a-887f-4fb3-b363-3b7fe8e74483' 201 | } 202 | } 203 | 204 | module webKeyVaultAccess 'core/security/role.bicep' = { 205 | name: 'web-keyvault-access' 206 | scope: resourceGroup 207 | params: { 208 | principalId: aca.outputs.SERVICE_ACA_IDENTITY_PRINCIPAL_ID 209 | principalType: 'ServicePrincipal' 210 | roleDefinitionId: '00482a5a-887f-4fb3-b363-3b7fe8e74483' 211 | } 212 | } 213 | 214 | module secrets 'secrets.bicep' = if (!empty(authClientSecret)) { 215 | name: 'secrets' 216 | scope: resourceGroup 217 | params: { 218 | keyVaultName: keyVault.outputs.name 219 | clientSecretName: authClientSecretName 220 | clientSecretValue: authClientSecret 221 | } 222 | } 223 | 224 | output AZURE_LOCATION string = location 225 | 226 | output AZURE_OPENAI_CHATGPT_DEPLOYMENT string = openAiDeploymentName 227 | output AZURE_OPENAI_API_VERSION string = openAiApiVersion 228 | output AZURE_OPENAI_ENDPOINT string = openAi.outputs.endpoint 229 | output AZURE_OPENAI_RESOURCE string = openAi.outputs.name 230 | output AZURE_OPENAI_RESOURCE_GROUP string = openAiResourceGroup.name 231 | output AZURE_OPENAI_SKU_NAME string = openAi.outputs.skuName 232 | output AZURE_OPENAI_RESOURCE_GROUP_LOCATION string = openAiResourceGroup.location 233 | 234 | output SERVICE_ACA_IDENTITY_PRINCIPAL_ID string = aca.outputs.SERVICE_ACA_IDENTITY_PRINCIPAL_ID 235 | output SERVICE_ACA_NAME string = aca.outputs.SERVICE_ACA_NAME 236 | output SERVICE_ACA_URI string = aca.outputs.SERVICE_ACA_URI 237 | output SERVICE_ACA_IMAGE_NAME string = aca.outputs.SERVICE_ACA_IMAGE_NAME 238 | output AZURE_AUTH_REDIRECT_URI string = '${aca.outputs.SERVICE_ACA_URI}/redirect' 239 | output AZURE_AUTH_CLIENT_SECRET_NAME string = authClientSecretName 240 | output AZURE_AUTH_CLIENT_ID string = authClientId 241 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 242 | output AZURE_AUTH_AUTHORITY string = authAuthority 243 | 244 | output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName 245 | output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer 246 | output AZURE_CONTAINER_REGISTRY_NAME string = containerApps.outputs.registryName 247 | 248 | output AZURE_REDIS_HOST string = redisCache.outputs.hostName 249 | -------------------------------------------------------------------------------- /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 | "name": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "authTenantId": { 15 | "value": "${AZURE_AUTH_TENANT_ID}" 16 | }, 17 | "authClientId": { 18 | "value": "${AZURE_AUTH_CLIENT_ID}" 19 | }, 20 | "authClientSecret": { 21 | "value": "${AZURE_AUTH_CLIENT_SECRET}" 22 | }, 23 | "openAiResourceName": { 24 | "value": "${AZURE_OPENAI_RESOURCE}" 25 | }, 26 | "openAiResourceGroupName": { 27 | "value": "${AZURE_OPENAI_RESOURCE_GROUP}" 28 | }, 29 | "openAiResourceGroupLocation": { 30 | "value": "${AZURE_OPENAI_RESOURCE_GROUP_LOCATION}" 31 | }, 32 | "openAiSkuName": { 33 | "value": "${AZURE_OPENAI_SKU_NAME}" 34 | }, 35 | "openAiApiVersion": { 36 | "value": "${AZURE_OPENAI_API_VERSION}" 37 | }, 38 | "createRoleForUser": { 39 | "value": "${CREATE_ROLE_FOR_USER=true}" 40 | }, 41 | "acaExists": { 42 | "value": "${SERVICE_ACA_RESOURCE_EXISTS=false}" 43 | }, 44 | "runningOnGh": { 45 | "value": "${GITHUB_ACTIONS}" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /infra/secrets.bicep: -------------------------------------------------------------------------------- 1 | param keyVaultName string 2 | 3 | param clientSecretName string 4 | 5 | @secure() 6 | param clientSecretValue string 7 | 8 | module clientSecretKVSecret 'core/security/keyvault-secret.bicep' = { 9 | name: 'clientsecret' 10 | params: { 11 | keyVaultName: keyVaultName 12 | name: clientSecretName 13 | secretValue: clientSecretValue 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py311" 3 | line-length = 120 4 | src = ["src"] 5 | 6 | [tool.ruff.lint] 7 | select = ["E", "F", "I", "UP"] 8 | 9 | [tool.ruff.lint.isort] 10 | known-first-party = ["quartapp"] 11 | 12 | [tool.black] 13 | target-version = ["py311"] 14 | line-length = 120 15 | 16 | [tool.pytest.ini_options] 17 | addopts = "-ra --cov" 18 | pythonpath = ["src"] 19 | 20 | [tool.coverage.report] 21 | show_missing = true 22 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r src/requirements.txt 2 | -r scripts/requirements.txt 3 | black 4 | ruff 5 | pre-commit 6 | pytest 7 | pytest-asyncio 8 | pytest-snapshot 9 | pytest-cov 10 | pip-tools 11 | -------------------------------------------------------------------------------- /scripts/auth_common.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import subprocess 4 | 5 | from dotenv import load_dotenv 6 | 7 | logger = logging.getLogger("authsetup") 8 | 9 | 10 | def load_azd_env(): 11 | """Get path to current azd env file and load file using python-dotenv""" 12 | result = subprocess.run("azd env list -o json", shell=True, capture_output=True, text=True) 13 | if result.returncode != 0: 14 | raise Exception("Error loading azd env") 15 | env_json = json.loads(result.stdout) 16 | env_file_path = None 17 | for entry in env_json: 18 | if entry["IsDefault"]: 19 | env_file_path = entry["DotEnvPath"] 20 | if not env_file_path: 21 | raise Exception("No default azd env file found") 22 | logger.info(f"Loading azd env from {env_file_path}") 23 | load_dotenv(env_file_path, override=True) 24 | -------------------------------------------------------------------------------- /scripts/auth_init.ps1: -------------------------------------------------------------------------------- 1 | if ($env:GITHUB_ACTIONS) { 2 | Write-Host "This script does not currently work in GitHub Actions. Please run azd up locally first to set up Microsoft Entra application registration." 3 | exit 0 4 | } 5 | 6 | $pythonCmd = Get-Command python -ErrorAction SilentlyContinue 7 | if (-not $pythonCmd) { 8 | # fallback to python3 if python not found 9 | $pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue 10 | } 11 | 12 | Start-Process -FilePath ($pythonCmd).Source -ArgumentList "./scripts/auth_init.py" -Wait -NoNewWindow 13 | -------------------------------------------------------------------------------- /scripts/auth_init.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import subprocess 5 | 6 | from auth_common import load_azd_env 7 | from azure.identity import AzureDeveloperCliCredential 8 | from kiota_abstractions.api_error import APIError 9 | from msgraph import GraphServiceClient 10 | from msgraph.generated.applications.item.add_password.add_password_post_request_body import ( 11 | AddPasswordPostRequestBody, 12 | ) 13 | from msgraph.generated.models.application import Application 14 | from msgraph.generated.models.implicit_grant_settings import ImplicitGrantSettings 15 | from msgraph.generated.models.password_credential import PasswordCredential 16 | from msgraph.generated.models.web_application import WebApplication 17 | from rich.logging import RichHandler 18 | 19 | logging.basicConfig( 20 | level=logging.WARNING, format="%(message)s", handlers=[RichHandler(rich_tracebacks=True, log_time_format="")] 21 | ) 22 | logger = logging.getLogger("authsetup") 23 | logger.setLevel(logging.INFO) 24 | 25 | 26 | async def check_for_application(client: GraphServiceClient, app_id: str) -> bool: 27 | try: 28 | await client.applications.by_application_id(app_id).get() 29 | except APIError: 30 | return False 31 | return True 32 | 33 | 34 | async def create_application(client: GraphServiceClient) -> Application: 35 | request_body = Application( 36 | display_name="WebApp", 37 | sign_in_audience="AzureADandPersonalMicrosoftAccount", 38 | web=WebApplication( 39 | redirect_uris=["http://localhost:50505/redirect"], 40 | implicit_grant_settings=ImplicitGrantSettings(enable_id_token_issuance=True), 41 | ), 42 | ) 43 | return await client.applications.post(request_body) 44 | 45 | 46 | async def add_client_secret(client: GraphServiceClient, app_id: str) -> str: 47 | request_body = AddPasswordPostRequestBody( 48 | password_credential=PasswordCredential(display_name="WebAppSecret"), 49 | ) 50 | result = await client.applications.by_application_id(app_id).add_password.post(request_body) 51 | return result.secret_text 52 | 53 | 54 | def update_azd_env(name, val): 55 | subprocess.run(f"azd env set {name} {val}", shell=True) 56 | 57 | 58 | async def main(): 59 | logger.info("Setting up authentication...") 60 | tenant_id = os.getenv("AZURE_AUTH_TENANT_ID") 61 | auth_credential = AzureDeveloperCliCredential(tenant_id=tenant_id) 62 | 63 | scopes = ["https://graph.microsoft.com/.default"] 64 | client = GraphServiceClient(credentials=auth_credential, scopes=scopes) 65 | 66 | app_id = os.getenv("AZURE_AUTH_APP_ID", "no-id") 67 | if app_id != "no-id": 68 | logger.info(f"Checking if application {app_id} exists") 69 | if await check_for_application(client, app_id): 70 | logger.info("Application already exists, not creating new one") 71 | exit(0) 72 | 73 | logger.info("Creating application registration") 74 | app = await create_application(client) 75 | 76 | logger.info(f"Adding client secret to {app.id}") 77 | client_secret = await add_client_secret(client, app.id) 78 | 79 | logger.info("Updating azd env with AZURE_AUTH_APP_ID, AZURE_AUTH_CLIENT_ID, AZURE_AUTH_CLIENT_SECRET") 80 | update_azd_env("AZURE_AUTH_APP_ID", app.id) 81 | update_azd_env("AZURE_AUTH_CLIENT_ID", app.app_id) 82 | update_azd_env("AZURE_AUTH_CLIENT_SECRET", client_secret) 83 | 84 | 85 | if __name__ == "__main__": 86 | load_azd_env() 87 | asyncio.run(main()) 88 | -------------------------------------------------------------------------------- /scripts/auth_init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$GITHUB_ACTIONS" ]; then 4 | echo "This script does not currently work in GitHub Actions. Please run azd up locally first to set up Microsoft Entra application registration." 5 | exit 0 6 | fi 7 | 8 | python ./scripts/auth_init.py 9 | -------------------------------------------------------------------------------- /scripts/auth_update.ps1: -------------------------------------------------------------------------------- 1 | if ($env:GITHUB_ACTIONS) { 2 | Write-Host "This script does not currently work in GitHub Actions. Please run azd up locally first to set up Microsoft Entra application registration." 3 | exit 0 4 | } 5 | 6 | $pythonCmd = Get-Command python -ErrorAction SilentlyContinue 7 | if (-not $pythonCmd) { 8 | # fallback to python3 if python not found 9 | $pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue 10 | } 11 | 12 | Start-Process -FilePath ($pythonCmd).Source -ArgumentList "./scripts/auth_update.py" -Wait -NoNewWindow 13 | -------------------------------------------------------------------------------- /scripts/auth_update.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import subprocess 5 | 6 | from auth_common import load_azd_env 7 | from azure.identity import AzureDeveloperCliCredential 8 | from msgraph import GraphServiceClient 9 | from msgraph.generated.models.application import Application 10 | from msgraph.generated.models.web_application import WebApplication 11 | from rich.logging import RichHandler 12 | 13 | logging.basicConfig( 14 | level=logging.WARNING, format="%(message)s", handlers=[RichHandler(rich_tracebacks=True, log_time_format="")] 15 | ) 16 | logger = logging.getLogger("authsetup") 17 | logger.setLevel(logging.INFO) 18 | 19 | 20 | async def update_redirect_uris(client: GraphServiceClient, app_id: str, uri: str): 21 | request_body = Application( 22 | web=WebApplication(redirect_uris=["http://localhost:50505/redirect", uri]), 23 | ) 24 | await client.applications.by_application_id(app_id).patch(request_body) 25 | 26 | 27 | async def main(): 28 | logger.info("Clearing secret from environment (now that it's stored in KeyVault)...") 29 | subprocess.run('azd env set AZURE_AUTH_CLIENT_SECRET ""', shell=True) 30 | 31 | logger.info("Updating authentication...") 32 | credential = AzureDeveloperCliCredential(tenant_id=os.getenv("AZURE_AUTH_TENANT_ID")) 33 | scopes = ["https://graph.microsoft.com/.default"] 34 | client = GraphServiceClient(credentials=credential, scopes=scopes) 35 | 36 | app_id = os.getenv("AZURE_AUTH_APP_ID") 37 | uri = os.getenv("AZURE_AUTH_REDIRECT_URI") 38 | logger.info(f"Updating application registration {app_id} with redirect URI for {uri}") 39 | await update_redirect_uris(client, app_id, uri) 40 | 41 | 42 | if __name__ == "__main__": 43 | load_azd_env() 44 | asyncio.run(main()) 45 | -------------------------------------------------------------------------------- /scripts/auth_update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -n "$GITHUB_ACTIONS" ]; then 4 | echo "This script does not currently work in GitHub Actions. Please run azd up locally first to set up Microsoft Entra application registration." 5 | exit 0 6 | fi 7 | 8 | python ./scripts/auth_update.py 9 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | azure-identity 2 | msgraph-sdk 3 | rich 4 | python-dotenv 5 | -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | .git* 2 | .venv/ 3 | **/*.pyc 4 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | # ------------------- Stage 0: Base Stage ------------------------------ 2 | FROM python:3.11-alpine AS base 3 | 4 | WORKDIR /code 5 | 6 | # Install tini, a tiny init for containers 7 | RUN apk add --update --no-cache tini 8 | 9 | # Install required packages for cryptography package 10 | # https://cryptography.io/en/latest/installation/#building-cryptography-on-linux 11 | RUN apk add gcc musl-dev python3-dev libffi-dev openssl-dev cargo pkgconfig 12 | # Install git so that we can pip install from git repositories 13 | RUN apk add git 14 | 15 | # ------------------- Stage 1: Build Stage ------------------------------ 16 | FROM base AS build 17 | 18 | COPY requirements.txt . 19 | 20 | RUN pip3 install -r requirements.txt 21 | 22 | COPY . . 23 | # ------------------- Stage 2: Final Stage ------------------------------ 24 | FROM base AS final 25 | 26 | RUN addgroup -S app && adduser -S app -G app 27 | 28 | COPY --from=build --chown=app:app /usr/local/lib/python3.11 /usr/local/lib/python3.11 29 | COPY --from=build --chown=app:app /usr/local/bin /usr/local/bin 30 | COPY --from=build --chown=app:app /code /code 31 | 32 | USER app 33 | 34 | EXPOSE 50505 35 | 36 | ENTRYPOINT ["tini", "gunicorn", "quartapp:create_app()"] 37 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-chat-app-entra-auth-local/5148af1db0ffd98973a649df7cfb710a4fd22904/src/__init__.py -------------------------------------------------------------------------------- /src/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv(override=True) 7 | 8 | max_requests = 1000 9 | max_requests_jitter = 50 10 | log_file = "-" 11 | bind = "0.0.0.0:50505" 12 | 13 | if not os.getenv("RUNNING_IN_PRODUCTION"): 14 | reload = True 15 | 16 | num_cpus = multiprocessing.cpu_count() 17 | workers = (num_cpus * 2) + 1 18 | worker_class = "uvicorn.workers.UvicornWorker" 19 | 20 | timeout = 120 21 | -------------------------------------------------------------------------------- /src/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quartapp" 3 | version = "1.0.0" 4 | description = "Create a simple chat app using Quart and OpenAI" 5 | dependencies = [ 6 | "quart", 7 | "werkzeug", 8 | "gunicorn", 9 | "uvicorn[standard]", 10 | "openai", 11 | "azure-identity", 12 | "aiohttp", 13 | "python-dotenv", 14 | "pyyaml", 15 | "redis[hiredis]", 16 | "azure-keyvault-secrets", 17 | "identity[quart]" 18 | ] 19 | 20 | [build-system] 21 | requires = ["flit_core<4"] 22 | build-backend = "flit_core.buildapi" 23 | -------------------------------------------------------------------------------- /src/quartapp/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from quart import Quart 6 | 7 | 8 | def create_app(): 9 | if os.getenv("RUNNING_IN_PRODUCTION"): 10 | logging.basicConfig(level=logging.WARNING) 11 | else: 12 | logging.basicConfig(level=logging.INFO) 13 | load_dotenv(verbose=True, override=True) 14 | 15 | app = Quart(__name__) 16 | app.logger.setLevel(logging.INFO) 17 | 18 | from . import chat # noqa 19 | 20 | app.register_blueprint(chat.bp) 21 | 22 | return app 23 | -------------------------------------------------------------------------------- /src/quartapp/chat.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from functools import wraps 5 | 6 | import azure.identity.aio 7 | import openai 8 | import redis.asyncio as redis 9 | from azure.keyvault.secrets.aio import SecretClient 10 | from identity.quart import Auth 11 | from quart import ( 12 | Blueprint, 13 | Response, 14 | current_app, 15 | render_template, 16 | request, 17 | stream_with_context, 18 | ) 19 | 20 | bp = Blueprint("chat", __name__, template_folder="templates", static_folder="static") 21 | 22 | 23 | def get_azure_credential(): 24 | if not hasattr(bp, "azure_credential"): 25 | bp.azure_credential = azure.identity.aio.DefaultAzureCredential(exclude_shared_token_cache_credential=True) 26 | return bp.azure_credential 27 | 28 | 29 | async def setup_redis(): 30 | azure_scope = "https://redis.azure.com/.default" 31 | use_azure_redis = os.getenv("RUNNING_IN_PRODUCTION") is not None 32 | if use_azure_redis: 33 | host = os.getenv("AZURE_REDIS_HOST") 34 | bp.redis_username = os.getenv("AZURE_REDIS_USER") 35 | port = 6380 36 | ssl = True 37 | else: 38 | host = "localhost" 39 | port = 6379 40 | bp.redis_username = None 41 | password = None 42 | ssl = False 43 | if use_azure_redis: 44 | current_app.logger.info("Using Azure Redis with default credential") 45 | bp.redis_token = await get_azure_credential().get_token(azure_scope) 46 | password = bp.redis_token.token 47 | else: 48 | current_app.logger.info("Using Redis with username and password") 49 | return redis.Redis( 50 | host=host, ssl=ssl, port=port, username=bp.redis_username, password=password, decode_responses=True 51 | ) 52 | 53 | 54 | @bp.before_app_serving 55 | async def configure_clients(): 56 | client_args = {} 57 | if os.getenv("LOCAL_OPENAI_ENDPOINT"): 58 | # Use a local endpoint like llamafile server 59 | current_app.logger.info("Using local OpenAI-compatible API with no key") 60 | client_args["api_key"] = "no-key-required" 61 | client_args["base_url"] = os.getenv("LOCAL_OPENAI_ENDPOINT") 62 | bp.openai_client = openai.AsyncOpenAI( 63 | **client_args, 64 | ) 65 | else: 66 | # Use an Azure OpenAI endpoint instead, 67 | # either with a key or with keyless authentication 68 | if os.getenv("AZURE_OPENAI_KEY"): 69 | # Authenticate using an Azure OpenAI API key 70 | # This is generally discouraged, but is provided for developers 71 | # that want to develop locally inside the Docker container. 72 | current_app.logger.info("Using Azure OpenAI with key") 73 | client_args["api_key"] = os.getenv("AZURE_OPENAI_KEY") 74 | else: 75 | # Authenticate using the default Azure credential chain 76 | # See https://docs.microsoft.com/azure/developer/python/azure-sdk-authenticate#defaultazurecredential 77 | # This will *not* work inside a Docker container. 78 | current_app.logger.info("Using Azure OpenAI with default credential") 79 | client_args["azure_ad_token_provider"] = azure.identity.aio.get_bearer_token_provider( 80 | get_azure_credential(), "https://cognitiveservices.azure.com/.default" 81 | ) 82 | if not os.getenv("AZURE_OPENAI_ENDPOINT"): 83 | raise ValueError("AZURE_OPENAI_ENDPOINT is required for Azure OpenAI") 84 | if not os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT"): 85 | raise ValueError("AZURE_OPENAI_CHATGPT_DEPLOYMENT is required for Azure OpenAI") 86 | bp.openai_client = openai.AsyncAzureOpenAI( 87 | api_version=os.getenv("AZURE_OPENAI_API_VERSION") or "2024-02-15-preview", 88 | azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), 89 | **client_args, 90 | ) 91 | 92 | bp.cache = await setup_redis() 93 | current_app.config["SESSION_TYPE"] = "redis" 94 | current_app.config["SESSION_REDIS"] = bp.cache 95 | 96 | redirect_uri = "http://localhost:50505/redirect" 97 | if os.getenv("RUNNING_IN_PRODUCTION"): 98 | redirect_uri = ( 99 | f"https://{os.environ['CONTAINER_APP_NAME']}.{os.environ['CONTAINER_APP_ENV_DNS_SUFFIX']}/redirect" 100 | ) 101 | current_app.logger.warn(f"Using production redirect URI: {redirect_uri}") 102 | 103 | AZURE_AUTH_CLIENT_SECRET_NAME = os.getenv("AZURE_AUTH_CLIENT_SECRET_NAME") 104 | AZURE_KEY_VAULT_NAME = os.getenv("AZURE_KEY_VAULT_NAME") 105 | async with SecretClient( 106 | vault_url=f"https://{AZURE_KEY_VAULT_NAME}.vault.azure.net", credential=get_azure_credential() 107 | ) as key_vault_client: 108 | auth_client_secret = (await key_vault_client.get_secret(AZURE_AUTH_CLIENT_SECRET_NAME)).value 109 | 110 | bp.auth = Auth( 111 | current_app, 112 | authority=os.getenv("AZURE_AUTH_AUTHORITY"), 113 | client_id=os.getenv("AZURE_AUTH_CLIENT_ID"), 114 | client_credential=auth_client_secret, 115 | redirect_uri=redirect_uri, 116 | ) 117 | 118 | 119 | def login_required(f): 120 | """Decorator to require login for a route.""" 121 | 122 | @wraps(f) 123 | async def decorated_function(*args, **kwargs): 124 | return await bp.auth.login_required(f)(*args, **kwargs) 125 | 126 | return decorated_function 127 | 128 | 129 | @bp.before_request 130 | async def ensure_redis_token(): 131 | if not hasattr(bp, "redis_token"): 132 | return 133 | redis_cache = bp.cache 134 | redis_token = bp.redis_token 135 | if redis_token.expires_on < time.time() + 60: 136 | current_app.logger.info("Refreshing token...") 137 | tmp_token = await get_azure_credential().get_token("https://redis.azure.com/.default") 138 | if tmp_token: 139 | azure_token = tmp_token 140 | await redis_cache.execute_command("AUTH", bp.redis_username, azure_token.token) 141 | current_app.logger.info("Successfully refreshed token.") 142 | 143 | 144 | @bp.after_app_serving 145 | async def shutdown_openai(): 146 | await bp.openai_client.close() 147 | await bp.azure_credential.close() 148 | 149 | 150 | @bp.get("/") 151 | @login_required 152 | async def index(*, context): 153 | return await render_template("index.html", user=context["user"]["name"]) 154 | 155 | 156 | @bp.post("/chat/stream") 157 | @login_required 158 | async def chat_handler(*, context): 159 | request_messages = (await request.get_json())["messages"] 160 | 161 | @stream_with_context 162 | async def response_stream(): 163 | # This sends all messages, so API request may exceed token limits 164 | all_messages = [ 165 | {"role": "system", "content": "You are a helpful assistant."}, 166 | ] + request_messages 167 | 168 | chat_coroutine = bp.openai_client.chat.completions.create( 169 | # Azure Open AI takes the deployment name as the model name 170 | model=os.environ["AZURE_OPENAI_CHATGPT_DEPLOYMENT"], 171 | messages=all_messages, 172 | stream=True, 173 | ) 174 | try: 175 | async for event in await chat_coroutine: 176 | event_dict = event.model_dump() 177 | if event_dict["choices"]: 178 | yield json.dumps(event_dict["choices"][0], ensure_ascii=False) + "\n" 179 | except Exception as e: 180 | current_app.logger.error(e) 181 | yield json.dumps({"error": str(e)}, ensure_ascii=False) + "\n" 182 | 183 | return Response(response_stream()) 184 | -------------------------------------------------------------------------------- /src/quartapp/static/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, body { 6 | height: 100%; 7 | } 8 | 9 | #messages .toast-container { 10 | margin-bottom: 12px; 11 | } 12 | 13 | .background-user { 14 | background-color: #2372cc; 15 | } 16 | 17 | .background-assistant { 18 | background-color: #2c8310; 19 | } 20 | -------------------------------------------------------------------------------- /src/quartapp/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Azure OpenAI Chat App + Microsoft Entra 8 | 10 | 12 | 14 | 15 | 16 | 17 |
18 | 19 | 37 | 38 | {% block content %} 39 | {% endblock %} 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /src/quartapp/templates/identity/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 |
5 | 6 | {% if user_code %} 7 |
    8 |
  1. To sign in, type {{ user_code }} into 9 | {{ auth_uri }} 10 | to authenticate. 11 |
  2. 12 |
  3. And then proceed.
  4. 13 |
14 | {% else %} 15 |

16 | To use this application, you must 17 | sign in.

18 | {% endif %} 19 | 20 | {% if reset_password_url %} 21 |
22 |

Reset password

23 | {% endif %} 24 |
25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /src/quartapp/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | 5 | 19 | 20 | 35 | 36 | 37 |
38 |
39 |
40 |
41 | 42 | 43 | 47 |
48 |
49 |
50 | 51 | 52 | 53 | 108 | {% endblock %} 109 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt pyproject.toml 6 | # 7 | aiofiles==24.1.0 8 | # via quart 9 | aiohappyeyeballs==2.4.4 10 | # via aiohttp 11 | aiohttp==3.11.11 12 | # via quartapp (pyproject.toml) 13 | aiosignal==1.3.2 14 | # via aiohttp 15 | annotated-types==0.7.0 16 | # via pydantic 17 | anyio==4.4.0 18 | # via 19 | # httpx 20 | # openai 21 | # watchfiles 22 | attrs==24.3.0 23 | # via aiohttp 24 | azure-core==1.30.2 25 | # via 26 | # azure-identity 27 | # azure-keyvault-secrets 28 | azure-identity==1.17.1 29 | # via quartapp (pyproject.toml) 30 | azure-keyvault-secrets==4.8.0 31 | # via quartapp (pyproject.toml) 32 | blinker==1.8.2 33 | # via 34 | # flask 35 | # quart 36 | certifi==2024.7.4 37 | # via 38 | # httpcore 39 | # httpx 40 | # requests 41 | cffi==1.16.0 42 | # via cryptography 43 | charset-normalizer==3.3.2 44 | # via requests 45 | click==8.1.7 46 | # via 47 | # flask 48 | # quart 49 | # uvicorn 50 | cryptography==44.0.1 51 | # via 52 | # azure-identity 53 | # msal 54 | # pyjwt 55 | distro==1.9.0 56 | # via openai 57 | flask==3.0.3 58 | # via quart 59 | frozenlist==1.4.1 60 | # via 61 | # aiohttp 62 | # aiosignal 63 | gunicorn==23.0.0 64 | # via quartapp (pyproject.toml) 65 | h11==0.14.0 66 | # via 67 | # httpcore 68 | # hypercorn 69 | # uvicorn 70 | # wsproto 71 | h2==4.1.0 72 | # via hypercorn 73 | hiredis==2.3.2 74 | # via redis 75 | hpack==4.0.0 76 | # via h2 77 | httpcore==1.0.5 78 | # via httpx 79 | httptools==0.6.1 80 | # via uvicorn 81 | httpx==0.27.0 82 | # via openai 83 | hypercorn==0.17.3 84 | # via quart 85 | hyperframe==6.0.1 86 | # via h2 87 | identity[quart]==0.8.0 88 | # via quartapp (pyproject.toml) 89 | idna==3.10 90 | # via 91 | # anyio 92 | # httpx 93 | # requests 94 | # yarl 95 | isodate==0.6.1 96 | # via azure-keyvault-secrets 97 | itsdangerous==2.2.0 98 | # via 99 | # flask 100 | # quart 101 | jinja2==3.1.6 102 | # via 103 | # flask 104 | # quart 105 | markupsafe==2.1.5 106 | # via 107 | # jinja2 108 | # quart 109 | # werkzeug 110 | msal==1.29.0 111 | # via 112 | # azure-identity 113 | # identity 114 | # msal-extensions 115 | msal-extensions==1.2.0 116 | # via azure-identity 117 | multidict==6.1.0 118 | # via 119 | # aiohttp 120 | # yarl 121 | openai==1.35.10 122 | # via quartapp (pyproject.toml) 123 | packaging==24.1 124 | # via gunicorn 125 | portalocker==2.10.0 126 | # via msal-extensions 127 | priority==2.0.0 128 | # via hypercorn 129 | propcache==0.2.1 130 | # via 131 | # aiohttp 132 | # yarl 133 | pycparser==2.22 134 | # via cffi 135 | pydantic==2.8.2 136 | # via openai 137 | pydantic-core==2.20.1 138 | # via pydantic 139 | pyjwt[crypto]==2.8.0 140 | # via msal 141 | python-dotenv==1.0.1 142 | # via 143 | # quartapp (pyproject.toml) 144 | # uvicorn 145 | pyyaml==6.0.1 146 | # via 147 | # quartapp (pyproject.toml) 148 | # uvicorn 149 | quart==0.20.0 150 | # via 151 | # identity 152 | # quart-session 153 | # quartapp (pyproject.toml) 154 | quart-session==3.0.0 155 | # via identity 156 | redis[hiredis]==5.0.7 157 | # via quartapp (pyproject.toml) 158 | requests==2.32.3 159 | # via 160 | # azure-core 161 | # identity 162 | # msal 163 | six==1.16.0 164 | # via 165 | # azure-core 166 | # isodate 167 | sniffio==1.3.1 168 | # via 169 | # anyio 170 | # openai 171 | tqdm==4.66.4 172 | # via openai 173 | typing-extensions==4.12.2 174 | # via 175 | # azure-core 176 | # azure-identity 177 | # azure-keyvault-secrets 178 | # openai 179 | # pydantic 180 | # pydantic-core 181 | urllib3==2.2.2 182 | # via requests 183 | uvicorn[standard]==0.30.1 184 | # via quartapp (pyproject.toml) 185 | uvloop==0.19.0 186 | # via uvicorn 187 | watchfiles==0.22.0 188 | # via uvicorn 189 | websockets==12.0 190 | # via uvicorn 191 | werkzeug==3.0.6 192 | # via 193 | # flask 194 | # quart 195 | # quartapp (pyproject.toml) 196 | wsproto==1.2.0 197 | # via hypercorn 198 | yarl==1.18.3 199 | # via aiohttp 200 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/openai-chat-app-entra-auth-local/5148af1db0ffd98973a649df7cfb710a4fd22904/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | import identity.quart 4 | import openai 5 | import pytest 6 | import pytest_asyncio 7 | from azure.keyvault.secrets.aio import SecretClient 8 | 9 | import quartapp 10 | 11 | from . import mock_cred 12 | 13 | 14 | @pytest.fixture 15 | def mock_openai_chatcompletion(monkeypatch): 16 | class AsyncChatCompletionIterator: 17 | def __init__(self, answer: str): 18 | self.chunk_index = 0 19 | self.chunks = [ 20 | # This is an Azure-specific chunk solely for prompt_filter_results 21 | openai.types.chat.ChatCompletionChunk( 22 | object="chat.completion.chunk", 23 | choices=[], 24 | id="", 25 | created=0, 26 | model="", 27 | prompt_filter_results=[ 28 | { 29 | "prompt_index": 0, 30 | "content_filter_results": { 31 | "hate": {"filtered": False, "severity": "safe"}, 32 | "self_harm": {"filtered": False, "severity": "safe"}, 33 | "sexual": {"filtered": False, "severity": "safe"}, 34 | "violence": {"filtered": False, "severity": "safe"}, 35 | }, 36 | } 37 | ], 38 | ), 39 | openai.types.chat.ChatCompletionChunk( 40 | id="test-123", 41 | object="chat.completion.chunk", 42 | choices=[ 43 | openai.types.chat.chat_completion_chunk.Choice( 44 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(content=None, role="assistant"), 45 | index=0, 46 | finish_reason=None, 47 | # Only Azure includes content_filter_results 48 | content_filter_results={}, 49 | ) 50 | ], 51 | created=1703462735, 52 | model="gpt-35-turbo", 53 | ), 54 | ] 55 | answer_deltas = answer.split(" ") 56 | for answer_index, answer_delta in enumerate(answer_deltas): 57 | # Completion chunks include whitespace, so we need to add it back in 58 | if answer_index > 0: 59 | answer_delta = " " + answer_delta 60 | self.chunks.append( 61 | openai.types.chat.ChatCompletionChunk( 62 | id="test-123", 63 | object="chat.completion.chunk", 64 | choices=[ 65 | openai.types.chat.chat_completion_chunk.Choice( 66 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta( 67 | role=None, content=answer_delta 68 | ), 69 | finish_reason=None, 70 | index=0, 71 | logprobs=None, 72 | # Only Azure includes content_filter_results 73 | content_filter_results={ 74 | "hate": {"filtered": False, "severity": "safe"}, 75 | "self_harm": {"filtered": False, "severity": "safe"}, 76 | "sexual": {"filtered": False, "severity": "safe"}, 77 | "violence": {"filtered": False, "severity": "safe"}, 78 | }, 79 | ) 80 | ], 81 | created=1703462735, 82 | model="gpt-35-turbo", 83 | ) 84 | ) 85 | self.chunks.append( 86 | openai.types.chat.ChatCompletionChunk( 87 | id="test-123", 88 | object="chat.completion.chunk", 89 | choices=[ 90 | openai.types.chat.chat_completion_chunk.Choice( 91 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(content=None, role=None), 92 | index=0, 93 | finish_reason="stop", 94 | # Only Azure includes content_filter_results 95 | content_filter_results={}, 96 | ) 97 | ], 98 | created=1703462735, 99 | model="gpt-35-turbo", 100 | ) 101 | ) 102 | 103 | def __aiter__(self): 104 | return self 105 | 106 | async def __anext__(self): 107 | if self.chunk_index < len(self.chunks): 108 | next_chunk = self.chunks[self.chunk_index] 109 | self.chunk_index += 1 110 | return next_chunk 111 | else: 112 | raise StopAsyncIteration 113 | 114 | async def mock_acreate(*args, **kwargs): 115 | # Only mock a stream=True completion 116 | last_message = kwargs.get("messages")[-1]["content"] 117 | if last_message == "What is the capital of France?": 118 | return AsyncChatCompletionIterator("The capital of France is Paris.") 119 | elif last_message == "What is the capital of Germany?": 120 | return AsyncChatCompletionIterator("The capital of Germany is Berlin.") 121 | else: 122 | raise ValueError(f"Unexpected message: {last_message}") 123 | 124 | monkeypatch.setattr("openai.resources.chat.AsyncCompletions.create", mock_acreate) 125 | 126 | 127 | @pytest.fixture 128 | def mock_defaultazurecredential(monkeypatch): 129 | monkeypatch.setattr("azure.identity.aio.DefaultAzureCredential", mock_cred.MockAzureCredential) 130 | 131 | 132 | @pytest.fixture 133 | def mock_keyvault_secretclient(monkeypatch): 134 | monkeypatch.setenv("AZURE_KEY_VAULT_NAME", "my_key_vault") 135 | monkeypatch.setenv("AZURE_AUTH_CLIENT_SECRET_NAME", "my_secret_name") 136 | 137 | async def get_secret(*args, **kwargs): 138 | if args[1] == "my_secret_name": 139 | return mock_cred.MockKeyVaultSecret("mysecret") 140 | raise Exception(f"Unexpected secret name: {args[1]}") 141 | 142 | monkeypatch.setattr(SecretClient, "get_secret", get_secret) 143 | 144 | 145 | @pytest.fixture 146 | def mock_login_required(monkeypatch): 147 | def login_required(self, f): 148 | context = { 149 | "user": { 150 | "name": "Namey McNameface", 151 | # Other fields have been omitted for brevity 152 | } 153 | } 154 | 155 | @wraps(f) 156 | async def decorated_function(*args, **kwargs): 157 | return await f(*args, context=context, **kwargs) 158 | 159 | return decorated_function 160 | 161 | monkeypatch.setattr(identity.quart.Auth, "login_required", login_required) 162 | 163 | 164 | @pytest_asyncio.fixture 165 | async def client( 166 | monkeypatch, 167 | mock_openai_chatcompletion, 168 | mock_defaultazurecredential, 169 | mock_keyvault_secretclient, 170 | mock_login_required, 171 | ): 172 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com") 173 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt") 174 | 175 | quart_app = quartapp.create_app() 176 | 177 | async with quart_app.test_app() as test_app: 178 | quart_app.config.update({"TESTING": True}) 179 | 180 | yield test_app.test_client() 181 | -------------------------------------------------------------------------------- /tests/mock_cred.py: -------------------------------------------------------------------------------- 1 | import azure.core.credentials_async 2 | 3 | 4 | class MockAzureCredential(azure.core.credentials_async.AsyncTokenCredential): 5 | pass 6 | 7 | 8 | class MockKeyVaultSecret: 9 | def __init__(self, value): 10 | self.value = value 11 | -------------------------------------------------------------------------------- /tests/snapshots/test_app/test_chat_stream_text/result.json: -------------------------------------------------------------------------------- 1 | {"delta": {"content": null, "function_call": null, "role": "assistant", "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {}} 2 | {"delta": {"content": "The", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 3 | {"delta": {"content": " capital", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 4 | {"delta": {"content": " of", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 5 | {"delta": {"content": " France", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 6 | {"delta": {"content": " is", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 7 | {"delta": {"content": " Paris.", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 8 | {"delta": {"content": null, "function_call": null, "role": null, "tool_calls": null}, "finish_reason": "stop", "index": 0, "logprobs": null, "content_filter_results": {}} 9 | -------------------------------------------------------------------------------- /tests/snapshots/test_app/test_chat_stream_text_history/result.json: -------------------------------------------------------------------------------- 1 | {"delta": {"content": null, "function_call": null, "role": "assistant", "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {}} 2 | {"delta": {"content": "The", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 3 | {"delta": {"content": " capital", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 4 | {"delta": {"content": " of", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 5 | {"delta": {"content": " Germany", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 6 | {"delta": {"content": " is", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 7 | {"delta": {"content": " Berlin.", "function_call": null, "role": null, "tool_calls": null}, "finish_reason": null, "index": 0, "logprobs": null, "content_filter_results": {"hate": {"filtered": false, "severity": "safe"}, "self_harm": {"filtered": false, "severity": "safe"}, "sexual": {"filtered": false, "severity": "safe"}, "violence": {"filtered": false, "severity": "safe"}}} 8 | {"delta": {"content": null, "function_call": null, "role": null, "tool_calls": null}, "finish_reason": "stop", "index": 0, "logprobs": null, "content_filter_results": {}} 9 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import quartapp 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_index(client): 8 | response = await client.get("/") 9 | assert response.status_code == 200 10 | assert b"Namey McNameface" in await response.get_data() 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_chat_stream_text(client, snapshot): 15 | response = await client.post( 16 | "/chat/stream", 17 | json={ 18 | "messages": [ 19 | {"role": "user", "content": "What is the capital of France?"}, 20 | ] 21 | }, 22 | ) 23 | assert response.status_code == 200 24 | result = await response.get_data() 25 | snapshot.assert_match(result, "result.json") 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_chat_stream_text_history(client, snapshot): 30 | response = await client.post( 31 | "/chat/stream", 32 | json={ 33 | "messages": [ 34 | {"role": "user", "content": "What is the capital of France?"}, 35 | {"role": "assistant", "content": "Paris"}, 36 | {"role": "user", "content": "What is the capital of Germany?"}, 37 | ] 38 | }, 39 | ) 40 | assert response.status_code == 200 41 | result = await response.get_data() 42 | snapshot.assert_match(result, "result.json") 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_openai_key(monkeypatch, mock_keyvault_secretclient): 47 | monkeypatch.setenv("AZURE_OPENAI_KEY", "test-key") 48 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com") 49 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt") 50 | monkeypatch.setenv("AZURE_OPENAI_VERSION", "2023-10-01-preview") 51 | 52 | quart_app = quartapp.create_app() 53 | 54 | async with quart_app.test_app(): 55 | assert quart_app.blueprints["chat"].openai_client.api_key == "test-key" 56 | assert quart_app.blueprints["chat"].openai_client._azure_ad_token_provider is None 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_openai_managedidentity(monkeypatch, mock_keyvault_secretclient): 61 | monkeypatch.setenv("AZURE_OPENAI_CLIENT_ID", "test-client-id") 62 | monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "test-openai-service.openai.azure.com") 63 | monkeypatch.setenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT", "test-chatgpt") 64 | monkeypatch.setenv("AZURE_OPENAI_VERSION", "2023-10-01-preview") 65 | 66 | quart_app = quartapp.create_app() 67 | 68 | async with quart_app.test_app(): 69 | assert quart_app.blueprints["chat"].openai_client._azure_ad_token_provider is not None 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_openai_local(monkeypatch, mock_keyvault_secretclient): 74 | monkeypatch.setenv("LOCAL_OPENAI_ENDPOINT", "http://localhost:8080") 75 | 76 | quart_app = quartapp.create_app() 77 | 78 | async with quart_app.test_app(): 79 | assert quart_app.blueprints["chat"].openai_client.api_key == "no-key-required" 80 | assert quart_app.blueprints["chat"].openai_client.base_url == "http://localhost:8080" 81 | --------------------------------------------------------------------------------