├── .devcontainer └── devcontainer.json ├── .env.sample ├── .gitattributes ├── .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 ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE.md ├── README.md ├── SECURITY.md ├── azure.yaml ├── docker-compose.yaml ├── docs ├── deploy_existing.md ├── readme_diagram.png └── screenshot_chatapp.png ├── infra ├── aca.bicep ├── appregistration.bicep ├── appupdate.bicep ├── bicepconfig.json ├── core │ ├── ai │ │ └── cognitiveservices.bicep │ ├── host │ │ ├── container-app-upsert.bicep │ │ ├── container-app.bicep │ │ ├── container-apps-environment.bicep │ │ ├── container-apps.bicep │ │ └── container-registry.bicep │ ├── monitor │ │ └── loganalytics.bicep │ └── security │ │ ├── registry-access.bicep │ │ └── role.bicep ├── main.bicep └── main.parameters.json ├── pyproject.toml ├── requirements-dev.txt ├── src ├── .dockerignore ├── Dockerfile ├── __init__.py ├── gunicorn.conf.py ├── pyproject.toml ├── quartapp │ ├── __init__.py │ ├── chat.py │ ├── static │ │ └── styles.css │ └── templates │ │ └── index.html └── requirements.txt └── tests ├── __init__.py ├── conftest.py ├── mock_cred.py ├── snapshots └── test_app │ ├── test_chat_stream_text │ └── result.jsonlines │ └── test_chat_stream_text_history │ └── result.jsonlines └── test_app.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openai-chat-app-quickstart", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", 4 | "forwardPorts": [50505], 5 | "features": { 6 | "ghcr.io/azure/azure-dev/azd:latest": {} 7 | }, 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "ms-azuretools.azure-dev", 12 | "ms-azuretools.vscode-bicep", 13 | "ms-python.python", 14 | "GitHub.vscode-github-actions" 15 | ] 16 | } 17 | }, 18 | "postCreateCommand": "python3 -m pip install -r requirements-dev.txt && python3 -m pip install -e src", 19 | "remoteUser": "vscode", 20 | "hostRequirements": { 21 | "memory": "8gb" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | AZURE_INFERENCE_ENDPOINT=https://.services.ai.azure.com/models 2 | AZURE_TENANT_ID=YOUR-TENANT-ID 3 | AZURE_INFERENCE_API_VERSION=2024-05-01-preview 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf 2 | *.jsonlines text eol=lf 3 | -------------------------------------------------------------------------------- /.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 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | 46 | -------------------------------------------------------------------------------- /.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: Build Bicep for linting 23 | uses: azure/CLI@v2 24 | with: 25 | inlineScript: | 26 | export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 27 | az config set bicep.use_binary_from_path=false && az bicep build -f infra/main.bicep --stdout 28 | 29 | - name: Run Microsoft Security DevOps Analysis 30 | uses: microsoft/security-devops-action@preview 31 | id: msdo 32 | continue-on-error: true 33 | with: 34 | tools: templateanalyzer 35 | 36 | - name: Upload alerts to Security tab 37 | uses: github/codeql-action/upload-sarif@v3 38 | if: github.repository_owner == 'Azure-Samples' 39 | with: 40 | sarif_file: ${{ steps.msdo.outputs.sarifFile }} 41 | -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Azure with azd 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 | SERVICE_ACA_RESOURCE_EXISTS: ${{ vars.SERVICE_ACA_RESOURCE_EXISTS }} 29 | DISABLE_KEY_BASED_AUTH: ${{ vars.DISABLE_KEY_BASED_AUTH }} 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Install azd 35 | uses: Azure/setup-azd@v1.0.0 36 | 37 | - name: Log in with Azure (Federated Credentials) 38 | if: ${{ env.AZURE_CLIENT_ID != '' }} 39 | run: | 40 | azd auth login ` 41 | --client-id "$Env:AZURE_CLIENT_ID" ` 42 | --federated-credential-provider "github" ` 43 | --tenant-id "$Env:AZURE_TENANT_ID" 44 | shell: pwsh 45 | 46 | - name: Log in with Azure (Client Credentials) 47 | if: ${{ env.AZURE_CREDENTIALS != '' }} 48 | run: | 49 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 50 | Write-Host "::add-mask::$($info.clientSecret)" 51 | 52 | azd auth login ` 53 | --client-id "$($info.clientId)" ` 54 | --client-secret "$($info.clientSecret)" ` 55 | --tenant-id "$($info.tenantId)" 56 | shell: pwsh 57 | env: 58 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 59 | 60 | - name: Provision Infrastructure 61 | run: | 62 | azd env set CREATE_ROLE_FOR_USER false --no-prompt 63 | azd provision --no-prompt 64 | env: 65 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 66 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 67 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 68 | 69 | - name: Deploy Application 70 | run: azd deploy --no-prompt 71 | env: 72 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 73 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 74 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 75 | -------------------------------------------------------------------------------- /.github/workflows/python-check.yaml: -------------------------------------------------------------------------------- 1 | name: Python check 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test_package: 11 | name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: ["ubuntu-latest", "windows-latest"] 17 | python_version: ["3.11"] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python_version }} 24 | architecture: x64 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements-dev.txt 29 | - name: Lint with ruff 30 | run: python3 -m ruff check . 31 | - name: Check formatting with black 32 | run: python3 -m black . --check --verbose 33 | - name: Run tests with pytest 34 | run: python3 -m pytest 35 | -------------------------------------------------------------------------------- /.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: Debug 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 | "**/__pycache__": true, 9 | "**/.coverage": true, 10 | "**/.pytest_cache": true, 11 | "**/.ruff_cache": true, 12 | "**/.mypy_cache": true 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Azure Samples 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat Application using DeepSeek-R1 (Python) 2 | 3 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/deepseek-python) 4 | [![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/deepseek-python) 5 | 6 | This sample Python app uses the [openai client library](https://pypi.org/project/openai/) to call the DeepSeek-R1 model to generate responses to user messages. 7 | 8 | The project includes all the infrastructure and configuration needed to provision a DeepSeek deployment in Azure AI 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). By default, the app will use managed identity to authenticate with Azure AI. 9 | 10 | We recommend first going through the [deploying steps](#deploying) before running this app locally, 11 | since the local app needs credentials for Azure AI to work properly. 12 | 13 | * [Features](#features) 14 | * [Architecture diagram](#architecture-diagram) 15 | * [Getting started](#getting-started) 16 | * [GitHub Codespaces](#github-codespaces) 17 | * [VS Code Dev Containers](#vs-code-dev-containers) 18 | * [Local Environment](#local-environment) 19 | * [Deploying](#deploying) 20 | * [Development server](#development-server) 21 | * [Guidance](#guidance) 22 | * [Costs](#costs) 23 | * [Security Guidelines](#security-guidelines) 24 | 25 | ## Important Security Notice 26 | 27 | This template, the application code and configuration it contains, has been built to showcase Microsoft Azure specific services and tools. We strongly advise our customers not to make this code part of their production environments without implementing or enabling additional security features. See [Security Guidelines](#security-guidelines) for more information on how to secure your deployment. 28 | 29 | ## Features 30 | 31 | * A Python [Quart](https://quart.palletsprojects.com/en/latest/) that uses the [openai client library](https://pypi.org/project/openai/) package to generate responses to user messages. 32 | * A basic HTML/JS frontend that streams responses from the backend using [JSON Lines](http://jsonlines.org/) over a [ReadableStream](https://developer.mozilla.org/docs/Web/API/ReadableStream). 33 | * [Bicep files](https://docs.microsoft.com/azure/azure-resource-manager/bicep/) for provisioning Azure resources, including Azure AI Services, Azure Container Apps, Azure Container Registry, Azure Log Analytics, and RBAC roles. 34 | 35 | ![Screenshot of the chat app](docs/screenshot_chatapp.png) 36 | 37 | ## Architecture diagram 38 | 39 | ![Architecture diagram: Azure Container Apps inside Container Apps Environment, connected to Container Registry with Container, connected to Managed Identity for Azure AI Services](docs/readme_diagram.png) 40 | 41 | ## Getting started 42 | 43 | You have a few options for getting started with this template. 44 | 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). 45 | 46 | ### GitHub Codespaces 47 | 48 | You can run this template virtually by using GitHub Codespaces. The button will open a web-based VS Code instance in your browser: 49 | 50 | 1. Open the template (this may take several minutes): 51 | 52 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/deepseek-python) 53 | 54 | 2. Open a terminal window 55 | 3. Continue with the [deploying steps](#deploying) 56 | 57 | ### VS Code Dev Containers 58 | 59 | 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): 60 | 61 | 1. Start Docker Desktop (install it if not already installed) 62 | 2. Open the project: 63 | 64 | [![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/deepseek-python) 65 | 66 | 3. In the VS Code window that opens, once the project files show up (this may take several minutes), open a terminal window. 67 | 4. Continue with the [deploying steps](#deploying) 68 | 69 | ### Local Environment 70 | 71 | If you're not using one of the above options for opening the project, then you'll need to: 72 | 73 | 1. Make sure the following tools are installed: 74 | 75 | * [Azure Developer CLI (azd)](https://aka.ms/install-azd) 76 | * [Python 3.10+](https://www.python.org/downloads/) 77 | * [Docker Desktop](https://www.docker.com/products/docker-desktop/) 78 | * [Git](https://git-scm.com/downloads) 79 | 80 | 2. Download the project code: 81 | 82 | ```shell 83 | azd init -t deepseek-python 84 | ``` 85 | 86 | 3. Open the project folder 87 | 4. Create a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html#creating-virtual-environments) and activate it. 88 | 5. Install required Python packages: 89 | 90 | ```shell 91 | pip install -r requirements-dev.txt 92 | ``` 93 | 94 | 6. Install the app as an editable package: 95 | 96 | ```shell 97 | python3 -m pip install -e src 98 | ``` 99 | 100 | 7. Continue with the [deploying steps](#deploying). 101 | 102 | ## Deploying 103 | 104 | 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. 105 | 106 | ### Azure account setup 107 | 108 | 1. Sign up for a [free Azure account](https://azure.microsoft.com/free/) and create an Azure Subscription. 109 | 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. 110 | 3. Check that you have the necessary permissions: 111 | 112 | * 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). 113 | * Your Azure account also needs `Microsoft.Resources/deployments/write` permissions on the subscription level. 114 | 115 | ### Deploying with azd 116 | 117 | 1. Login to Azure: 118 | 119 | ```shell 120 | azd auth login 121 | ``` 122 | 123 | 2. Provision and deploy all the resources: 124 | 125 | ```shell 126 | azd up 127 | ``` 128 | 129 | It will prompt you to provide an `azd` environment name (like "chat-app"), select a subscription from your Azure account, and select a [location where DeepSeek-R1 is available](https://learn.microsoft.com/azure/ai-studio/how-to/deploy-models-serverless-availability#deepseek-models-from-microsoft) (like "westus"). Then it will provision the resources in your account and deploy the latest code. If you get an error or timeout with deployment, changing the location can help, as there may be availability constraints for the Azure AI resource. 130 | 131 | 3. 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! 🎉 132 | 4. Remember to take down your app once you're no longer using it, either by deleting the resource group in the Portal or running this command: 133 | 134 | ```shell 135 | azd down 136 | ``` 137 | 138 | ### Continuous deployment with GitHub Actions 139 | 140 | This project includes a Github workflow for deploying the resources to Azure 141 | on every push to main. That workflow requires several Azure-related authentication secrets 142 | to be stored as Github action secrets. To set that up, run: 143 | 144 | ```shell 145 | azd pipeline config 146 | ``` 147 | 148 | ## Development server 149 | 150 | Assuming you've run the steps to [open the project](#getting-started) and the steps in [Deploying](#deploying), you can now run the Quart app in your development environment: 151 | 152 | 1. Copy `.env.sample.azure` into `.env`: 153 | 154 | ```shell 155 | cp .env.sample .env 156 | ``` 157 | 158 | 2. Run this command to get the value of `AZURE_INFERENCE_ENDPOINT` from your deployed resource group and paste it in the `.env` file: 159 | 160 | ```shell 161 | azd env get-value AZURE_INFERENCE_ENDPOINT 162 | ``` 163 | 164 | 3. Run this command to get the value of `AZURE_TENANT_ID` from your deployed resource group and paste it in the `.env` file: 165 | 166 | ```shell 167 | azd env get-value AZURE_TENANT_ID 168 | ``` 169 | 170 | 4. Run the development server: 171 | 172 | ```shell 173 | python -m quart --app src.quartapp run --port 50505 --reload 174 | ``` 175 | 176 | This will start the app on port 50505, and you can access it at `http://localhost:50505`. 177 | 178 | ## Guidance 179 | 180 | ### Costs 181 | 182 | Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage. 183 | The majority of the Azure resources used in this infrastructure are on usage-based pricing tiers. 184 | However, Azure Container Registry has a fixed cost per registry per day. 185 | 186 | You can try the [Azure pricing calculator](https://azure.microsoft.com/pricing/calculator/) for the resources: 187 | 188 | * Azure AI Service: S0 tier, DeepSeek-R1 model. Pricing is based on token count. [Pricing](https://aka.ms/DeepSeekPricing) 189 | * 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/) 190 | * Azure Container Registry: Basic tier. [Pricing](https://azure.microsoft.com/pricing/details/container-registry/) 191 | * Log analytics: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/) 192 | 193 | ⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use, 194 | either by deleting the resource group in the Portal or running `azd down`. 195 | 196 | ### Security Guidelines 197 | 198 | This template uses [Managed Identity](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview) for authenticating to the Azure OpenAI service. 199 | 200 | This template also enables the Container Apps [built-in authentication feature](https://learn.microsoft.com/azure/container-apps/authentication) with a Microsoft Entra ID identity provider. The Bicep files use the new [Microsoft Graph extension (public preview)](https://learn.microsoft.com/graph/templates/overview-bicep-templates-for-graph) to create the Entra application registration using [managed identity with Federated Identity Credentials](https://learn.microsoft.com/azure/container-apps/managed-identity), so that no client secrets or certificates are necessary. 201 | 202 | 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. 203 | 204 | You may want to consider additional security measures, such as: 205 | 206 | * 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). 207 | * Enabling Microsoft Defender for Cloud on the resource group and setting up [security policies](https://learn.microsoft.com/azure/defender-for-cloud/security-policy-concept). 208 | 209 | ### Resources 210 | 211 | * [Blog post: Building a streaming DeepSeek-R1 app on Azure](https://blog.pamelafox.org/2025/04/building-streaming-deepseek-r1-app-on.html) 212 | * [Video walkthrough: Deploying DeepSeek-R1 app to Azure](https://www.youtube.com/watch?v=W2j-dK47dYU) 213 | -------------------------------------------------------------------------------- /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: deepseek-python 4 | metadata: 5 | template: deepseek-python@0.1.0 6 | requiredVersions: 7 | azd: ">= 1.10.0" 8 | services: 9 | aca: 10 | project: ./src 11 | language: py 12 | host: containerapp 13 | docker: 14 | remoteBuild: true 15 | pipeline: 16 | variables: 17 | - SERVICE_ACA_RESOURCE_EXISTS 18 | - DISABLE_KEY_BASED_AUTH 19 | -------------------------------------------------------------------------------- /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/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 | 9 | ## Resource group 10 | 11 | 1. Run `azd env set AZURE_RESOURCE_GROUP {Name of existing resource group}` 12 | 1. Run `azd env set AZURE_LOCATION {Location of existing resource group}` 13 | -------------------------------------------------------------------------------- /docs/readme_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/deepseek-python/d6ade4d5239ba151bcb9dd837c26ec43489badbb/docs/readme_diagram.png -------------------------------------------------------------------------------- /docs/screenshot_chatapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/deepseek-python/d6ade4d5239ba151bcb9dd837c26ec43489badbb/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 aiServicesDeploymentName string 11 | param aiServicesEndpoint string 12 | 13 | resource acaIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 14 | name: identityName 15 | location: location 16 | } 17 | 18 | var env = [ 19 | { 20 | name: 'AZURE_DEEPSEEK_DEPLOYMENT' 21 | value: aiServicesDeploymentName 22 | } 23 | { 24 | name: 'AZURE_INFERENCE_ENDPOINT' 25 | value: aiServicesEndpoint 26 | } 27 | { 28 | name: 'RUNNING_IN_PRODUCTION' 29 | value: 'true' 30 | } 31 | { 32 | // DefaultAzureCredential will look for an environment variable with this name: 33 | name: 'AZURE_CLIENT_ID' 34 | value: acaIdentity.properties.clientId 35 | } 36 | ] 37 | 38 | module app 'core/host/container-app-upsert.bicep' = { 39 | name: '${serviceName}-container-app-module' 40 | params: { 41 | name: name 42 | location: location 43 | tags: union(tags, { 'azd-service-name': serviceName }) 44 | identityName: acaIdentity.name 45 | exists: exists 46 | containerAppsEnvironmentName: containerAppsEnvironmentName 47 | containerRegistryName: containerRegistryName 48 | env: env 49 | targetPort: 50505 50 | secrets: { 51 | 'override-use-mi-fic-assertion-client-id': acaIdentity.properties.clientId 52 | } 53 | } 54 | } 55 | 56 | output identityPrincipalId string = acaIdentity.properties.principalId 57 | output name string = app.outputs.name 58 | output uri string = app.outputs.uri 59 | output imageName string = app.outputs.imageName 60 | -------------------------------------------------------------------------------- /infra/appregistration.bicep: -------------------------------------------------------------------------------- 1 | extension microsoftGraphV1 2 | 3 | @description('Specifies the name of cloud environment to run this deployment in.') 4 | param cloudEnvironment string = environment().name 5 | 6 | // NOTE: Microsoft Graph Bicep file deployment is only supported in Public Cloud 7 | @description('Audience uris for public and national clouds') 8 | param audiences object = { 9 | AzureCloud: { 10 | uri: 'api://AzureADTokenExchange' 11 | } 12 | AzureUSGovernment: { 13 | uri: 'api://AzureADTokenExchangeUSGov' 14 | } 15 | USNat: { 16 | uri: 'api://AzureADTokenExchangeUSNat' 17 | } 18 | USSec: { 19 | uri: 'api://AzureADTokenExchangeUSSec' 20 | } 21 | AzureChinaCloud: { 22 | uri: 'api://AzureADTokenExchangeChina' 23 | } 24 | } 25 | 26 | @description('Specifies the ID of the user-assigned managed identity.') 27 | param webAppIdentityId string 28 | 29 | @description('Specifies the unique name for the client application.') 30 | param clientAppName string 31 | 32 | @description('Specifies the display name for the client application') 33 | param clientAppDisplayName string 34 | 35 | @description('Specifies the scopes that the client application requires.') 36 | param clientAppScopes array = ['User.Read', 'offline_access', 'openid', 'profile'] 37 | 38 | param serviceManagementReference string = '' 39 | 40 | param issuer string 41 | 42 | param webAppEndpoint string 43 | 44 | // Get the MS Graph Service Principal based on its application ID: 45 | // https://learn.microsoft.com/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in 46 | var msGraphAppId = '00000003-0000-0000-c000-000000000000' 47 | resource msGraphSP 'Microsoft.Graph/servicePrincipals@v1.0' existing = { 48 | appId: msGraphAppId 49 | } 50 | 51 | var graphScopes = msGraphSP.oauth2PermissionScopes 52 | resource clientApp 'Microsoft.Graph/applications@v1.0' = { 53 | uniqueName: clientAppName 54 | displayName: clientAppDisplayName 55 | signInAudience: 'AzureADMyOrg' 56 | serviceManagementReference: empty(serviceManagementReference) ? null : serviceManagementReference 57 | web: { 58 | redirectUris: [ 59 | 'http://localhost:50505/.auth/login/aad/callback' 60 | '${webAppEndpoint}/.auth/login/aad/callback' 61 | ] 62 | implicitGrantSettings: { enableIdTokenIssuance: true } 63 | } 64 | requiredResourceAccess: [ 65 | { 66 | resourceAppId: msGraphAppId 67 | resourceAccess: [ 68 | for (scope, i) in clientAppScopes: { 69 | id: filter(graphScopes, graphScopes => graphScopes.value == scope)[0].id 70 | type: 'Scope' 71 | } 72 | ] 73 | } 74 | ] 75 | 76 | resource clientAppFic 'federatedIdentityCredentials@v1.0' = { 77 | name: '${clientApp.uniqueName}/miAsFic' 78 | audiences: [ 79 | audiences[cloudEnvironment].uri 80 | ] 81 | issuer: issuer 82 | subject: webAppIdentityId 83 | } 84 | } 85 | 86 | resource clientSp 'Microsoft.Graph/servicePrincipals@v1.0' = { 87 | appId: clientApp.appId 88 | } 89 | 90 | output clientAppId string = clientApp.appId 91 | output clientSpId string = clientSp.id 92 | -------------------------------------------------------------------------------- /infra/appupdate.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Apps Auth Config using Microsoft Entra as Identity Provider.' 2 | 3 | @description('The name of the container apps resource within the current resource group scope') 4 | param containerAppName string 5 | 6 | @description('The client ID of the Microsoft Entra application.') 7 | param clientId string 8 | 9 | param openIdIssuer string 10 | 11 | @description('Enable token store for the Container App.') 12 | param includeTokenStore bool = false 13 | 14 | @description('The URI of the Azure Blob Storage container to be used for token storage.') 15 | param blobContainerUri string = '' 16 | @description('The resource ID of the managed identity to be used for accessing the Azure Blob Storage.') 17 | param appIdentityResourceId string = '' 18 | 19 | resource app 'Microsoft.App/containerApps@2023-05-01' existing = { 20 | name: containerAppName 21 | } 22 | 23 | resource auth 'Microsoft.App/containerApps/authConfigs@2024-10-02-preview' = { 24 | parent: app 25 | name: 'current' 26 | properties: { 27 | platform: { 28 | enabled: true 29 | } 30 | globalValidation: { 31 | redirectToProvider: 'azureactivedirectory' 32 | unauthenticatedClientAction: 'RedirectToLoginPage' 33 | } 34 | identityProviders: { 35 | azureActiveDirectory: { 36 | enabled: true 37 | registration: { 38 | clientId: clientId 39 | clientSecretSettingName: 'override-use-mi-fic-assertion-client-id' 40 | openIdIssuer: openIdIssuer 41 | } 42 | validation: { 43 | defaultAuthorizationPolicy: { 44 | allowedApplications: [] 45 | } 46 | } 47 | } 48 | } 49 | login: { 50 | // https://learn.microsoft.com/azure/container-apps/token-store 51 | tokenStore: { 52 | enabled: includeTokenStore 53 | azureBlobStorage: includeTokenStore 54 | ? { 55 | blobContainerUri: blobContainerUri 56 | managedIdentityResourceId: appIdentityResourceId 57 | } 58 | : {} 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /infra/bicepconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "experimentalFeaturesEnabled": { 3 | "extensibility": true 4 | }, 5 | // specify an alias for the version of the v1.0 dynamic types package you want to use 6 | "extensions": { 7 | "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.1.8-preview" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /infra/core/ai/cognitiveservices.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Cognitive Services instance.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | @description('The custom subdomain name used to access the API. Defaults to the value of the name parameter.') 6 | param customSubDomainName string = name 7 | param disableLocalAuth bool = false 8 | param deployments array = [] 9 | param kind string = 'OpenAI' 10 | 11 | @allowed([ 'Enabled', 'Disabled' ]) 12 | param publicNetworkAccess string = 'Enabled' 13 | param sku object = { 14 | name: 'S0' 15 | } 16 | 17 | param allowedIpRules array = [] 18 | param networkAcls object = empty(allowedIpRules) ? { 19 | defaultAction: 'Allow' 20 | } : { 21 | ipRules: allowedIpRules 22 | defaultAction: 'Deny' 23 | } 24 | 25 | resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { 26 | name: name 27 | location: location 28 | tags: tags 29 | kind: kind 30 | properties: { 31 | customSubDomainName: customSubDomainName 32 | publicNetworkAccess: publicNetworkAccess 33 | networkAcls: networkAcls 34 | disableLocalAuth: disableLocalAuth 35 | } 36 | sku: sku 37 | } 38 | 39 | @batchSize(1) 40 | resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { 41 | parent: account 42 | name: deployment.name 43 | properties: { 44 | model: deployment.model 45 | raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null 46 | } 47 | sku: contains(deployment, 'sku') ? deployment.sku : { 48 | name: 'Standard' 49 | capacity: 20 50 | } 51 | }] 52 | 53 | output endpoint string = account.properties.endpoint 54 | output endpoints object = account.properties.endpoints 55 | output id string = account.id 56 | output name string = account.name 57 | -------------------------------------------------------------------------------- /infra/core/host/container-app-upsert.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates or updates an existing Azure Container App.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('The environment name for the container apps') 7 | param containerAppsEnvironmentName string 8 | 9 | @description('The number of CPU cores allocated to a single container instance, e.g., 0.5') 10 | param containerCpuCoreCount string = '0.5' 11 | 12 | @description('The maximum number of replicas to run. Must be at least 1.') 13 | @minValue(1) 14 | param containerMaxReplicas int = 10 15 | 16 | @description('The amount of memory allocated to a single container instance, e.g., 1Gi') 17 | param containerMemory string = '1.0Gi' 18 | 19 | @description('The minimum number of replicas to run. Must be at least 1.') 20 | @minValue(1) 21 | param containerMinReplicas int = 1 22 | 23 | @description('The name of the container') 24 | param containerName string = 'main' 25 | 26 | @description('The name of the container registry') 27 | param containerRegistryName string = '' 28 | 29 | @description('Hostname suffix for container registry. Set when deploying to sovereign clouds') 30 | param containerRegistryHostSuffix string = 'azurecr.io' 31 | 32 | @allowed([ 'http', 'grpc' ]) 33 | @description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC') 34 | param daprAppProtocol string = 'http' 35 | 36 | @description('Enable or disable Dapr for the container app') 37 | param daprEnabled bool = false 38 | 39 | @description('The Dapr app ID') 40 | param daprAppId string = containerName 41 | 42 | @description('Specifies if the resource already exists') 43 | param exists bool = false 44 | 45 | @description('Specifies if Ingress is enabled for the container app') 46 | param ingressEnabled bool = true 47 | 48 | @description('The type of identity for the resource') 49 | @allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) 50 | param identityType string = 'None' 51 | 52 | @description('The name of the user-assigned identity') 53 | param identityName string = '' 54 | 55 | @description('The name of the container image') 56 | param imageName string = '' 57 | 58 | @description('The secrets required for the container') 59 | @secure() 60 | param secrets object = {} 61 | 62 | @description('The environment variables for the container') 63 | param env array = [] 64 | 65 | @description('Specifies if the resource ingress is exposed externally') 66 | param external bool = true 67 | 68 | @description('The service binds associated with the container') 69 | param serviceBinds array = [] 70 | 71 | @description('The target port for the container') 72 | param targetPort int = 80 73 | 74 | resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { 75 | name: name 76 | } 77 | 78 | module app 'container-app.bicep' = { 79 | name: '${deployment().name}-update' 80 | params: { 81 | name: name 82 | location: location 83 | tags: tags 84 | identityType: identityType 85 | identityName: identityName 86 | ingressEnabled: ingressEnabled 87 | containerName: containerName 88 | containerAppsEnvironmentName: containerAppsEnvironmentName 89 | containerRegistryName: containerRegistryName 90 | containerRegistryHostSuffix: containerRegistryHostSuffix 91 | containerCpuCoreCount: containerCpuCoreCount 92 | containerMemory: containerMemory 93 | containerMinReplicas: containerMinReplicas 94 | containerMaxReplicas: containerMaxReplicas 95 | daprEnabled: daprEnabled 96 | daprAppId: daprAppId 97 | daprAppProtocol: daprAppProtocol 98 | secrets: secrets 99 | external: external 100 | env: env 101 | imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : '' 102 | targetPort: targetPort 103 | serviceBinds: serviceBinds 104 | } 105 | } 106 | 107 | output defaultDomain string = app.outputs.defaultDomain 108 | output imageName string = app.outputs.imageName 109 | output name string = app.outputs.name 110 | output uri string = app.outputs.uri 111 | -------------------------------------------------------------------------------- /infra/core/host/container-app.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a container app in an Azure Container App environment.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Allowed origins') 7 | param allowedOrigins array = [] 8 | 9 | @description('Name of the environment for container apps') 10 | param containerAppsEnvironmentName string 11 | 12 | @description('CPU cores allocated to a single container instance, e.g., 0.5') 13 | param containerCpuCoreCount string = '0.5' 14 | 15 | @description('The maximum number of replicas to run. Must be at least 1.') 16 | @minValue(1) 17 | param containerMaxReplicas int = 10 18 | 19 | @description('Memory allocated to a single container instance, e.g., 1Gi') 20 | param containerMemory string = '1.0Gi' 21 | 22 | @description('The minimum number of replicas to run. Must be at least 1.') 23 | param containerMinReplicas int = 1 24 | 25 | @description('The name of the container') 26 | param containerName string = 'main' 27 | 28 | @description('The name of the container registry') 29 | param containerRegistryName string = '' 30 | 31 | @description('Hostname suffix for container registry. Set when deploying to sovereign clouds') 32 | param containerRegistryHostSuffix string = 'azurecr.io' 33 | 34 | @description('The protocol used by Dapr to connect to the app, e.g., http or grpc') 35 | @allowed([ 'http', 'grpc' ]) 36 | param daprAppProtocol string = 'http' 37 | 38 | @description('The Dapr app ID') 39 | param daprAppId string = containerName 40 | 41 | @description('Enable Dapr') 42 | param daprEnabled bool = false 43 | 44 | @description('The environment variables for the container') 45 | param env array = [] 46 | 47 | @description('Specifies if the resource ingress is exposed externally') 48 | param external bool = true 49 | 50 | @description('The name of the user-assigned identity') 51 | param identityName string = '' 52 | 53 | @description('The type of identity for the resource') 54 | @allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) 55 | param identityType string = 'None' 56 | 57 | @description('The name of the container image') 58 | param imageName string = '' 59 | 60 | @description('Specifies if Ingress is enabled for the container app') 61 | param ingressEnabled bool = true 62 | 63 | param revisionMode string = 'Single' 64 | 65 | @description('The secrets required for the container') 66 | @secure() 67 | param secrets object = {} 68 | 69 | @description('The service binds associated with the container') 70 | param serviceBinds array = [] 71 | 72 | @description('The name of the container apps add-on to use. e.g. redis') 73 | param serviceType string = '' 74 | 75 | @description('The target port for the container') 76 | param targetPort int = 80 77 | 78 | resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = if (!empty(identityName)) { 79 | name: identityName 80 | } 81 | 82 | // Private registry support requires both an ACR name and a User Assigned managed identity 83 | var usePrivateRegistry = !empty(identityName) && !empty(containerRegistryName) 84 | 85 | // Automatically set to `UserAssigned` when an `identityName` has been set 86 | var normalizedIdentityType = !empty(identityName) ? 'UserAssigned' : identityType 87 | 88 | module containerRegistryAccess '../security/registry-access.bicep' = if (usePrivateRegistry) { 89 | name: '${deployment().name}-registry-access' 90 | params: { 91 | containerRegistryName: containerRegistryName 92 | principalId: usePrivateRegistry ? userIdentity.properties.principalId : '' 93 | } 94 | } 95 | 96 | resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { 97 | name: name 98 | location: location 99 | tags: tags 100 | // It is critical that the identity is granted ACR pull access before the app is created 101 | // otherwise the container app will throw a provision error 102 | // This also forces us to use an user assigned managed identity since there would no way to 103 | // provide the system assigned identity with the ACR pull access before the app is created 104 | dependsOn: usePrivateRegistry ? [ containerRegistryAccess ] : [] 105 | identity: { 106 | type: normalizedIdentityType 107 | userAssignedIdentities: !empty(identityName) && normalizedIdentityType == 'UserAssigned' ? { '${userIdentity.id}': {} } : null 108 | } 109 | properties: { 110 | managedEnvironmentId: containerAppsEnvironment.id 111 | configuration: { 112 | activeRevisionsMode: revisionMode 113 | ingress: ingressEnabled ? { 114 | external: external 115 | targetPort: targetPort 116 | transport: 'auto' 117 | corsPolicy: { 118 | allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) 119 | } 120 | } : null 121 | dapr: daprEnabled ? { 122 | enabled: true 123 | appId: daprAppId 124 | appProtocol: daprAppProtocol 125 | appPort: ingressEnabled ? targetPort : 0 126 | } : { enabled: false } 127 | secrets: [for secret in items(secrets): { 128 | name: secret.key 129 | value: secret.value 130 | }] 131 | service: !empty(serviceType) ? { type: serviceType } : null 132 | registries: usePrivateRegistry ? [ 133 | { 134 | server: '${containerRegistryName}.${containerRegistryHostSuffix}' 135 | identity: userIdentity.id 136 | } 137 | ] : [] 138 | } 139 | template: { 140 | serviceBinds: !empty(serviceBinds) ? serviceBinds : null 141 | containers: [ 142 | { 143 | image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' 144 | name: containerName 145 | env: env 146 | resources: { 147 | cpu: json(containerCpuCoreCount) 148 | memory: containerMemory 149 | } 150 | } 151 | ] 152 | scale: { 153 | minReplicas: containerMinReplicas 154 | maxReplicas: containerMaxReplicas 155 | } 156 | } 157 | } 158 | } 159 | 160 | resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { 161 | name: containerAppsEnvironmentName 162 | } 163 | 164 | output defaultDomain string = containerAppsEnvironment.properties.defaultDomain 165 | output identityPrincipalId string = normalizedIdentityType == 'None' ? '' : (empty(identityName) ? app.identity.principalId : userIdentity.properties.principalId) 166 | output imageName string = imageName 167 | output name string = app.name 168 | output serviceBind object = !empty(serviceType) ? { serviceId: app.id, name: name } : {} 169 | output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : '' 170 | -------------------------------------------------------------------------------- /infra/core/host/container-apps-environment.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Apps environment.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Name of the Application Insights resource') 7 | param applicationInsightsName string = '' 8 | 9 | @description('Specifies if Dapr is enabled') 10 | param daprEnabled bool = false 11 | 12 | @description('Name of the Log Analytics workspace') 13 | param logAnalyticsWorkspaceName string 14 | 15 | resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { 16 | name: name 17 | location: location 18 | tags: tags 19 | properties: { 20 | appLogsConfiguration: { 21 | destination: 'log-analytics' 22 | logAnalyticsConfiguration: { 23 | customerId: logAnalyticsWorkspace.properties.customerId 24 | sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey 25 | } 26 | } 27 | daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' 28 | } 29 | } 30 | 31 | resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { 32 | name: logAnalyticsWorkspaceName 33 | } 34 | 35 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) { 36 | name: applicationInsightsName 37 | } 38 | 39 | output defaultDomain string = containerAppsEnvironment.properties.defaultDomain 40 | output id string = containerAppsEnvironment.id 41 | output name string = containerAppsEnvironment.name 42 | -------------------------------------------------------------------------------- /infra/core/host/container-apps.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | param containerAppsEnvironmentName string 7 | param containerRegistryName string 8 | param containerRegistryResourceGroupName string = '' 9 | param containerRegistryAdminUserEnabled bool = false 10 | param logAnalyticsWorkspaceName string 11 | param applicationInsightsName string = '' 12 | param daprEnabled bool = false 13 | 14 | module containerAppsEnvironment 'container-apps-environment.bicep' = { 15 | name: '${name}-container-apps-environment' 16 | params: { 17 | name: containerAppsEnvironmentName 18 | location: location 19 | tags: tags 20 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 21 | applicationInsightsName: applicationInsightsName 22 | daprEnabled: daprEnabled 23 | } 24 | } 25 | 26 | module containerRegistry 'container-registry.bicep' = { 27 | name: '${name}-container-registry' 28 | scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup() 29 | params: { 30 | name: containerRegistryName 31 | location: location 32 | adminUserEnabled: containerRegistryAdminUserEnabled 33 | tags: tags 34 | } 35 | } 36 | 37 | output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain 38 | output environmentName string = containerAppsEnvironment.outputs.name 39 | output environmentId string = containerAppsEnvironment.outputs.id 40 | 41 | output registryLoginServer string = containerRegistry.outputs.loginServer 42 | output registryName string = containerRegistry.outputs.name 43 | -------------------------------------------------------------------------------- /infra/core/host/container-registry.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates an Azure Container Registry.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | @description('Indicates whether admin user is enabled') 7 | param adminUserEnabled bool = false 8 | 9 | @description('Indicates whether anonymous pull is enabled') 10 | param anonymousPullEnabled bool = false 11 | 12 | @description('Azure ad authentication as arm policy settings') 13 | param azureADAuthenticationAsArmPolicy object = { 14 | status: 'enabled' 15 | } 16 | 17 | @description('Indicates whether data endpoint is enabled') 18 | param dataEndpointEnabled bool = false 19 | 20 | @description('Encryption settings') 21 | param encryption object = { 22 | status: 'disabled' 23 | } 24 | 25 | @description('Export policy settings') 26 | param exportPolicy object = { 27 | status: 'enabled' 28 | } 29 | 30 | @description('Metadata search settings') 31 | param metadataSearch string = 'Disabled' 32 | 33 | @description('Options for bypassing network rules') 34 | param networkRuleBypassOptions string = 'AzureServices' 35 | 36 | @description('Public network access setting') 37 | param publicNetworkAccess string = 'Enabled' 38 | 39 | @description('Quarantine policy settings') 40 | param quarantinePolicy object = { 41 | status: 'disabled' 42 | } 43 | 44 | @description('Retention policy settings') 45 | param retentionPolicy object = { 46 | days: 7 47 | status: 'disabled' 48 | } 49 | 50 | @description('Scope maps setting') 51 | param scopeMaps array = [] 52 | 53 | @description('SKU settings') 54 | param sku object = { 55 | name: 'Basic' 56 | } 57 | 58 | @description('Soft delete policy settings') 59 | param softDeletePolicy object = { 60 | retentionDays: 7 61 | status: 'disabled' 62 | } 63 | 64 | @description('Trust policy settings') 65 | param trustPolicy object = { 66 | type: 'Notary' 67 | status: 'disabled' 68 | } 69 | 70 | @description('Zone redundancy setting') 71 | param zoneRedundancy string = 'Disabled' 72 | 73 | @description('The log analytics workspace ID used for logging and monitoring') 74 | param workspaceId string = '' 75 | 76 | // 2023-11-01-preview needed for metadataSearch 77 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { 78 | name: name 79 | location: location 80 | tags: tags 81 | sku: sku 82 | properties: { 83 | adminUserEnabled: adminUserEnabled 84 | anonymousPullEnabled: anonymousPullEnabled 85 | dataEndpointEnabled: dataEndpointEnabled 86 | encryption: encryption 87 | metadataSearch: metadataSearch 88 | networkRuleBypassOptions: networkRuleBypassOptions 89 | policies:{ 90 | quarantinePolicy: quarantinePolicy 91 | trustPolicy: trustPolicy 92 | retentionPolicy: retentionPolicy 93 | exportPolicy: exportPolicy 94 | azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy 95 | softDeletePolicy: softDeletePolicy 96 | } 97 | publicNetworkAccess: publicNetworkAccess 98 | zoneRedundancy: zoneRedundancy 99 | } 100 | 101 | resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { 102 | name: scopeMap.name 103 | properties: scopeMap.properties 104 | }] 105 | } 106 | 107 | // TODO: Update diagnostics to be its own module 108 | // Blocking issue: https://github.com/Azure/bicep/issues/622 109 | // Unable to pass in a `resource` scope or unable to use string interpolation in resource types 110 | resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { 111 | name: 'registry-diagnostics' 112 | scope: containerRegistry 113 | properties: { 114 | workspaceId: workspaceId 115 | logs: [ 116 | { 117 | category: 'ContainerRegistryRepositoryEvents' 118 | enabled: true 119 | } 120 | { 121 | category: 'ContainerRegistryLoginEvents' 122 | enabled: true 123 | } 124 | ] 125 | metrics: [ 126 | { 127 | category: 'AllMetrics' 128 | enabled: true 129 | timeGrain: 'PT1M' 130 | } 131 | ] 132 | } 133 | } 134 | 135 | output id string = containerRegistry.id 136 | output loginServer string = containerRegistry.properties.loginServer 137 | output name string = containerRegistry.name 138 | -------------------------------------------------------------------------------- /infra/core/monitor/loganalytics.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a Log Analytics workspace.' 2 | param name string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | 6 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { 7 | name: name 8 | location: location 9 | tags: tags 10 | properties: any({ 11 | retentionInDays: 30 12 | features: { 13 | searchVersion: 1 14 | } 15 | sku: { 16 | name: 'PerGB2018' 17 | } 18 | }) 19 | } 20 | 21 | output id string = logAnalytics.id 22 | output name string = logAnalytics.name 23 | -------------------------------------------------------------------------------- /infra/core/security/registry-access.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' 2 | param containerRegistryName string 3 | param principalId string 4 | 5 | var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') 6 | 7 | resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 8 | scope: containerRegistry // Use when specifying a scope that is different than the deployment scope 9 | name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) 10 | properties: { 11 | roleDefinitionId: acrPullRole 12 | principalType: 'ServicePrincipal' 13 | principalId: principalId 14 | } 15 | } 16 | 17 | resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { 18 | name: containerRegistryName 19 | } 20 | -------------------------------------------------------------------------------- /infra/core/security/role.bicep: -------------------------------------------------------------------------------- 1 | metadata description = 'Creates a role assignment for a service principal.' 2 | param principalId string 3 | 4 | @allowed([ 5 | 'Device' 6 | 'ForeignGroup' 7 | 'Group' 8 | 'ServicePrincipal' 9 | 'User' 10 | ]) 11 | param principalType string = 'ServicePrincipal' 12 | param roleDefinitionId string 13 | 14 | resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 15 | name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) 16 | properties: { 17 | principalId: principalId 18 | principalType: principalType 19 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /infra/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 | param acaExists bool = false 16 | 17 | @minLength(1) 18 | @description('Location for the Azure AI resource') 19 | // https://learn.microsoft.com/azure/ai-studio/how-to/deploy-models-serverless-availability#deepseek-models-from-microsoft 20 | @allowed([ 21 | 'eastus' 22 | 'eastus2' 23 | 'northcentralus' 24 | 'southcentralus' 25 | 'westus' 26 | 'westus3' 27 | ]) 28 | @metadata({ 29 | azd: { 30 | type: 'location' 31 | } 32 | }) 33 | param aiServicesResourceLocation string 34 | param disableKeyBasedAuth bool = true 35 | 36 | // Parameters for the specific Azure AI deployment: 37 | param aiServicesDeploymentName string = 'DeepSeek-R1' 38 | 39 | @description('Service Management Reference for the Entra app registration') 40 | param serviceManagementReference string = '' 41 | 42 | var resourceToken = toLower(uniqueString(subscription().id, name, location)) 43 | var tags = { 'azd-env-name': name } 44 | 45 | resource resourceGroup 'Microsoft.Resources/resourceGroups@2021-04-01' = { 46 | name: '${name}-rg' 47 | location: location 48 | tags: tags 49 | } 50 | 51 | var prefix = '${name}-${resourceToken}' 52 | 53 | var aiServicesNameAndSubdomain = '${resourceToken}-aiservices' 54 | module aiServices 'br/public:avm/res/cognitive-services/account:0.7.2' = { 55 | name: 'deepseek' 56 | scope: resourceGroup 57 | params: { 58 | name: aiServicesNameAndSubdomain 59 | location: aiServicesResourceLocation 60 | tags: tags 61 | kind: 'AIServices' 62 | customSubDomainName: aiServicesNameAndSubdomain 63 | sku: 'S0' 64 | publicNetworkAccess: 'Enabled' 65 | deployments: [ 66 | { 67 | name: aiServicesDeploymentName 68 | model: { 69 | format: 'DeepSeek' 70 | name: 'DeepSeek-R1' 71 | version: '1' 72 | } 73 | sku: { 74 | name: 'GlobalStandard' 75 | capacity: 1 76 | } 77 | } 78 | ] 79 | disableLocalAuth: disableKeyBasedAuth 80 | roleAssignments: [ 81 | { 82 | principalId: principalId 83 | principalType: 'User' 84 | roleDefinitionIdOrName: 'Cognitive Services User' 85 | } 86 | ] 87 | } 88 | } 89 | 90 | module logAnalyticsWorkspace 'core/monitor/loganalytics.bicep' = { 91 | name: 'loganalytics' 92 | scope: resourceGroup 93 | params: { 94 | name: '${prefix}-loganalytics' 95 | location: location 96 | tags: tags 97 | } 98 | } 99 | 100 | // Container apps host (including container registry) 101 | module containerApps 'core/host/container-apps.bicep' = { 102 | name: 'container-apps' 103 | scope: resourceGroup 104 | params: { 105 | name: 'app' 106 | location: location 107 | tags: tags 108 | containerAppsEnvironmentName: '${prefix}-containerapps-env' 109 | containerRegistryName: '${replace(prefix, '-', '')}registry' 110 | logAnalyticsWorkspaceName: logAnalyticsWorkspace.outputs.name 111 | } 112 | } 113 | 114 | // Container app frontend 115 | module aca 'aca.bicep' = { 116 | name: 'aca' 117 | scope: resourceGroup 118 | params: { 119 | name: replace('${take(prefix,19)}-ca', '--', '-') 120 | location: location 121 | tags: tags 122 | identityName: '${prefix}-id-aca' 123 | containerAppsEnvironmentName: containerApps.outputs.environmentName 124 | containerRegistryName: containerApps.outputs.registryName 125 | aiServicesDeploymentName: aiServicesDeploymentName 126 | aiServicesEndpoint: 'https://${aiServices.outputs.name}.services.ai.azure.com' 127 | exists: acaExists 128 | } 129 | } 130 | 131 | var issuer = '${environment().authentication.loginEndpoint}${tenant().tenantId}/v2.0' 132 | module registration 'appregistration.bicep' = { 133 | name: 'reg' 134 | scope: resourceGroup 135 | params: { 136 | clientAppName: '${prefix}-entra-client-app' 137 | clientAppDisplayName: 'DeepSeek Entra Client App' 138 | webAppEndpoint: aca.outputs.uri 139 | webAppIdentityId: aca.outputs.identityPrincipalId 140 | issuer: issuer 141 | serviceManagementReference: serviceManagementReference 142 | } 143 | } 144 | 145 | module appupdate 'appupdate.bicep' = { 146 | name: 'appupdate' 147 | scope: resourceGroup 148 | params: { 149 | containerAppName: aca.outputs.name 150 | clientId: registration.outputs.clientAppId 151 | openIdIssuer: issuer 152 | includeTokenStore: false 153 | } 154 | } 155 | 156 | module aiServicesRoleBackend 'core/security/role.bicep' = { 157 | scope: resourceGroup 158 | name: 'aiservices-role-backend' 159 | params: { 160 | principalId: aca.outputs.identityPrincipalId 161 | roleDefinitionId: 'a97b65f3-24c7-4388-baec-2e87135dc908' 162 | principalType: 'ServicePrincipal' 163 | } 164 | } 165 | 166 | output AZURE_LOCATION string = location 167 | output AZURE_TENANT_ID string = tenant().tenantId 168 | 169 | output AZURE_DEEPSEEK_DEPLOYMENT string = aiServicesDeploymentName 170 | output AZURE_INFERENCE_ENDPOINT string = 'https://${aiServices.outputs.name}.services.ai.azure.com' 171 | 172 | output SERVICE_ACA_IDENTITY_PRINCIPAL_ID string = aca.outputs.identityPrincipalId 173 | output SERVICE_ACA_NAME string = aca.outputs.name 174 | output SERVICE_ACA_URI string = aca.outputs.uri 175 | output SERVICE_ACA_IMAGE_NAME string = aca.outputs.imageName 176 | 177 | output AZURE_CONTAINER_ENVIRONMENT_NAME string = containerApps.outputs.environmentName 178 | output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerApps.outputs.registryLoginServer 179 | output AZURE_CONTAINER_REGISTRY_NAME string = containerApps.outputs.registryName 180 | -------------------------------------------------------------------------------- /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 | "createRoleForUser": { 15 | "value": "${CREATE_ROLE_FOR_USER=true}" 16 | }, 17 | "acaExists": { 18 | "value": "${SERVICE_ACA_RESOURCE_EXISTS=false}" 19 | }, 20 | "disableKeyBasedAuth": { 21 | "value": "${DISABLE_KEY_BASED_AUTH=true}" 22 | }, 23 | "serviceManagementReference": { 24 | "value": "${AZURE_SERVICE_MANAGEMENT_REFERENCE}" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | lint.select = ["E", "F", "I", "UP"] 3 | target-version = "py311" 4 | line-length = 120 5 | src = ["src"] 6 | 7 | [tool.ruff.lint.isort] 8 | known-first-party = ["quartapp"] 9 | 10 | [tool.black] 11 | target-version = ["py311"] 12 | line-length = 120 13 | 14 | [tool.pytest.ini_options] 15 | addopts = "-ra --cov" 16 | pythonpath = ["src"] 17 | 18 | [tool.coverage.report] 19 | show_missing = true 20 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r src/requirements.txt 2 | black 3 | ruff 4 | pre-commit 5 | pytest 6 | pytest-asyncio 7 | pytest-snapshot 8 | pytest-cov 9 | pip-tools 10 | -------------------------------------------------------------------------------- /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 | 13 | # ------------------- Stage 1: Build Stage ------------------------------ 14 | FROM base AS build 15 | 16 | COPY requirements.txt . 17 | 18 | RUN pip3 install -r requirements.txt 19 | 20 | COPY . . 21 | # ------------------- Stage 2: Final Stage ------------------------------ 22 | FROM base AS final 23 | 24 | RUN addgroup -S app && adduser -S app -G app 25 | 26 | COPY --from=build --chown=app:app /usr/local/lib/python3.11 /usr/local/lib/python3.11 27 | COPY --from=build --chown=app:app /usr/local/bin /usr/local/bin 28 | COPY --from=build --chown=app:app /code /code 29 | 30 | USER app 31 | 32 | EXPOSE 50505 33 | 34 | ENTRYPOINT ["tini", "gunicorn", "quartapp:create_app()"] 35 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/deepseek-python/d6ade4d5239ba151bcb9dd837c26ec43489badbb/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 | workers = 1 19 | worker_class = "uvicorn.workers.UvicornWorker" 20 | 21 | timeout = 120 22 | -------------------------------------------------------------------------------- /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", 10 | # Used by uvicorn on non-Windows platforms 11 | "uvloop>=0.16.0; sys_platform != 'win32' and (sys_platform != 'cygwin' and platform_python_implementation != 'PyPy')", 12 | # Recommended for uvicorn 13 | "httptools", 14 | # Used by uvicorn for reload functionality 15 | "watchfiles", 16 | "azure-identity", 17 | "openai", 18 | "aiohttp", 19 | "python-dotenv", 20 | "pyyaml" 21 | ] 22 | 23 | [build-system] 24 | requires = ["flit_core<4"] 25 | build-backend = "flit_core.buildapi" 26 | -------------------------------------------------------------------------------- /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(testing=False): 9 | # We do this here in addition to gunicorn.conf.py, since we don't always use gunicorn 10 | if not testing: 11 | load_dotenv(override=True) 12 | 13 | if os.getenv("RUNNING_IN_PRODUCTION"): 14 | logging.basicConfig(level=logging.WARNING) 15 | else: 16 | logging.basicConfig(level=logging.INFO) 17 | 18 | app = Quart(__name__) 19 | 20 | from . import chat # noqa 21 | 22 | app.register_blueprint(chat.bp) 23 | 24 | return app 25 | -------------------------------------------------------------------------------- /src/quartapp/chat.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from azure.identity.aio import AzureDeveloperCliCredential, ManagedIdentityCredential, get_bearer_token_provider 5 | from openai import AsyncAzureOpenAI 6 | from quart import ( 7 | Blueprint, 8 | Response, 9 | current_app, 10 | render_template, 11 | request, 12 | stream_with_context, 13 | ) 14 | 15 | bp = Blueprint("chat", __name__, template_folder="templates", static_folder="static") 16 | 17 | 18 | @bp.before_app_serving 19 | async def configure_openai(): 20 | if os.getenv("RUNNING_IN_PRODUCTION"): 21 | client_id = os.environ["AZURE_CLIENT_ID"] 22 | current_app.logger.info("Using Azure OpenAI with managed identity credential for client ID: %s", client_id) 23 | bp.azure_credential = ManagedIdentityCredential(client_id=client_id) 24 | else: 25 | tenant_id = os.environ["AZURE_TENANT_ID"] 26 | current_app.logger.info("Using Azure OpenAI with Azure Developer CLI credential for tenant ID: %s", tenant_id) 27 | bp.azure_credential = AzureDeveloperCliCredential(tenant_id=tenant_id) 28 | 29 | # Get the token provider for Azure OpenAI based on the selected Azure credential 30 | openai_token_provider = get_bearer_token_provider( 31 | bp.azure_credential, "https://cognitiveservices.azure.com/.default" 32 | ) 33 | 34 | # Create the Asynchronous Azure OpenAI client 35 | bp.openai_client = AsyncAzureOpenAI( 36 | azure_endpoint=os.environ["AZURE_INFERENCE_ENDPOINT"], 37 | azure_ad_token_provider=openai_token_provider, 38 | api_version="2025-04-01-preview", # temporary 39 | ) 40 | 41 | # Set the model name to the Azure OpenAI model deployment name 42 | bp.openai_model = os.getenv("AZURE_DEEPSEEK_DEPLOYMENT") 43 | 44 | 45 | @bp.after_app_serving 46 | async def shutdown_openai(): 47 | await bp.openai_client.close() 48 | 49 | 50 | @bp.get("/") 51 | async def index(): 52 | return await render_template("index.html") 53 | 54 | 55 | @bp.post("/chat/stream") 56 | async def chat_handler(): 57 | request_messages = (await request.get_json())["messages"] 58 | 59 | @stream_with_context 60 | async def response_stream(): 61 | # This sends all messages, so API request may exceed token limits 62 | all_messages = [ 63 | {"role": "system", "content": "You are a helpful assistant."}, 64 | ] + request_messages 65 | 66 | chat_coroutine = bp.openai_client.chat.completions.create( 67 | # Azure Open AI takes the deployment name as the model name 68 | model=bp.openai_model, 69 | messages=all_messages, 70 | stream=True, 71 | ) 72 | 73 | try: 74 | async for update in await chat_coroutine: 75 | if update.choices: 76 | yield update.choices[0].model_dump_json() + "\n" 77 | except Exception as e: 78 | current_app.logger.error(e) 79 | yield json.dumps({"error": str(e)}, ensure_ascii=False) + "\n" 80 | 81 | return Response(response_stream()) 82 | -------------------------------------------------------------------------------- /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 | 21 | .thoughts { 22 | background-color: rgb(239, 239, 239); 23 | border-radius: 3px; 24 | padding: 6px 6px 0px 6px; 25 | } 26 | 27 | .loading-bar { 28 | width: 30%; 29 | background-color: #ffffff; 30 | border-radius: 4px; 31 | overflow: hidden; 32 | height: 4px; 33 | position: relative; 34 | } 35 | 36 | .loading-bar::before { 37 | content: ''; 38 | display: block; 39 | height: 100%; 40 | width: 0; 41 | background-color: #2c8310; 42 | animation: loading 2s infinite; 43 | } 44 | 45 | @keyframes loading { 46 | 0% { 47 | width: 0; 48 | } 49 | 50% { 50 | width: 100%; 51 | } 52 | 100% { 53 | width: 0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/quartapp/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | OpenAI ChatGPT Demo 8 | 10 | 12 | 14 | 15 | 16 | 17 |
18 | 19 |
20 | 21 | 35 | 36 | 59 | 60 | 61 |
62 |
63 |
64 |
65 | 66 | 67 | 71 |
72 |
73 |
74 |
75 | 76 | 77 | 78 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /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.3 10 | # via aiohttp 11 | aiohttp==3.10.11 12 | # via quartapp (pyproject.toml) 13 | aiosignal==1.3.1 14 | # via aiohttp 15 | annotated-types==0.7.0 16 | # via pydantic 17 | anyio==4.6.0 18 | # via 19 | # httpx 20 | # openai 21 | # watchfiles 22 | attrs==24.2.0 23 | # via aiohttp 24 | azure-core==1.31.0 25 | # via azure-identity 26 | azure-identity==1.19.0 27 | # via quartapp (pyproject.toml) 28 | blinker==1.8.2 29 | # via 30 | # flask 31 | # quart 32 | certifi==2024.8.30 33 | # via 34 | # httpcore 35 | # httpx 36 | # requests 37 | cffi==1.17.1 38 | # via cryptography 39 | charset-normalizer==3.4.0 40 | # via requests 41 | click==8.2.1 42 | # via 43 | # flask 44 | # quart 45 | # uvicorn 46 | cryptography==44.0.1 47 | # via 48 | # azure-identity 49 | # msal 50 | # pyjwt 51 | distro==1.9.0 52 | # via openai 53 | flask==3.0.3 54 | # via quart 55 | frozenlist==1.4.1 56 | # via 57 | # aiohttp 58 | # aiosignal 59 | gunicorn==23.0.0 60 | # via quartapp (pyproject.toml) 61 | h11==0.16.0 62 | # via 63 | # httpcore 64 | # hypercorn 65 | # uvicorn 66 | # wsproto 67 | h2==4.1.0 68 | # via hypercorn 69 | hpack==4.0.0 70 | # via h2 71 | httpcore==1.0.9 72 | # via httpx 73 | httptools==0.6.4 74 | # via quartapp (pyproject.toml) 75 | httpx==0.28.1 76 | # via openai 77 | hypercorn==0.17.3 78 | # via quart 79 | hyperframe==6.0.1 80 | # via h2 81 | idna==3.10 82 | # via 83 | # anyio 84 | # httpx 85 | # requests 86 | # yarl 87 | itsdangerous==2.2.0 88 | # via 89 | # flask 90 | # quart 91 | jinja2==3.1.6 92 | # via 93 | # flask 94 | # quart 95 | jiter==0.9.0 96 | # via openai 97 | markupsafe==3.0.1 98 | # via 99 | # jinja2 100 | # quart 101 | # werkzeug 102 | msal==1.31.0 103 | # via 104 | # azure-identity 105 | # msal-extensions 106 | msal-extensions==1.2.0 107 | # via azure-identity 108 | multidict==6.1.0 109 | # via 110 | # aiohttp 111 | # yarl 112 | openai==1.66.2 113 | # via quartapp (pyproject.toml) 114 | packaging==24.1 115 | # via gunicorn 116 | portalocker==2.10.1 117 | # via msal-extensions 118 | priority==2.0.0 119 | # via hypercorn 120 | propcache==0.2.0 121 | # via yarl 122 | pycparser==2.22 123 | # via cffi 124 | pydantic==2.10.6 125 | # via openai 126 | pydantic-core==2.27.2 127 | # via pydantic 128 | pyjwt[crypto]==2.9.0 129 | # via 130 | # msal 131 | # pyjwt 132 | python-dotenv==1.0.1 133 | # via quartapp (pyproject.toml) 134 | pyyaml==6.0.2 135 | # via quartapp (pyproject.toml) 136 | quart==0.20.0 137 | # via quartapp (pyproject.toml) 138 | requests==2.32.3 139 | # via 140 | # azure-core 141 | # msal 142 | six==1.16.0 143 | # via azure-core 144 | sniffio==1.3.1 145 | # via 146 | # anyio 147 | # openai 148 | tqdm==4.67.1 149 | # via openai 150 | typing-extensions==4.12.2 151 | # via 152 | # azure-core 153 | # azure-identity 154 | # openai 155 | # pydantic 156 | # pydantic-core 157 | urllib3==2.2.3 158 | # via requests 159 | uvicorn==0.34.2 160 | # via quartapp (pyproject.toml) 161 | uvloop==0.20.0 ; sys_platform != "win32" and (sys_platform != "cygwin" and platform_python_implementation != "PyPy") 162 | # via quartapp (pyproject.toml) 163 | watchfiles==0.24.0 164 | # via quartapp (pyproject.toml) 165 | werkzeug==3.0.6 166 | # via 167 | # flask 168 | # quart 169 | # quartapp (pyproject.toml) 170 | wsproto==1.2.0 171 | # via hypercorn 172 | yarl==1.17.1 173 | # via aiohttp 174 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/deepseek-python/d6ade4d5239ba151bcb9dd837c26ec43489badbb/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import openai 2 | import pytest 3 | import pytest_asyncio 4 | 5 | import quartapp 6 | 7 | from . import mock_cred 8 | 9 | 10 | @pytest.fixture 11 | def mock_openai_chatcompletion(monkeypatch): 12 | class AsyncChatCompletionIterator: 13 | def __init__(self, reasoning: str, answer: str): 14 | self.chunk_index = 0 15 | self.chunks = [ 16 | openai.types.chat.ChatCompletionChunk( 17 | object="chat.completion.chunk", 18 | choices=[], 19 | id="", 20 | created=0, 21 | model="", 22 | prompt_filter_results=[ 23 | { 24 | "prompt_index": 0, 25 | "content_filter_results": { 26 | "hate": {"filtered": False, "severity": "safe"}, 27 | "self_harm": {"filtered": False, "severity": "safe"}, 28 | "sexual": {"filtered": False, "severity": "safe"}, 29 | "violence": {"filtered": False, "severity": "safe"}, 30 | }, 31 | } 32 | ], 33 | ) 34 | ] 35 | reasoning_deltas = reasoning.split(" ") 36 | for reasoning_index, reasoning_delta in enumerate(reasoning_deltas): 37 | # Text completion chunks include whitespace, so we need to add it back in 38 | if reasoning_index > 0: 39 | answer_delta = " " + reasoning_delta 40 | self.chunks.append( 41 | openai.types.chat.ChatCompletionChunk( 42 | id="test-123", 43 | object="chat.completion.chunk", 44 | choices=[ 45 | openai.types.chat.chat_completion_chunk.Choice( 46 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta( 47 | role=None, reasoning_content=reasoning_delta 48 | ), 49 | finish_reason=None, 50 | index=0, 51 | logprobs=None, 52 | # Only Azure includes content_filter_results 53 | content_filter_results={ 54 | "hate": {"filtered": False, "severity": "safe"}, 55 | "self_harm": {"filtered": False, "severity": "safe"}, 56 | "sexual": {"filtered": False, "severity": "safe"}, 57 | "violence": {"filtered": False, "severity": "safe"}, 58 | }, 59 | ) 60 | ], 61 | created=1703462735, 62 | model="DeepSeek-R1", 63 | ) 64 | ) 65 | 66 | answer_deltas = answer.split(" ") 67 | for answer_index, answer_delta in enumerate(answer_deltas): 68 | # Text completion chunks include whitespace, so we need to add it back in 69 | if answer_index > 0: 70 | answer_delta = " " + answer_delta 71 | self.chunks.append( 72 | openai.types.chat.ChatCompletionChunk( 73 | id="test-123", 74 | object="chat.completion.chunk", 75 | choices=[ 76 | openai.types.chat.chat_completion_chunk.Choice( 77 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta( 78 | role=None, content=answer_delta 79 | ), 80 | finish_reason=None, 81 | index=0, 82 | logprobs=None, 83 | # Only Azure includes content_filter_results 84 | content_filter_results={ 85 | "hate": {"filtered": False, "severity": "safe"}, 86 | "self_harm": {"filtered": False, "severity": "safe"}, 87 | "sexual": {"filtered": False, "severity": "safe"}, 88 | "violence": {"filtered": False, "severity": "safe"}, 89 | }, 90 | ) 91 | ], 92 | created=1703462735, 93 | model="DeepSeek-R1", 94 | ) 95 | ) 96 | self.chunks.append( 97 | openai.types.chat.ChatCompletionChunk( 98 | id="test-123", 99 | object="chat.completion.chunk", 100 | choices=[ 101 | openai.types.chat.chat_completion_chunk.Choice( 102 | delta=openai.types.chat.chat_completion_chunk.ChoiceDelta(content=None, role=None), 103 | index=0, 104 | finish_reason="stop", 105 | # Only Azure includes content_filter_results 106 | content_filter_results={}, 107 | ) 108 | ], 109 | created=1703462735, 110 | model="DeepSeek-R1", 111 | ) 112 | ) 113 | 114 | def __aiter__(self): 115 | return self 116 | 117 | async def __anext__(self): 118 | if self.chunk_index < len(self.chunks): 119 | next_chunk = self.chunks[self.chunk_index] 120 | self.chunk_index += 1 121 | return next_chunk 122 | else: 123 | raise StopAsyncIteration 124 | 125 | async def mock_acreate(*args, **kwargs): 126 | # Only mock a stream=True completion 127 | last_message = kwargs.get("messages")[-1]["content"] 128 | if last_message == "What is the capital of France?": 129 | return AsyncChatCompletionIterator("hmm", "The capital of France is Paris.") 130 | elif last_message == "What is the capital of Germany?": 131 | return AsyncChatCompletionIterator("hmm", "The capital of Germany is Berlin.") 132 | else: 133 | raise ValueError(f"Unexpected message: {last_message}") 134 | 135 | monkeypatch.setattr("openai.resources.chat.AsyncCompletions.create", mock_acreate) 136 | 137 | 138 | @pytest.fixture 139 | def mock_defaultazurecredential(monkeypatch): 140 | monkeypatch.setattr("azure.identity.aio.AzureDeveloperCliCredential", mock_cred.MockAzureCredential) 141 | monkeypatch.setattr("azure.identity.aio.ManagedIdentityCredential", mock_cred.MockAzureCredential) 142 | 143 | 144 | @pytest_asyncio.fixture 145 | async def client(monkeypatch, mock_openai_chatcompletion, mock_defaultazurecredential): 146 | monkeypatch.setenv("AZURE_INFERENCE_ENDPOINT", "test-deepseek-service.ai.azure.com") 147 | monkeypatch.setenv("AZURE_TENANT_ID", "test-tenant-id") 148 | 149 | quart_app = quartapp.create_app(testing=True) 150 | 151 | async with quart_app.test_app() as test_app: 152 | quart_app.config.update({"TESTING": True}) 153 | 154 | yield test_app.test_client() 155 | -------------------------------------------------------------------------------- /tests/mock_cred.py: -------------------------------------------------------------------------------- 1 | import azure.core.credentials 2 | import azure.core.credentials_async 3 | 4 | 5 | class MockAzureCredential(azure.core.credentials_async.AsyncTokenCredential): 6 | async def get_token(self, *scopes, **kwargs): 7 | return azure.core.credentials.AccessToken( 8 | token="mock_token", 9 | expires_on=1703462735, 10 | ) 11 | -------------------------------------------------------------------------------- /tests/snapshots/test_app/test_chat_stream_text/result.jsonlines: -------------------------------------------------------------------------------- 1 | {"delta":{"content":null,"function_call":null,"refusal":null,"role":null,"tool_calls":null,"reasoning_content":"hmm"},"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"}}} 2 | {"delta":{"content":"The","function_call":null,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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.jsonlines: -------------------------------------------------------------------------------- 1 | {"delta":{"content":null,"function_call":null,"refusal":null,"role":null,"tool_calls":null,"reasoning_content":"hmm"},"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"}}} 2 | {"delta":{"content":"The","function_call":null,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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,"refusal":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 | from azure.core.credentials import AzureKeyCredential 3 | from azure.core.credentials_async import AsyncTokenCredential 4 | 5 | import quartapp 6 | 7 | from . import mock_cred 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_index(client): 12 | response = await client.get("/") 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_chat_stream_text(client, snapshot): 18 | response = await client.post( 19 | "/chat/stream", 20 | json={ 21 | "messages": [ 22 | {"role": "user", "content": "What is the capital of France?"}, 23 | ] 24 | }, 25 | ) 26 | assert response.status_code == 200 27 | result = await response.get_data() 28 | snapshot.assert_match(result, "result.jsonlines") 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_chat_stream_text_history(client, snapshot): 33 | response = await client.post( 34 | "/chat/stream", 35 | json={ 36 | "messages": [ 37 | {"role": "user", "content": "What is the capital of France?"}, 38 | {"role": "assistant", "content": "Paris"}, 39 | {"role": "user", "content": "What is the capital of Germany?"}, 40 | ] 41 | }, 42 | ) 43 | assert response.status_code == 200 44 | result = await response.get_data() 45 | snapshot.assert_match(result, "result.jsonlines") 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_openai_managedidentity(monkeypatch): 50 | monkeypatch.setenv("AZURE_CLIENT_ID", "test-client-id") 51 | monkeypatch.setenv("AZURE_INFERENCE_ENDPOINT", "test-deepseek-service.ai.azure.com") 52 | monkeypatch.setenv("RUNNING_IN_PRODUCTION", "true") 53 | 54 | monkeypatch.setattr("azure.identity.aio.ManagedIdentityCredential", mock_cred.MockAzureCredential) 55 | 56 | quart_app = quartapp.create_app(testing=True) 57 | 58 | async with quart_app.test_app(): 59 | assert not isinstance(quart_app.blueprints["chat"].azure_credential, AzureKeyCredential) 60 | assert isinstance(quart_app.blueprints["chat"].azure_credential, AsyncTokenCredential) 61 | --------------------------------------------------------------------------------