Compare commits

..

No commits in common. "main" and "v0.9.5" have entirely different histories.
main ... v0.9.5

940 changed files with 109578 additions and 215005 deletions

View file

@ -13,31 +13,10 @@ OPENAI_API_KEY=''
# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080'
CORS_ALLOW_ORIGIN='*'
# Set to false to keep memory tools enabled without adding memory context to the system context.
ENABLE_MEMORY_SYSTEM_CONTEXT=true
# Set to true to add compact row/column stats to parsed CSV retrieval context.
ENABLE_RAG_CSV_SUMMARY=false
# Set to true to preserve backing file records, storage blobs, and per-file vectors when files are removed from knowledge bases.
ENABLE_KNOWLEDGE_FILE_RETENTION=false
# Set to false to disable workspace Tools and Functions.
ENABLE_PLUGINS=true
# For production you should set this to match the proxy configuration (127.0.0.1)
FORWARDED_ALLOW_IPS='*'
# DO NOT TRACK
SCARF_NO_ANALYTICS=true
DO_NOT_TRACK=true
ANONYMIZED_TELEMETRY=false
# Valkey Vector Store (requires VECTOR_DB=valkey)
# VALKEY_URL='valkey://localhost:6379'
# VALKEY_COLLECTION_PREFIX='open_webui'
# VALKEY_INDEX_TYPE='HNSW'
# VALKEY_DISTANCE_METRIC='COSINE'
# VALKEY_HNSW_M='16'
# VALKEY_HNSW_EF_CONSTRUCTION='200'
# VALKEY_HNSW_EF_RUNTIME='10'
ANONYMIZED_TELEMETRY=false

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
github: open-webui
github: tjbck

View file

@ -2,6 +2,7 @@ name: Bug Report
description: Create a detailed bug report to help us improve Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
assignees: []
body:
- type: markdown
attributes:
@ -34,8 +35,6 @@ body:
required: true
- label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
required: true
- label: I have checked whether this issue is already fixed on the `dev` branch or in the latest source.
required: true
- label: I am using the latest version of Open WebUI.
required: true
@ -56,7 +55,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.11.0)
description: Specify the version (e.g., v0.6.26)
validations:
required: true
@ -64,7 +63,7 @@ body:
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
validations:
required: false
@ -72,7 +71,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
validations:
required: true
@ -80,7 +79,7 @@ body:
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
validations:
required: false
@ -139,11 +138,11 @@ body:
placeholder: |
Example (include every detail):
1. Start with a clean Ubuntu 26.04 install.
2. Install Docker v29.7.1 and start the service.
1. Start with a clean Ubuntu 22.04 install.
2. Install Docker v24.0.5 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 151.0 in incognito mode.
5. Open browser Chrome 115.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".

View file

@ -1,5 +1 @@
blank_issues_enabled: false
contact_links:
- name: 🔒 Report a Security Vulnerability
url: https://github.com/open-webui/open-webui/security
about: Do NOT open a public issue for security vulnerabilities, suspected vulnerabilities, or any security-related concern. Please review our Security Policy and report privately via the "Report a vulnerability" button so it can be handled as a private advisory.

View file

@ -1,78 +1,82 @@
name: Feature Request
description: Suggest a new feature or improvement
description: Suggest an idea for this project
title: 'feat: '
labels: ['triage']
body:
- type: markdown
attributes:
value: |
## Before Submitting
## Important Notes
### Before submitting
Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted.
It's likely we're already tracking it! If youre unsure, start a discussion post first.
Please check **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) for similar requests. If you find one, add your input there instead.
#### Scope
### Scope Guidelines
If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
**We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
This will help us efficiently focus on improving the project.
### Collaborate respectfully
We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**.
Feature requests that require significant implementation effort should be posted in the **Ideas** section of [Discussions](https://github.com/open-webui/open-webui/discussions) instead. We will move oversized feature requests to Discussions to keep the Issues tab focused on actionable items.
Remember:
- Open WebUI is a **volunteer-driven project**
- It's managed by a **single maintainer**
- It's supported by contributors who also have **full-time jobs**
If your request might impact the broader community, please open a Discussion first so others can weigh in on the design.
### Be Respectful
Open WebUI is a volunteer-driven project maintained by a small team. We value constructive, positive communication. Please be mindful of maintainers' time and energy.
We appreciate your time and ask that you **respect ours**.
### Contributing
If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI.
If you encounter an issue, we encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout and maintain project quality.
### Reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may be moved to the "Issues" section in Discussions for community assistance.
### Bug reproducibility
If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, its not that the issue doesnt exist; we need your help!
- type: checkboxes
id: existing-issue
attributes:
label: Check Existing Issues
description: Confirm you have searched for similar requests.
description: Please confirm that you've checked for existing similar requests
options:
- label: I have searched all existing **open AND closed** issues and discussions and found none comparable to my request.
- label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
required: true
- label: I have checked whether this request is already implemented on the `dev` branch or in the latest source.
required: true
- type: checkboxes
id: feature-scope
attributes:
label: Verify Feature Scope
description: Confirm this request belongs in Issues rather than Discussions.
description: Please confirm the feature's scope is within the described scope
options:
- label: I believe this feature request is appropriately scoped for the Issues section as described above.
- label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the Discussions.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: Is this related to a problem? Describe the pain point clearly.
placeholder: "e.g., I'm frustrated when..."
description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is.
placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Proposed Solution
description: Describe what you would like to happen.
label: Desired Solution you'd like
description: Clearly describe what you want to happen.
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or workarounds you have considered.
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request.
description: Add any other context or screenshots about the feature request here.

View file

@ -4,22 +4,17 @@ updates:
directory: '/'
schedule:
interval: monthly
target-branch: dev
target-branch: 'dev'
- package-ecosystem: pip
directory: '/backend'
schedule:
interval: monthly
target-branch: dev
target-branch: 'dev'
- package-ecosystem: github-actions
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
# Check for updates to GitHub Actions every week
interval: monthly
target-branch: dev
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: dev
target-branch: 'dev'

View file

@ -1,114 +1,93 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. First-time contributors should not open pull requests directly unless the pull request contains only i18n/localization updates.
Do not open a PR as the first step.
For real, reproducible bugs, start with a well-described Issue that explains the problem, why it matters, and what outcome you are looking for.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active Discussion.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
We may close unsolicited PRs without review.
Contributors with a history of successful merged PRs may be given more latitude.
3. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Do not open a pull request as the first step.
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
For real, reproducible bugs, start with a well-described [Issue](https://github.com/open-webui/open-webui/issues) that explains the problem, why it matters, and what outcome you are looking for.
For feature requests, enhancements, behavior changes, UI/UX changes, architecture changes, suspected fixes, or unconfirmed approaches, start with an active [Discussion](https://github.com/open-webui/open-webui/discussions). Merely opening a discussion is not enough; it needs to be actively discussed.
If you want to propose an implementation, include it only as a reference in the Issue or Discussion, such as a local diff, patch, or branch.
Opening an Issue or Discussion does not mean a PR is the right next step. Maintainers will confirm when a PR would be useful.
We ask for this because PRs, especially from first-time contributors, often need broader maintainer context on product direction, scope, architecture, UX, edge cases, compatibility, documentation, and long-term maintenance before implementation.
Unsolicited PRs may be closed without review. Contributors with a history of successful merged PRs may be given more latitude.
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked and filled out the following:**
**Before submitting, make sure you've checked the following:**
- [ ] **Linked Issue/Discussion:** This PR references an existing, well-described [Issue](https://github.com/open-webui/open-webui/issues) for a real bug or an active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) for a feature request or enhancement — `Closes #___` / `Relates to #___`.
- [ ] **First-time contributor policy:** This is not my first contribution to Open WebUI, this PR contains only i18n/localization updates, or a maintainer explicitly asked me to open this PR after reviewing the linked Issue or Discussion.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.
- [ ] **Documentation:** Relevant documentation has been added or updated in the [Open WebUI Docs Repository](https://github.com/open-webui/docs).
- [ ] **Dependencies:** Any new or updated dependencies are explained, tested, and documented.
- [ ] **Testing:** **Manual** end-to-end tests have been performed to verify the fix/feature works correctly and does not introduce regressions. Screenshots or recordings are included where applicable.
- [ ] **User-facing changes:** I have confirmed whether this PR changes the UI. If it does, screenshots are required, and a video recording is recommended.
- [ ] **No Unchecked AI Code:** This PR is either human-written or has undergone thorough human review AND manual testing. Unreviewed AI-generated PRs may be closed immediately.
- [ ] **Self-Review:** A self-review of the code has been performed, ensuring adherence to project coding standards.
- [ ] **Architecture:** Smart defaults are preferred over new settings. Local state is used for ephemeral UI logic. Major architectural or UX changes have been discussed first.
- [ ] **Git Hygiene:** The PR is atomic (one logical change), rebased on `dev`, and contains no unrelated commits.
- [ ] **Title Prefix:** The PR title uses one of the following prefixes:
- **BREAKING CHANGE**: Changes affecting backward compatibility
- **build**: Build system or dependency changes
- **ci**: CI/CD workflow changes
- **chore**: Refactoring, cleanup, or non-functional changes
- **docs**: Documentation additions or updates
- **feat**: New features or enhancements
- **fix**: Bug fixes or corrections
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows
- **chore**: Refactor, cleanup, or other non-functional code changes
- **docs**: Documentation update or addition
- **feat**: Introduces a new feature or enhancement to the codebase
- **fix**: Bug fix or error correction
- **i18n**: Internationalization or localization changes
- **perf**: Performance improvements
- **refactor**: Code restructuring
- **perf**: Performance improvement
- **refactor**: Code restructuring for better maintainability, readability, or scalability
- **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.)
- **test**: Adding missing tests or correcting existing tests
- **WIP**: Work in progress, a temporary label for incomplete or ongoing work
# Changelog Entry
### Description
- [Describe the changes, including motivation and impact]
- [Concisely describe the changes made in this pull request, including any relevant motivation and impact (e.g., fixing a bug, adding a feature, or improving performance)]
### Added
- [New features, functionalities, or additions]
- [List any new features, functionalities, or additions]
### Changed
- [Changes, updates, refactorings, or optimizations]
- [List any changes, updates, refactorings, or optimizations]
### Deprecated
- [Deprecated functionality or features]
- [List any deprecated functionality or features that have been removed]
### Removed
- [Removed features, files, or functionalities]
- [List any removed features, files, or functionalities]
### Fixed
- [Bug fixes or corrections]
- [List any fixes, corrections, or bug fixes]
### Security
- [Security-related changes or vulnerability fixes]
- [List any new or updated security-related changes, including vulnerability fixes]
### Breaking Changes
- **BREAKING CHANGE**: [Changes affecting compatibility or functionality]
- **BREAKING CHANGE**: [List any breaking changes affecting compatibility or functionality]
---
### Additional Information
- [Any additional context, notes, or references to related issues/commits]
- [Insert any additional context, notes, or explanations for the changes]
- [Reference any related issues, commits, or other relevant information]
### Screenshots or Videos
- [Attach screenshots or videos for user-facing changes. For UI changes, screenshots are required, and a video recording is recommended.]
- [Attach any relevant screenshots or videos demonstrating the changes]
### Contributor License Agreement

View file

@ -1,43 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Backend CI — Python formatting checks via Ruff
# Runs on pushes and PRs to main/dev when backend files change
# ─────────────────────────────────────────────────────────────────────────────
name: Python CI
on:
push:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
pull_request:
branches: [main, dev]
paths: ['backend/**', 'pyproject.toml', 'uv.lock', '.github/workflows/backend.yaml']
concurrency:
group: backend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Ruff format check across supported Python versions ───────────────────
format-check:
name: Ruff Format (${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install formatter
run: pip install "ruff>=0.15.5"
- name: Verify formatting
run: ruff format --check . --exclude .venv --exclude venv
- name: Detect logic errors
run: ruff check --select=F --ignore=F401,F403,F405,F541,F811,F841 --output-format=github .

61
.github/workflows/build-release.yml vendored Normal file
View file

@ -0,0 +1,61 @@
name: Release
on:
push:
branches:
- main # or whatever branch you want to use
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
git diff --cached --diff-filter=d package.json || {
echo "No changes to package.json"
exit 1
}
- name: Get version number from package.json
id: get_version
run: |
VERSION=$(jq -r '.version' package.json)
echo "::set-output name=version::$VERSION"
- name: Extract latest CHANGELOG entry
run: |
VERSION="${{ steps.get_version.outputs.version }}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.get_version.outputs.version }}" \
--title "v${{ steps.get_version.outputs.version }}" \
--notes-file /tmp/release-notes.md
- name: Upload package to GitHub release
uses: actions/upload-artifact@v4
with:
name: package
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build workflow
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker-build.yaml',
ref: 'v${{ steps.get_version.outputs.version }}',
})

917
.github/workflows/docker-build.yaml vendored Normal file
View file

@ -0,0 +1,917 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
env:
REGISTRY: ghcr.io
jobs:
build-main-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.platform }}-
latest=false
- name: Build Docker image (latest)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-main-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-cuda126-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (cuda126 tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-cuda126-${{ matrix.platform }}-
latest=false
- name: Build Docker image (cuda126)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_CUDA=true
USE_CUDA_VER=cu126
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-cuda126-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-ollama-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-ollama-${{ matrix.platform }}-
latest=false
- name: Build Docker image (ollama)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_OLLAMA=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-ollama-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-slim-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-slim-${{ matrix.platform }}-
latest=false
- name: Build Docker image (slim)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-slim-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda-images:
runs-on: ubuntu-latest
needs: [build-cuda-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-cuda126-images:
runs-on: ubuntu-latest
needs: [build-cuda126-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default latest tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=cuda126
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-cuda126,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-ollama-images:
runs-on: ubuntu-latest
needs: [build-ollama-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default ollama tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=ollama
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-ollama,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-slim-images:
runs-on: ubuntu-latest
needs: [build-slim-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-slim-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"

View file

@ -1,407 +0,0 @@
name: Create and publish Docker images with specific build args
on:
workflow_dispatch:
push:
branches:
- main
- dev
tags:
- v*
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build:
runs-on: ${{ matrix.platform.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
platform:
- arch: linux/amd64
runner: ubuntu-latest
- arch: linux/arm64
runner: ubuntu-24.04-arm
variant:
- name: main
suffix: ""
build_args: ""
free_disk: false
- name: cuda
suffix: "-cuda"
build_args: "USE_CUDA=true"
free_disk: true
- name: cuda126
suffix: "-cuda126"
build_args: |
USE_CUDA=true
USE_CUDA_VER=cu126
free_disk: true
- name: ollama
suffix: "-ollama"
build_args: "USE_OLLAMA=true"
free_disk: false
- name: slim
suffix: "-slim"
build_args: "USE_SLIM=true"
free_disk: false
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
platform=${{ matrix.platform.arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV}
- name: Free disk space
if: matrix.variant.free_disk
run: rm -rf /opt/hostedtoolcache
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare CI Dockerfile
run: |
awk '
/^FROM --platform=\$BUILDPLATFORM node:/ {
print
print "ENV NODE_OPTIONS=\"--max-old-space-size=12288\""
next
}
{ print }
' Dockerfile > "${RUNNER_TEMP}/Dockerfile"
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}-
latest=false
- name: Build Docker image
uses: docker/build-push-action@v5
id: build
with:
context: .
file: ${{ runner.temp }}/Dockerfile
push: true
platforms: ${{ matrix.platform.arch }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
sbom: true
build-args: |
BUILD_HASH=${{ github.sha }}
${{ matrix.variant.build_args }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.variant.name }}-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [build]
if: ${{ !cancelled() }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
variant:
- name: main
suffix: ""
- name: cuda
suffix: "-cuda"
- name: cuda126
suffix: "-cuda126"
- name: ollama
suffix: "-ollama"
- name: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Download digests
id: download
uses: actions/download-artifact@v5
with:
pattern: digests-${{ matrix.variant.name }}-*
path: /tmp/digests
merge-multiple: true
continue-on-error: true
- name: Check digests
id: check
run: |
count=$(find /tmp/digests -type f 2>/dev/null | wc -l | tr -d ' ')
echo "digest_count=$count" >> $GITHUB_OUTPUT
if [ "$count" -lt 2 ]; then
echo "::warning::${{ matrix.variant.name }}: found $count digest(s), need 2 (one per arch). Skipping merge."
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
if: steps.check.outputs.skip != 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
if: steps.check.outputs.skip != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images
if: steps.check.outputs.skip != 'true'
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }}
- name: Create manifest list and push
if: steps.check.outputs.skip != 'true'
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
if: steps.check.outputs.skip != 'true'
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
notify-helm-charts:
runs-on: ubuntu-latest
needs: [merge]
if: ${{ !cancelled() && needs.merge.result == 'success' && (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')) }}
steps:
- name: Create Helm charts app token
id: helm-app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HELM_CHARTS_APP_ID }}
private-key: ${{ secrets.HELM_CHARTS_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: helm-charts
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Verify published Open WebUI image
id: image
run: |
set -euo pipefail
image_name="ghcr.io/${GITHUB_REPOSITORY,,}"
ref_name="${GITHUB_REF_NAME}"
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
image_tag="dev"
else
image_tag="${ref_name#v}"
fi
docker buildx imagetools inspect "${image_name}:${image_tag}"
echo "tag=${image_tag}" >> "${GITHUB_OUTPUT}"
- name: Dispatch Helm chart automation
uses: actions/github-script@v8
with:
github-token: ${{ steps.helm-app-token.outputs.token }}
script: |
const isDev = context.ref === 'refs/heads/dev';
const eventType = isDev
? 'open-webui-dev-image-published'
: 'open-webui-release-published';
const refName = context.ref.replace('refs/heads/', '').replace('refs/tags/', '');
const appVersion = refName.startsWith('v') ? refName.slice(1) : refName;
const payload = {
image_tag: isDev ? 'dev' : appVersion,
source_ref: context.ref,
source_sha: context.sha,
source_run_id: String(context.runId),
source_repository: context.repo.repo,
};
if (!isDev) {
payload.app_version = appVersion;
}
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: 'helm-charts',
event_type: eventType,
client_payload: payload,
});
copy-to-dockerhub:
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
needs: [merge]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Prepare environment
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"

46
.github/workflows/format-backend.yaml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Python CI
on:
push:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format Backend'
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- 3.11.x
- 3.12.x
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '${{ matrix.python-version }}'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install "ruff>=0.15.5"
- name: Ruff format check
run: ruff format --check . --exclude .venv --exclude venv

View file

@ -0,0 +1,65 @@
name: Frontend Build
on:
push:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
branches:
- main
- dev
paths-ignore:
- 'backend/**'
- 'pyproject.toml'
- 'uv.lock'
jobs:
build:
name: 'Format & Build Frontend'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm install --force
- name: Format Frontend
run: npm run format
- name: Run i18next
run: npm run i18n:parse
- name: Check for Changes After Format
run: git diff --exit-code
- name: Build Frontend
run: npm run build
test-frontend:
name: 'Frontend Unit Tests'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
run: npm ci --force
- name: Run vitest
run: npm run test:frontend

View file

@ -1,65 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Frontend CI — Lint, format check, build, and unit tests
# Runs on pushes and PRs to main/dev, skipping backend-only changes
# ─────────────────────────────────────────────────────────────────────────────
name: Frontend Build
on:
push:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
pull_request:
branches: [main, dev]
paths-ignore: ['backend/**', 'pyproject.toml', 'uv.lock']
concurrency:
group: frontend-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Format, i18n, and production build ────────────────────────────────────
format-and-build:
name: Format & Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies
run: npm install --force
- name: Verify code formatting
run: npm run format
- name: Verify i18n strings
run: npm run i18n:parse
- name: Ensure working tree is clean
run: git diff --exit-code
- name: Production build
run: npm run build
env:
NODE_OPTIONS: --max-old-space-size=8192
# ── Vitest unit tests ────────────────────────────────────────────────────
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install dependencies (frozen lockfile)
run: npm ci --force
- name: Execute test suite
run: npm run test:frontend

View file

@ -0,0 +1,255 @@
name: Integration Test
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
cypress-run:
name: Run Cypress Integration Tests
runs-on: ubuntu-latest
steps:
- name: Maximize build space
uses: AdityaGarg8/remove-unwanted-software@v4.1
with:
remove-android: 'true'
remove-haskell: 'true'
remove-codeql: 'true'
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build and run Compose Stack
run: |
docker compose \
--file docker-compose.yaml \
--file docker-compose.api.yaml \
--file docker-compose.a1111-test.yaml \
up --detach --build
- name: Delete Docker build cache
run: |
docker builder prune --all --force
- name: Wait for Ollama to be up
timeout-minutes: 5
run: |
until curl --output /dev/null --silent --fail http://localhost:11434; do
printf '.'
sleep 1
done
echo "Service is up!"
- name: Preload Ollama model
run: |
docker exec ollama ollama pull qwen:0.5b-chat-v1.5-q2_K
- name: Cypress run
uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with:
browser: chrome
wait-on: 'http://localhost:3000'
config: baseUrl=http://localhost:3000
- uses: actions/upload-artifact@v4
if: always()
name: Upload Cypress videos
with:
name: cypress-videos
path: cypress/videos
if-no-files-found: ignore
- name: Extract Compose logs
if: always()
run: |
docker compose logs > compose-logs.txt
- uses: actions/upload-artifact@v4
if: always()
name: Upload Compose logs
with:
name: compose-logs
path: compose-logs.txt
if-no-files-found: ignore
# pytest:
# name: Run Backend Tests
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
# - name: Install dependencies
# run: |
# python -m pip install --upgrade pip
# pip install -r backend/requirements.txt
# - name: pytest run
# run: |
# ls -al
# cd backend
# PYTHONPATH=. pytest . -o log_cli=true -o log_cli_level=INFO
migration_test:
name: Run Migration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
# mysql:
# image: mysql
# env:
# MYSQL_ROOT_PASSWORD: mysql
# MYSQL_DATABASE: mysql
# options: >-
# --health-cmd "mysqladmin ping -h localhost"
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# ports:
# - 3306:3306
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: yezz123/setup-uv@v4
with:
uv-venv: venv
- name: Activate virtualenv
run: |
. venv/bin/activate
echo PATH=$PATH >> $GITHUB_ENV
- name: Install dependencies
run: |
uv pip install -r backend/requirements.txt
- name: Test backend with SQLite
id: sqlite
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
run: |
cd backend
uvicorn open_webui.main:app --port "8080" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 40 seconds for the server to start
for i in {1..40}; do
curl -s http://localhost:8080/api/config > /dev/null && break
sleep 1
if [ $i -eq 40 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
- name: Test backend with Postgres
if: success() || steps.sqlite.conclusion == 'failure'
env:
WEBUI_SECRET_KEY: secret-key
GLOBAL_LOG_LEVEL: debug
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DATABASE_POOL_SIZE: 10
DATABASE_POOL_MAX_OVERFLOW: 10
DATABASE_POOL_TIMEOUT: 30
run: |
cd backend
uvicorn open_webui.main:app --port "8081" --forwarded-allow-ips '*' &
UVICORN_PID=$!
# Wait up to 20 seconds for the server to start
for i in {1..20}; do
curl -s http://localhost:8081/api/config > /dev/null && break
sleep 1
if [ $i -eq 20 ]; then
echo "Server failed to start"
kill -9 $UVICORN_PID
exit 1
fi
done
# Check that the server is still running after 5 seconds
sleep 5
if ! kill -0 $UVICORN_PID; then
echo "Server has stopped"
exit 1
fi
# Check that service will reconnect to postgres when connection will be closed
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has failed before postgres reconnect check"
exit 1
fi
echo "Terminating all connections to postgres..."
python -c "import os, psycopg2 as pg2; \
conn = pg2.connect(dsn=os.environ['DATABASE_URL'].replace('+pool', '')); \
cur = conn.cursor(); \
cur.execute('SELECT pg_terminate_backend(psa.pid) FROM pg_stat_activity psa WHERE datname = current_database() AND pid <> pg_backend_pid();')"
status_code=$(curl --write-out %{http_code} -s --output /dev/null http://localhost:8081/health/db)
if [[ "$status_code" -ne 200 ]] ; then
echo "Server has not reconnected to postgres after connection was closed: returned status $status_code"
exit 1
fi
# - name: Test backend with MySQL
# if: success() || steps.sqlite.conclusion == 'failure' || steps.postgres.conclusion == 'failure'
# env:
# WEBUI_SECRET_KEY: secret-key
# GLOBAL_LOG_LEVEL: debug
# DATABASE_URL: mysql://root:mysql@localhost:3306/mysql
# run: |
# cd backend
# uvicorn open_webui.main:app --port "8083" --forwarded-allow-ips '*' &
# UVICORN_PID=$!
# # Wait up to 20 seconds for the server to start
# for i in {1..20}; do
# curl -s http://localhost:8083/api/config > /dev/null && break
# sleep 1
# if [ $i -eq 20 ]; then
# echo "Server failed to start"
# kill -9 $UVICORN_PID
# exit 1
# fi
# done
# # Check that the server is still running after 5 seconds
# sleep 5
# if ! kill -0 $UVICORN_PID; then
# echo "Server has stopped"
# exit 1
# fi

View file

@ -1,139 +0,0 @@
name: Issue Labeler
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
label-bug-reports:
runs-on: ubuntu-latest
steps:
- name: Add "bug" label to unlabeled bug reports
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
// Web-form submissions already carry the label from the issue template
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const isEdit = context.payload.action === 'edited';
const titleWasEdited = Boolean(context.payload.changes?.title);
if (isEdit && !titleWasEdited) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
const bugLikeTitle = /^\s*(\[\s*(bug|issue|fix)\b[^\]]*\]|(bug|issue|fix)\s*[:/\-])/i.test(title);
// API/CLI-created issues that reproduce the bug report form structure.
// Only headings distinctive to the bug form (both are required fields there) —
// generic headings like "Expected Behavior" also appear in freeform feature requests.
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
if (!bugLikeTitle && !bugFormBody) {
return;
}
if (isEdit) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const bugLabelWasRemoved = events.some(
(event) => event.event === 'unlabeled' && event.label?.name === 'bug'
);
if (bugLabelWasRemoved) {
return;
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['bug']
});
label-feature-requests:
runs-on: ubuntu-latest
steps:
- name: Add "enhancement" label to unlabeled feature requests
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (issue.labels.some((label) => label.name === 'enhancement')) {
return;
}
// A human (or the bug form) already classified this as a bug;
// do not stack a second, contradictory classification on it.
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const isEdit = context.payload.action === 'edited';
const titleWasEdited = Boolean(context.payload.changes?.title);
if (isEdit && !titleWasEdited) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Feature requests: "feat: ...", "feature: ...", "feature request: ...",
// "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature
// request form titles every submission "feat: ", so form submissions are
// covered by the same pattern.
const featureLikeTitle =
/^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test(
title
);
// API/CLI-created issues that reproduce the feature request form structure.
// Only headings distinctive to that form.
const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body);
if (!featureLikeTitle && !featureFormBody) {
return;
}
if (isEdit) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const enhancementLabelWasRemoved = events.some(
(event) => event.event === 'unlabeled' && event.label?.name === 'enhancement'
);
if (enhancementLabelWasRemoved) {
return;
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['enhancement']
});

View file

@ -1,74 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Release — Create GitHub release from CHANGELOG, trigger Docker builds
# Runs on pushes to main when package.json version changes
# ─────────────────────────────────────────────────────────────────────────────
name: Release
on:
push:
branches: [main]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Create release and trigger downstream workflows ──────────────────────
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v5
- name: Abort if package.json unchanged
run: |
git diff --cached --diff-filter=d package.json || {
echo "package.json not modified — skipping release"
exit 1
}
- name: Read version
id: pkg
run: echo "version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
- name: Extract release notes from CHANGELOG
run: |
VER="${{ steps.pkg.outputs.version }}"
awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
CHANGELOG.md > /tmp/release-notes.md
- name: Publish GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
echo "Release v${{ steps.pkg.outputs.version }} already exists — skipping creation"
else
gh release create "v${{ steps.pkg.outputs.version }}" \
--title "v${{ steps.pkg.outputs.version }}" \
--notes-file /tmp/release-notes.md
fi
- name: Archive source
uses: actions/upload-artifact@v4
with:
name: release-archive
path: |
.
!.git
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build
uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'docker.yaml',
ref: 'v${{ steps.pkg.outputs.version }}',
})

1
.gitignore vendored
View file

@ -310,4 +310,3 @@ dist
cypress/videos
cypress/screenshots
.vscode/settings.json
.cptr

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@ Examples of behavior that contribute to a positive and professional community in
- **Respecting others.** Be considerate, listen actively, and engage with empathy toward others' viewpoints and experiences.
- **Constructive feedback.** Provide actionable, thoughtful, and respectful feedback that helps improve the project and encourages collaboration. Avoid unproductive negativity or hypercriticism.
- **Recognizing volunteer contributions.** Appreciate that **contributors dedicate their free time and resources selflessly**. Approach them with gratitude and patience.
- **Recognizing volunteer contributions.** Appreciate that contributors dedicate their free time and resources selflessly. Approach them with gratitude and patience.
- **Focusing on shared goals.** Collaborate in ways that prioritize the health, success, and sustainability of the community over individual agendas.
Examples of unacceptable behavior include:
@ -32,7 +32,6 @@ Examples of unacceptable behavior include:
- **Entitlement, demand, or aggression toward contributors.** Volunteers are under no obligation to provide immediate or personalized support. Rude or dismissive behavior will not be tolerated.
- **Unproductive or destructive behavior.** This includes venting frustration as hostility ("tantrums"), hypercriticism, attention-seeking negativity, or anything that distracts from the project's goals.
- **Spamming and promotional exploitation.** Sharing irrelevant product promotions or self-promotion in the community is not allowed unless it directly contributes value to the discussion.
- Posting low-effort, hard to read, essay-length AI generated comments or other forms of low-quality, hard to parse content that puts the burden of understanding on the reader.
### Feedback and Community Engagement

View file

@ -126,7 +126,7 @@ RUN chown -R $UID:$GID /app $HOME
# Install common system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc curl jq ca-certificates \
git build-essential pandoc gcc netcat-openbsd curl jq \
libmariadb-dev \
python3-dev \
ffmpeg libsm6 libxext6 zstd \
@ -149,7 +149,7 @@ RUN set -e; \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
else \
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \
uv pip install --system -r requirements.txt --no-cache-dir; \
@ -158,18 +158,12 @@ RUN set -e; \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab', download_dir='/usr/local/share/nltk_data')"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
fi; \
fi; \
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \
rm -rf /var/lib/apt/lists/*;
# Optional: PPTX parsing through unstructured may need spaCy's English model.
# Keep this out of the default image to avoid the extra image bloat; deployments
# with read-only site-packages can uncomment it and bake the model in.
# RUN python -m spacy download en_core_web_sm
# Install Ollama if requested
RUN if [ "$USE_OLLAMA" = "true" ]; then \
date +%s > /tmp/ollama_build_hash && \
@ -190,17 +184,6 @@ COPY --chown=$UID:$GID --from=build /app/package.json /app/package.json
# copy backend files
COPY --chown=$UID:$GID ./backend .
# The backend rewrites its bundled static assets (favicons, splash, manifest,
# loader.js, ...) under open_webui/static at startup. Make that directory
# writable by an arbitrary UID -- which under OpenShift's restricted SCC is
# always a member of GID 0 -- so those writes don't fail with EACCES and crash
# the boot log with "[Errno 13] Permission denied". `chmod -R g=u` mirrors the
# owner bits onto the group (the Red Hat arbitrary-UID idiom). This is applied
# unconditionally because it targets a directory the app writes on every start;
# the broader, opt-in USE_PERMISSION_HARDENING below covers the rest of /app.
RUN chgrp -R 0 /app/backend/open_webui/static && \
chmod -R g=u /app/backend/open_webui/static
EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1

View file

@ -8,7 +8,7 @@
![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
![Open WebUI Banner](./banner.png)
@ -27,82 +27,58 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly via pip, uv, Docker, or Kubernetes (kubectl, kustomize, or helm), with `:ollama` and `:cuda` tagged images available for container deployments.
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
- 🤝 **Broad Model & API Integration**: Connect any OpenAI-compatible API alongside local Ollama models. Point the API URL at **LMStudio, GroqCloud, Mistral, OpenRouter, vLLM, and more** to mix and match providers freely.
- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**.
- 🔐 **Granular RBAC & User Groups**: Administrators define detailed roles, groups, and permissions, giving each user exactly the access they need. Secure by default, with tailored experiences per group.
- 🛡️ **Granular Permissions and User Groups**: By allowing administrators to create detailed user roles and permissions, we ensure a secure user environment. This granularity not only enhances security but also allows for customized user experiences, fostering a sense of ownership and responsibility amongst users.
- 🧩 **Plugin Support**: Extend Open WebUI with **Filters**, **Actions**, **Pipes**, **Tools**, and **Skills**. Connect external services through **MCP**, **MCPO**, and **OpenAPI tool servers**. Build custom integrations, rate limits, approval flows, data connections, and more.
- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
- 🤖 **Models & Agents**: Wrap any base model with custom instructions, tools, and knowledge to build specialized agents. Supports dynamic variables, per-user/group access control, and community preset imports via [Open WebUI Community](https://openwebui.com/).
- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
- 📝 **Notes**: A dedicated workspace for content outside conversations. Draft with a rich editor, use AI to rewrite selected text, and attach notes to any chat for full-context injection.
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 📢 **Channels**: Real-time shared spaces where your team and AI models collaborate in one timeline. Tag models to draft or critique, with threads, reactions, pins, and access control.
- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 🧠 **Persistent Memory**: The AI remembers facts about you across conversations, carrying context from one chat to the next.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- **Live Workflow & Message Flow**: Watch the AI build and work through checklists in real time. Queue messages while the AI is still responding; they send automatically when it's ready.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
- 📅 **Calendar & AI Scheduling**: Built-in personal and shared calendars with month/week/day views, recurring events, color coding, attendees, and reminders. Models manage your schedule conversationally through native function calling.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
- ⏱️ **Automations**: Schedule prompts to run on recurring schedules, with runs surfaced on your calendar and each completed run linking back to the chat it produced.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- 📱 **Responsive Design & PWA**: Seamless experience across desktop, laptop, and mobile, with a Progressive Web App for native app-like feel and offline access on localhost.
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
- ✒️🔢 **Full Markdown and LaTeX Support**: Comprehensive Markdown and LaTeX capabilities for enriched interaction.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
- 🎤📹 **Hands-Free Voice/Video Call**: Integrated voice and video calls with multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI).
- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling journals, trackers, leaderboards, and collaborative tools with personal and shared data scopes.
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 📚 **Local RAG Integration**: Retrieval Augmented Generation backed by 9 vector databases and multiple content-extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, external loaders). Supports hybrid search (BM25 + vector) with reranking and full-context mode. Load documents into chat or pull them from your library with the `#` command.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
- 🔍 **Web Search for RAG**: Search the web through dozens of providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `Firecrawl`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into the conversation.
- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
- 🌐 **Web Browsing Capability**: Pull websites into chat with the `#` command followed by a URL, or let the model fetch them on its own when needed.
- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
- 🎨 **Image Generation & Editing**: Create and edit images with multiple engines including OpenAI DALL·E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), supporting both generation and prompt-based editing.
- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
- ⚙️ **Multi-Model Conversations**: Engage several models at once, harnessing their individual strengths in parallel for the best possible responses.
- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
- 📊 **Usage Analytics & Model Evaluation**: Admin dashboards track message volume, token consumption, and cost across users and models. Evaluate models with a built-in arena, A/B testing, and ELO-based leaderboards.
- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
- 🗄️ **Flexible Database & Storage**: Choose SQLite (with optional encryption) or PostgreSQL, and store files locally or on S3, Google Cloud Storage, or Azure Blob Storage.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
- 🧬 **Advanced Vector Database Support**: Pick from 9 vector databases: ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai.
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🪪 **Enterprise Authentication & Provisioning**: Full LDAP/Active Directory integration, SSO via trusted headers and OAuth providers, and SCIM 2.0 automated provisioning for identity providers like Okta, Azure AD, and Google Workspace.
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
- ☁️ **Cloud-Native File Integration**: Native Google Drive and OneDrive/SharePoint file picking for seamless document import from enterprise cloud storage.
- 🔭 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, plugging into your existing monitoring stack.
- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker, multi-node deployments behind load balancers.
- 🌐🌍 **Multilingual Support**: Use Open WebUI in your preferred language with i18n support. We're actively seeking contributors to expand language coverage!
- 🌟 **Continuous Updates**: We're committed to improving Open WebUI with regular updates, fixes, and new features.
- 🛡️ **Transparent Security Process**: Security reports are triaged, fixed, and published as open advisories through a documented responsible-disclosure process. See our [Security Policy](https://github.com/open-webui/open-webui/security).
- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## The Open WebUI Ecosystem 🌐
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
- 🖥️ **Native Desktop App** ([open-webui/desktop](https://github.com/open-webui/desktop)): Run Open WebUI as a native app on macOS, Windows, and Linux. System-wide Spotlight chat bar with screenshot capture, push-to-talk voice, and optional fully-local inference via a built-in llama.cpp engine.
Want to learn more? Check out our [Open WebUI documentation](https://docs.openwebui.com) for more details!
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
@ -246,10 +222,6 @@ This project contains code under multiple licenses. The current codebase include
If you have any questions, suggestions, or need assistance, please open an issue or join our
[Open WebUI Discord community](https://discord.gg/5rJgQTnV4s) to connect with us! 🤝
## Security 🛡️
If you believe you've found a security vulnerability, or something that shouldn't be disclosed publicly, please [reach out confidentially through our responsible disclosure program on GitHub](https://github.com/open-webui/open-webui/security). We accept reports only through GitHub, not through any other platform. Thank you for helping us keep Open WebUI secure!
## Star History
<a href="https://star-history.com/#open-webui/open-webui&Date">

View file

@ -1,3 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --ws-per-message-deflate "${UVICORN_WS_PER_MESSAGE_DEFLATE:-true}" --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload

View file

@ -11,16 +11,12 @@ import uvicorn
app = typer.Typer()
KEY_FILE = Path.cwd() / '.webui_secret_key'
DEFAULT_SECRET_KEY_LENGTH = 24
def version_callback(value: bool) -> None:
if value:
from open_webui.env import VERSION
# LICENSE covers this Open WebUI CLI identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
typer.echo(f'Open WebUI version: {VERSION}')
raise typer.Exit()
@ -41,11 +37,8 @@ def serve(
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
key_length = int(os.getenv('WEBUI_SECRET_KEY_LENGTH', DEFAULT_SECRET_KEY_LENGTH))
if key_length < 1:
raise ValueError('WEBUI_SECRET_KEY_LENGTH must be a positive integer')
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(key_length)))
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
@ -74,7 +67,7 @@ def serve(
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE
from open_webui.env import UVICORN_WORKERS # Import the workers setting
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
@ -87,7 +80,6 @@ def serve(
port=port,
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
loop=loop,
)
@ -98,15 +90,12 @@ def dev(
port: int = 8080,
reload: bool = True,
):
from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE
uvicorn.run(
'open_webui.main:app',
host=host,
port=port,
reload=reload,
forwarded_allow_ips='*',
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
)

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,6 @@
from __future__ import annotations
import errno
from enum import Enum
_ERRNO_MESSAGES = {
errno.ENAMETOOLONG: 'File name is too long.',
errno.ENOSPC: 'The server is out of storage space.',
errno.EDQUOT: 'Server storage quota exceeded.',
errno.EACCES: 'Server storage is not writable.',
errno.EPERM: 'Server storage is not writable.',
errno.EROFS: 'Server storage is not writable.',
}
def _error_message(err='', fallback='') -> str:
if not err:
return 'Something went wrong :/'
if isinstance(err, OSError) and err.errno in _ERRNO_MESSAGES:
return f'[ERROR: {_ERRNO_MESSAGES[err.errno]}]'
if isinstance(err, Exception):
return f'[ERROR: {fallback}]' if fallback else 'Something went wrong :/'
return f'[ERROR: {err}]'
class MESSAGES(str, Enum):
DEFAULT = lambda msg='': f'{msg if msg else ""}'
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
@ -39,7 +16,7 @@ class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = _error_message
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
@ -95,11 +72,11 @@ class ERROR_MESSAGES(str, Enum):
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
DB_NOT_SQLITE = 'This feature is only available with SQLite databases.'
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
@ -118,15 +95,9 @@ class ERROR_MESSAGES(str, Enum):
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
AUTOMATION_COUNT_REQUIRES_DTSTART = (
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
)
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
# LICENSE covers this Open WebUI error identifier.
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,11 @@
import asyncio
import inspect
import logging
import sys
from typing import AsyncGenerator, Generator, Iterator
import inspect
import json
import asyncio
from pydantic import BaseModel
from typing import AsyncGenerator, Generator, Iterator
from fastapi import (
Depends,
FastAPI,
@ -14,36 +16,40 @@ from fastapi import (
UploadFile,
status,
)
from pydantic import BaseModel
from starlette.responses import Response, StreamingResponse
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.users import UserModel
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
)
from open_webui.models.users import UserModel
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import check_model_access
from open_webui.utils.json_codec import JSONCodec
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
get_last_user_message,
prepend_to_first_user_message_content,
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
prepend_to_first_user_message_content,
)
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
apply_system_prompt_to_body,
)
from open_webui.utils.plugin import (
get_function_module_from_cache,
load_function_module_by_id,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -69,9 +75,6 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
async def get_function_models(request):
if not ENABLE_PLUGINS:
return []
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
pipe_models = []
@ -100,7 +103,7 @@ async def get_function_models(request):
log.exception(e)
sub_pipes = []
log.debug("get_function_models: function '%s' is a manifold of %s", pipe.id, sub_pipes)
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
for p in sub_pipes:
sub_pipe_id = f'{pipe.id}.{p["id"]}'
@ -126,10 +129,7 @@ async def get_function_models(request):
pipe_flag = {'type': 'pipe'}
log.debug(
"get_function_models: function '%s' is a single pipe { 'id': %s, 'name': %s }",
pipe.id,
pipe.id,
pipe.name,
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
)
pipe_models.append(
@ -150,10 +150,7 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
if models is None:
models = {}
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)
@ -173,7 +170,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
line = line.model_dump_json()
line = f'data: {line}'
if isinstance(line, dict):
line = f'data: {JSONCodec.dumps(line)}'
line = f'data: {json.dumps(line)}'
try:
line = line.decode('utf-8')
@ -184,7 +181,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return f'{line}\n\n'
else:
line = openai_chat_chunk_message_template(form_data['model'], line)
return f'data: {JSONCodec.dumps(line)}\n\n'
return f'data: {json.dumps(line)}\n\n'
def get_pipe_id(form_data: dict) -> str:
pipe_id = form_data['model']
@ -212,13 +209,6 @@ async def generate_function_chat_completion(request, form_data, user, models: di
return params
# Set server-side by utils/chat.py, never by client input. Mirrors the routers.
bypass_system_prompt = getattr(request.state, 'bypass_system_prompt', False)
# Copy so the base-model substitution below doesn't leak into the caller's
# payload, which the tool-call continuation re-submits. Mirrors the routers.
form_data = {**form_data}
model_id = form_data.get('model')
model_info = await Models.get_model_by_id(model_id)
@ -294,8 +284,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if params:
system = params.pop('system', None)
form_data = apply_model_params_to_body_openai(params, form_data)
if not bypass_system_prompt:
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = await get_function_module_by_id(request, pipe_id)
@ -315,17 +304,17 @@ async def generate_function_chat_completion(request, form_data, user, models: di
yield data
return
if isinstance(res, dict):
yield f'data: {JSONCodec.dumps(res)}\n\n'
yield f'data: {json.dumps(res)}\n\n'
return
except Exception as e:
log.error(f'Error: {e}')
yield f'data: {JSONCodec.dumps({"error": {"detail": str(e)}})}\n\n'
yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n'
return
if isinstance(res, str):
message = openai_chat_chunk_message_template(form_data['model'], res)
yield f'data: {JSONCodec.dumps(message)}\n\n'
yield f'data: {json.dumps(message)}\n\n'
if isinstance(res, Iterator):
for line in res:
@ -335,10 +324,11 @@ async def generate_function_chat_completion(request, form_data, user, models: di
async for line in res:
yield process_line(form_data, line)
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {JSONCodec.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
if isinstance(res, str) or isinstance(res, Generator):
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type='text/event-stream')
else:

View file

@ -1,39 +1,36 @@
from __future__ import annotations
import logging
import os
import sys
import json
import logging
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.internal.wrappers import register_connection
from open_webui.env import (
DATABASE_ENABLE_IAM_TOKEN_AUTH,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_ENABLE_SQLITE_WAL,
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_SCHEMA,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_URL,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
ENABLE_DB_MIGRATIONS,
OPEN_WEBUI_DIR,
)
from open_webui.utils.json_codec import JSONCodec
from sqlalchemy import Dialect, MetaData, create_engine, event, types
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.sql.type_api import _T
from typing_extensions import Self
@ -120,25 +117,61 @@ extract_ssl_mode_from_url = extract_ssl_params_from_url
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
class JSONField(types.TypeDecorator): # TEXT-backed JSON storage
"""Store arbitrary Python objects as JSON-encoded TEXT.
Used instead of native JSON columns for portability across SQLite and
PostgreSQL. Values are serialized with ``JSONCodec.dumps`` on write and
deserialized with ``JSONCodec.loads`` on read.
"""
impl = types.UnicodeText
class JSONField(types.TypeDecorator):
impl = types.Text
cache_ok = True
def process_bind_param(self, value: _T | None, dialect: Dialect) -> Any:
return JSONCodec.dumps(value) if value is not None else None
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
return json.dumps(value)
def process_result_value(self, value: _T | None, dialect: Dialect) -> Any:
return JSONCodec.loads(value) if value is not None else None
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
if value is not None:
return json.loads(value)
def copy(self, **kwargs: Any) -> Self:
return JSONField(length=self.impl.length)
def copy(self, **kw: Any) -> Self:
return JSONField(self.impl.length)
def db_value(self, value):
return json.dumps(value)
def python_value(self, value):
if value is not None:
return json.loads(value)
# Workaround to handle the peewee migration
# This is required to ensure the peewee migration is handled before the alembic migration
def handle_peewee_migration(DATABASE_URL):
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
# and cert-file params are preserved in the connection string.
url_without_ssl, ssl_params = extract_ssl_params_from_url(DATABASE_URL)
normalized_url = reattach_ssl_params_to_url(url_without_ssl, ssl_params)
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
db.close()
except Exception as e:
log.error(f'Failed to initialize the database connection: {e}')
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
raise
finally:
# Properly closing the database connection
if db and not db.is_closed():
db.close()
# Assert if db connection has been closed
if db is not None:
assert db.is_closed(), 'Database connection is still open.'
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
# Normalize SSL params from the URL once; the sync engine needs them
@ -149,77 +182,6 @@ _url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
class RDSIAMTokenAuth:
_refresh_after = timedelta(minutes=14)
def __init__(self, database_url: str) -> None:
url = make_url(database_url)
if not url.drivername.startswith(('postgresql', 'postgres')):
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH is only supported for PostgreSQL databases')
if not url.host or not url.username:
raise ValueError('DATABASE_ENABLE_IAM_TOKEN_AUTH requires a database host and user')
self.host = url.host
self.port = url.port or 5432
self.username = url.username
self._client = None
self._token: str | None = None
self._expires_at = datetime.min.replace(tzinfo=timezone.utc)
@property
def client(self):
if self._client is None:
import boto3
self._client = boto3.client('rds')
return self._client
def get_password(self) -> str:
now = datetime.now(timezone.utc)
if self._token and now < self._expires_at:
return self._token
self._token = self.client.generate_db_auth_token(
DBHostname=self.host,
Port=self.port,
DBUsername=self.username,
)
self._expires_at = now + self._refresh_after
log.info('AWS RDS IAM database token refreshed; next refresh after %s', self._expires_at.isoformat())
return self._token
_rds_iam_token_auth = RDSIAMTokenAuth(SQLALCHEMY_DATABASE_URL) if DATABASE_ENABLE_IAM_TOKEN_AUTH else None
def _set_iam_token_password(dialect, conn_rec, cargs, cparams):
if _rds_iam_token_auth is not None:
cparams['password'] = _rds_iam_token_auth.get_password()
def enable_iam_token_auth(connectable) -> None:
if _rds_iam_token_auth is None:
return
engine = getattr(connectable, 'sync_engine', connectable)
url = engine.url
auth = _rds_iam_token_auth
# The token is bound to one host/port/user pair; leave other databases on their own credentials.
if (url.host, url.port or 5432, url.username) != (auth.host, auth.port, auth.username):
log.warning(
'AWS RDS IAM token auth not applied to %s: the token is issued for %s@%s:%s, '
'so this connection uses the password from its own URL',
url.render_as_string(hide_password=True),
auth.username,
auth.host,
auth.port,
)
return
if not event.contains(engine, 'do_connect', _set_iam_token_password):
event.listen(engine, 'do_connect', _set_iam_token_password)
def _make_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent.
@ -246,27 +208,6 @@ def _make_async_url(url: str) -> str:
return url
def _json_codec_kwargs(kwargs: dict) -> dict:
"""Default an engine to JSONCodec for native ``JSON`` columns.
Unlike ``JSONField``, those serialize through the engine, which otherwise uses
stdlib ``json``. With ``ENABLE_ORJSON`` off JSONCodec is stdlib ``json`` anyway.
"""
kwargs.setdefault('json_serializer', JSONCodec.dumps)
kwargs.setdefault('json_deserializer', JSONCodec.loads)
return kwargs
def _create_engine(*args, **kwargs):
"""``create_engine`` with the app JSON codec wired in."""
return create_engine(*args, **_json_codec_kwargs(kwargs))
def _create_async_engine(*args, **kwargs):
"""``create_async_engine`` with the app JSON codec wired in."""
return create_async_engine(*args, **_json_codec_kwargs(kwargs))
# ============================================================
# SYNC ENGINE (used only for: startup migrations, config loading,
# Alembic, peewee migration, health checks)
@ -295,7 +236,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
# in the native sqlcipher3 C library. Use NullPool by default for safety,
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
engine = _create_engine(
engine = create_engine(
'sqlite://',
creator=create_sqlcipher_connection,
pool_size=DATABASE_POOL_SIZE,
@ -307,7 +248,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
echo=False,
)
else:
engine = _create_engine(
engine = create_engine(
'sqlite://',
creator=create_sqlcipher_connection,
poolclass=NullPool,
@ -317,7 +258,7 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
log.info('Connected to encrypted SQLite database using SQLCipher')
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
engine = _create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
@ -349,7 +290,7 @@ elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
engine = _create_engine(
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
@ -359,11 +300,9 @@ else:
poolclass=QueuePool,
)
else:
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
else:
engine = _create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
enable_iam_token_auth(engine)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
# Sync session — used ONLY for startup config loading (config.py runs at import time)
@ -405,15 +344,14 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
async_engine = _create_async_engine(
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
connect_args={'check_same_thread': False},
pool_size=_sqlite_pool_size,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
@event.listens_for(async_engine.sync_engine, 'connect')
@ -422,7 +360,7 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = _create_async_engine(
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
@ -431,19 +369,17 @@ else:
pool_pre_ping=True,
)
else:
async_engine = _create_async_engine(
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
)
else:
async_engine = _create_async_engine(
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
)
enable_iam_token_auth(async_engine)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
@ -474,7 +410,7 @@ async def get_async_db():
@asynccontextmanager
async def get_async_db_context(db: AsyncSession | None = None):
async def get_async_db_context(db: Optional[AsyncSession] = None):
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
yield db

View file

@ -0,0 +1,253 @@
"""Peewee migrations -- 001_initial_schema.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# We perform different migrations for SQLite and other databases
# This is because SQLite is very loose with enforcing its schema, and trying to migrate other databases like SQLite
# will require per-database SQL queries.
# Instead, we assume that because external DB support was added at a later date, it is safe to assume a newer base
# schema instead of trying to migrate from an older schema.
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.CharField(max_length=255)
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.CharField()
filename = pw.CharField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.CharField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Auth(pw.Model):
id = pw.CharField(max_length=255, unique=True)
email = pw.CharField(max_length=255)
password = pw.TextField()
active = pw.BooleanField()
class Meta:
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
chat = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
tag_name = pw.CharField(max_length=255)
chat_id = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
id = pw.AutoField()
collection_name = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255, unique=True)
title = pw.TextField()
filename = pw.TextField()
content = pw.TextField(null=True)
user_id = pw.CharField(max_length=255)
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
id = pw.AutoField()
tag_name = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
modelfile = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
id = pw.AutoField()
command = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
title = pw.TextField()
content = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
user_id = pw.CharField(max_length=255)
data = pw.TextField(null=True)
class Meta:
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
id = pw.CharField(max_length=255, unique=True)
name = pw.CharField(max_length=255)
email = pw.CharField(max_length=255)
role = pw.CharField(max_length=255)
profile_image_url = pw.TextField()
timestamp = pw.BigIntegerField()
class Meta:
table_name = 'user'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('user')
migrator.remove_model('tag')
migrator.remove_model('prompt')
migrator.remove_model('modelfile')
migrator.remove_model('document')
migrator.remove_model('chatidtag')
migrator.remove_model('chat')
migrator.remove_model('auth')

View file

@ -0,0 +1,45 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'share_id')

View file

@ -0,0 +1,45 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'api_key')

View file

@ -0,0 +1,45 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('chat', archived=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('chat', 'archived')

View file

@ -0,0 +1,125 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
if isinstance(database, pw.SqliteDatabase):
migrate_sqlite(migrator, database, fake=fake)
else:
migrate_external(migrator, database, fake=fake)
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.DateTimeField(null=True), # Allow null for transition
updated_at=pw.DateTimeField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.DateTimeField(null=False),
updated_at=pw.DateTimeField(null=False),
)
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
'chat',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'chat',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
rollback_sqlite(migrator, database, fake=fake)
else:
rollback_external(migrator, database, fake=fake)
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False))
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False))

View file

@ -0,0 +1,129 @@
"""Peewee migrations -- 006_migrate_timestamps_and_charfields.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'document',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
'user',
timestamp=pw.BigIntegerField(),
)
# Alter the tables with varchar to text where necessary
migrator.change_fields(
'auth',
password=pw.TextField(),
)
migrator.change_fields(
'chat',
title=pw.TextField(),
)
migrator.change_fields(
'document',
title=pw.TextField(),
filename=pw.TextField(),
)
migrator.change_fields(
'prompt',
title=pw.TextField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.TextField(),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
if isinstance(database, pw.SqliteDatabase):
# Alter the tables with timestamps
migrator.change_fields(
'chatidtag',
timestamp=pw.DateField(),
)
migrator.change_fields(
'document',
timestamp=pw.DateField(),
)
migrator.change_fields(
'modelfile',
timestamp=pw.DateField(),
)
migrator.change_fields(
'prompt',
timestamp=pw.DateField(),
)
migrator.change_fields(
'user',
timestamp=pw.DateField(),
)
migrator.change_fields(
'auth',
password=pw.CharField(max_length=255),
)
migrator.change_fields(
'chat',
title=pw.CharField(),
)
migrator.change_fields(
'document',
title=pw.CharField(),
filename=pw.CharField(),
)
migrator.change_fields(
'prompt',
title=pw.CharField(),
)
migrator.change_fields(
'user',
profile_image_url=pw.CharField(),
)

View file

@ -0,0 +1,78 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields created_at and updated_at to the 'user' table
migrator.add_fields(
'user',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
'UPDATE "user" SET created_at = timestamp, updated_at = timestamp, last_active_at = timestamp WHERE timestamp IS NOT NULL'
)
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields('user', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
'user',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
last_active_at=pw.BigIntegerField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE "user" SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False))

View file

@ -0,0 +1,52 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
@migrator.create_model
class Memory(pw.Model):
id = pw.CharField(max_length=255, unique=True)
user_id = pw.CharField(max_length=255)
content = pw.TextField(null=False)
updated_at = pw.BigIntegerField(null=False)
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'memory'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('memory')

View file

@ -0,0 +1,60 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Model(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
base_model_id = pw.TextField(null=True)
name = pw.TextField()
meta = pw.TextField()
params = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'model'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('model')

View file

@ -0,0 +1,130 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
import json
from open_webui.utils.misc import parse_ollama_modelfile
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Fetch data from 'modelfile' table and insert into 'model' table
migrate_modelfile_to_model(migrator, database)
# Drop the 'modelfile' table
migrator.remove_model('modelfile')
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
ModelFile = migrator.orm['modelfile']
Model = migrator.orm['model']
modelfiles = ModelFile.select()
for modelfile in modelfiles:
# Extract and transform data in Python
modelfile.modelfile = json.loads(modelfile.modelfile)
meta = json.dumps(
{
'description': modelfile.modelfile.get('desc'),
'profile_image_url': modelfile.modelfile.get('imageUrl'),
'ollama': {'modelfile': modelfile.modelfile.get('content')},
'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'),
'categories': modelfile.modelfile.get('categories'),
'user': {**modelfile.modelfile.get('user', {}), 'community': True},
}
)
info = parse_ollama_modelfile(modelfile.modelfile.get('content'))
# Insert the processed data into the 'model' table
Model.create(
id=f'ollama-{modelfile.tag_name}',
user_id=modelfile.user_id,
base_model_id=info.get('base_model_id'),
name=modelfile.modelfile.get('title'),
meta=meta,
params=json.dumps(info.get('params', {})),
created_at=modelfile.timestamp,
updated_at=modelfile.timestamp,
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
recreate_modelfile_table(migrator, database)
move_data_back_to_modelfile(migrator, database)
migrator.remove_model('model')
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
query = """
CREATE TABLE IF NOT EXISTS modelfile (
user_id TEXT,
tag_name TEXT,
modelfile JSON,
timestamp BIGINT
)
"""
migrator.sql(query)
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
Model = migrator.orm['model']
Modelfile = migrator.orm['modelfile']
models = Model.select()
for model in models:
# Extract and transform data in Python
meta = json.loads(model.meta)
modelfile_data = {
'title': model.name,
'desc': meta.get('description'),
'imageUrl': meta.get('profile_image_url'),
'content': meta.get('ollama', {}).get('modelfile'),
'suggestionPrompts': meta.get('suggestion_prompts'),
'categories': meta.get('categories'),
'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'},
}
# Insert the processed data back into the 'modelfile' table
Modelfile.create(
user_id=model.user_id,
tag_name=model.id,
modelfile=modelfile_data,
timestamp=model.created_at,
)

View file

@ -0,0 +1,47 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields settings to the 'user' table
migrator.add_fields('user', settings=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'settings')

View file

@ -0,0 +1,60 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Tool(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
content = pw.TextField()
specs = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'tool'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('tool')

View file

@ -0,0 +1,47 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields info to the 'user' table
migrator.add_fields('user', info=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields('user', 'info')

View file

@ -0,0 +1,54 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class File(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
filename = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'file'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('file')

View file

@ -0,0 +1,60 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
@migrator.create_model
class Function(pw.Model):
id = pw.TextField(unique=True)
user_id = pw.TextField()
name = pw.TextField()
type = pw.TextField()
content = pw.TextField()
meta = pw.TextField()
created_at = pw.BigIntegerField(null=False)
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = 'function'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model('function')

View file

@ -0,0 +1,49 @@
"""Peewee migrations -- 009_add_models.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields('tool', valves=pw.TextField(null=True))
migrator.add_fields('function', valves=pw.TextField(null=True))
migrator.add_fields('function', is_active=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('tool', 'valves')
migrator.remove_fields('function', 'valves')
migrator.remove_fields('function', 'is_active')

View file

@ -0,0 +1,44 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'user',
oauth_sub=pw.TextField(null=True, unique=True),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('user', 'oauth_sub')

View file

@ -0,0 +1,48 @@
"""Peewee migrations -- 017_add_user_oauth_sub.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
'function',
is_global=pw.BooleanField(default=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields('function', 'is_global')

View file

@ -0,0 +1,84 @@
import logging
import os
from contextvars import ContextVar
from peewee import *
from peewee import InterfaceError as PeeWeeInterfaceError
from peewee import PostgresqlDatabase
from playhouse.db_url import connect, parse
from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__)
db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None}
db_state = ContextVar('db_state', default=db_state_default.copy())
class PeeweeConnectionState(object):
def __init__(self, **kwargs):
super().__setattr__('_state', db_state)
super().__init__(**kwargs)
def __setattr__(self, name, value):
self._state.get()[name] = value
def __getattr__(self, name):
value = self._state.get()[name]
return value
class CustomReconnectMixin(ReconnectMixin):
reconnect_errors = (
# psycopg2
(OperationalError, 'termin'),
(InterfaceError, 'closed'),
# peewee
(PeeWeeInterfaceError, 'closed'),
)
class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
pass
def register_connection(db_url):
# Check if using SQLCipher protocol
if db_url.startswith('sqlite+sqlcipher://'):
database_password = os.environ.get('DATABASE_PASSWORD')
if not database_password or database_password.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace('sqlite+sqlcipher://', '')
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to encrypted SQLite database using SQLCipher')
else:
# Standard database connection (existing logic)
db = connect(db_url, unquote_user=True, unquote_password=True)
if isinstance(db, PostgresqlDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to PostgreSQL database')
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
# Use our custom database class that supports reconnection
db = ReconnectingPostgresqlDatabase(**connection)
db.connect(reuse_if_open=True)
elif isinstance(db, SqliteDatabase):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info('Connected to SQLite database')
else:
raise ValueError('Unsupported database connection')
return db

File diff suppressed because it is too large Load diff

View file

@ -1,87 +1,120 @@
from __future__ import annotations
# Alembic environment configuration runner.
# Coordinates database migrations in both offline and online execution modes.
import logging.config
import logging
import alembic.context
from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.models.chat_messages import ChatMessage # noqa: F401
from open_webui.models.chats import Chat # noqa: F401
from sqlalchemy import create_engine, engine_from_config, pool
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
from sqlalchemy import engine_from_config, pool, create_engine
alembic_config = alembic.context.config
if alembic_config.config_file_name:
logging.config.fileConfig(alembic_config.config_file_name, disable_existing_loggers=False)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
if LOG_FORMAT == 'json':
from open_webui.env import JSONFormatter
for log_handler in logging.root.handlers:
log_handler.setFormatter(JSONFormatter())
migration_metadata = Auth.metadata
target_db_url = DATABASE_URL
base_url, ssl_query_params = extract_ssl_params_from_url(target_db_url)
if ssl_query_params:
target_db_url = reattach_ssl_params_to_url(base_url, ssl_query_params)
if target_db_url:
alembic_config.set_main_option('sqlalchemy.url', target_db_url.replace('%', '%%'))
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Auth.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2 for sync migrations).
url_without_ssl, ssl_params = extract_ssl_params_from_url(DB_URL)
DB_URL = reattach_ssl_params_to_url(url_without_ssl, ssl_params) if ssl_params else DB_URL
if DB_URL:
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
def run_migrations_offline() -> None:
"""Execute Alembic migrations in offline mode (outputs raw SQL DDL)."""
db_connection_url = alembic_config.get_main_option('sqlalchemy.url')
alembic.context.configure(
url=db_connection_url,
target_metadata=migration_metadata,
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
def _get_engine_connectable():
"""Build the database engine based on target URL and authentication credentials."""
if target_db_url and target_db_url.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or not DATABASE_PASSWORD.strip():
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
raw_db_path = target_db_url.replace('sqlite+sqlcipher://', '')
if raw_db_path.startswith('/'):
raw_db_path = raw_db_path[1:]
def _sqlite_cipher_creator():
import sqlcipher3
cipher_conn = sqlcipher3.connect(raw_db_path, check_same_thread=False)
cipher_conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return cipher_conn
return create_engine('sqlite://', creator=_sqlite_cipher_creator, echo=False)
return engine_from_config(
alembic_config.get_section(alembic_config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Execute migrations against a live database connection."""
live_connectable = _get_engine_connectable()
enable_iam_token_auth(live_connectable)
with live_connectable.connect() as live_connection:
alembic.context.configure(
connection=live_connection,
target_metadata=migration_metadata,
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
# Extract database path from SQLCipher URL
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
if db_path.startswith('/'):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
import sqlcipher3
conn = sqlcipher3.connect(db_path, check_same_thread=False)
conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
return conn
connectable = create_engine(
'sqlite://', # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
else:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
# Alembic execution entrypoint branch
if alembic.context.is_offline_mode():
run_migrations_offline() # run in offline mode
if not alembic.context.is_offline_mode():
run_migrations_online() # run in online mode
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -1,20 +1,15 @@
from __future__ import annotations
"""Alembic migration utilities."""
from alembic import op # noqa: E402 — alembic runtime context
from sqlalchemy import inspect # metadata inspection
from alembic import op
from sqlalchemy import Inspector
# --- database helper functions ---
def get_existing_tables() -> set[str]:
"""Return table names already present in the database."""
conn = op.get_bind()
return set(inspect(conn).get_table_names())
def get_existing_tables():
con = op.get_bind()
inspector = Inspector.from_engine(con)
tables = set(inspector.get_table_names())
return tables
def get_revision_id() -> str:
"""Generate a short random revision identifier."""
def get_revision_id():
import uuid
return uuid.uuid4().hex[:12]
return str(uuid.uuid4()).replace('-', '')[:12]

View file

@ -6,8 +6,8 @@ Create Date: 2025-08-13 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '018012973d35'
down_revision = 'd31026856c01'
@ -16,34 +16,18 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
def _idx_exists(table, idx_name):
return any(i['name'] == idx_name for i in inspector.get_indexes(table))
# Chat table indexes
if not _idx_exists('chat', 'folder_id_idx'):
op.create_index('folder_id_idx', 'chat', ['folder_id'])
if not _idx_exists('chat', 'user_id_pinned_idx'):
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
if not _idx_exists('chat', 'user_id_archived_idx'):
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
if not _idx_exists('chat', 'updated_at_user_id_idx'):
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
if not _idx_exists('chat', 'folder_id_user_id_idx'):
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
op.create_index('folder_id_idx', 'chat', ['folder_id'])
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
# Tag table index
if not _idx_exists('tag', 'user_id_idx'):
op.create_index('user_id_idx', 'tag', ['user_id'])
op.create_index('user_id_idx', 'tag', ['user_id'])
# Function table index (only if is_global column exists — added by a later migration)
conn = op.get_bind()
inspector = sa.inspect(conn)
func_cols = {c['name'] for c in inspector.get_columns('function')}
if 'is_global' in func_cols and not _idx_exists('function', 'is_global_idx'):
op.create_index('is_global_idx', 'function', ['is_global'])
# Function table index
op.create_index('is_global_idx', 'function', ['is_global'])
def downgrade():

View file

@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
"""
import json
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
import json
revision = '1af9b942657b'
down_revision = '242a2047eae0'
@ -93,11 +93,8 @@ def upgrade():
conn.execute(update_stmt)
# Add columns `pinned` and `meta` to 'chat'
chat_columns = {c['name'] for c in inspector.get_columns('chat')}
if 'pinned' not in chat_columns:
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
if 'meta' not in chat_columns:
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
chat = table(

View file

@ -1,28 +0,0 @@
"""Add group_member user_id index
Revision ID: 1ce6ade7d93b
Revises: f0bd01a18a3d
Create Date: 2026-07-31 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = '1ce6ade7d93b'
down_revision = 'f0bd01a18a3d'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {idx['name'] for idx in inspector.get_indexes('group_member')}
if 'ix_group_member_user_id_group_id' not in existing_indexes:
op.create_index('ix_group_member_user_id_group_id', 'group_member', ['user_id', 'group_id'])
def downgrade():
op.drop_index('ix_group_member_user_id_group_id', table_name='group_member')

View file

@ -6,11 +6,11 @@ Create Date: 2024-10-09 21:02:35.241684
"""
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import select, table, update
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update
import json
revision = '242a2047eae0'
down_revision = '6a39f3d8e55c'
@ -47,41 +47,34 @@ def upgrade():
# If the column is already JSON, no need to do anything
pass
# Step 3: Migrate data from 'old_chat' to 'chat' (only if old_chat exists)
# Re-check columns after potential rename above
current_cols = {c['name'] for c in sa.inspect(conn).get_columns('chat')}
if 'old_chat' in current_cols:
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# Step 3: Migrate data from 'old_chat' to 'chat'
chat_table = table(
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
# - Selecting all data from the table
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.old_chat))
for row in results:
try:
# Convert text JSON to actual JSON object, assuming the text is in JSON format
json_data = json.loads(row.old_chat)
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column('chat', 'old_chat')
def downgrade():
conn = op.get_bind()
columns = {col['name'] for col in sa.inspect(conn).get_columns('chat')}
# Step 1: Add 'old_chat' column back as Text
if 'old_chat' not in columns:
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
chat_table = table(
@ -91,14 +84,14 @@ def downgrade():
sa.Column('old_chat', sa.Text()),
)
if 'chat' in columns:
results = conn.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
conn.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 3: Remove the new 'chat' JSON column
op.drop_column('chat', 'chat')
# Step 4: Rename 'old_chat' back to 'chat'
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())

View file

@ -8,9 +8,9 @@ Create Date: 2025-11-27 03:07:56.200231
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '2f1211949ecc'
@ -20,76 +20,63 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# New columns to be added to channel_member table
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'status' not in cm_cols:
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
if 'is_active' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
if 'is_channel_muted' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'is_channel_pinned' not in cm_cols:
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'data' not in cm_cols:
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
if 'meta' not in cm_cols:
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
if 'joined_at' not in cm_cols:
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
if 'left_at' not in cm_cols:
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
if 'last_read_at' not in cm_cols:
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
if 'updated_at' not in cm_cols:
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
op.add_column(
'channel_member',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column(
'channel_member',
sa.Column(
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
# New columns to be added to message table
msg_cols = {c['name'] for c in inspector.get_columns('message')}
if 'is_pinned' not in msg_cols:
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
if 'pinned_at' not in msg_cols:
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
if 'pinned_by' not in msg_cols:
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
op.add_column(
'message',
sa.Column(
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
def downgrade() -> None:

View file

@ -6,11 +6,11 @@ Create Date: 2026-01-23 17:15:00.000000
"""
import uuid
from typing import Sequence, Union
import uuid
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision: str = '374d2f66af06'
down_revision: Union[str, None] = 'c440947495f3'
@ -20,167 +20,150 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# If the final state already exists (prompt has 'id' PK + prompt_history exists),
# the migration completed successfully on a prior run — nothing to do.
if 'prompt_history' in existing_tables and 'prompt_new' not in existing_tables:
# prompt_history exists and prompt_new was already renamed → done
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'id' in prompt_cols and 'version_id' in prompt_cols:
return
# Step 1: Read existing data from OLD table (schema likely command as PK)
# We use batch_alter previously, but we want to move to new table.
# We need to assume the OLD structure.
# Step 1: Read existing data from OLD table (schema: command as PK)
# Only read if the old-schema prompt table still exists (has 'command' but no 'version_id')
existing_prompts = []
if 'prompt' in existing_tables and 'prompt_new' not in existing_tables:
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'command' in prompt_cols and 'version_id' not in prompt_cols:
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
old_prompt_table = sa.table(
'prompt',
sa.column('command', sa.Text()),
sa.column('user_id', sa.Text()),
sa.column('title', sa.Text()),
sa.column('content', sa.Text()),
sa.column('timestamp', sa.BigInteger()),
sa.column('access_control', sa.JSON()),
)
# Check if table exists/read data
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
).fetchall()
except Exception:
existing_prompts = []
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
existing_prompts = []
# Step 2: Create new prompt table with 'id' as PRIMARY KEY (if not already created)
if 'prompt_new' not in existing_tables:
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
op.create_table(
'prompt_new',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('command', sa.String(), unique=True, index=True),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('version_id', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table (if not already created)
if 'prompt_history' not in existing_tables:
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table
op.create_table(
'prompt_history',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('prompt_id', sa.Text(), nullable=False, index=True),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('snapshot', sa.JSON(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('commit_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
# Step 4: Migrate data (only if we have old data to migrate)
if existing_prompts:
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
# Step 4: Migrate data
prompt_new_table = sa.table(
'prompt_new',
sa.column('id', sa.Text()),
sa.column('command', sa.String()),
sa.column('user_id', sa.String()),
sa.column('name', sa.Text()),
sa.column('content', sa.Text()),
sa.column('data', sa.JSON()),
sa.column('meta', sa.JSON()),
sa.column('access_control', sa.JSON()),
sa.column('is_active', sa.Boolean()),
sa.column('version_id', sa.Text()),
sa.column('tags', sa.JSON()),
sa.column('created_at', sa.BigInteger()),
sa.column('updated_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
prompt_history_table = sa.table(
'prompt_history',
sa.column('id', sa.Text()),
sa.column('prompt_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('snapshot', sa.JSON()),
sa.column('user_id', sa.Text()),
sa.column('commit_message', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
)
# Step 5: Replace old table with new one (only if prompt_new exists)
# Re-check tables after potential creation above
inspector.clear_cache()
current_tables = set(inspector.get_table_names())
if 'prompt_new' in current_tables:
if 'prompt' in current_tables:
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
# Step 5: Replace old table with new one
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
def downgrade() -> None:

View file

@ -6,8 +6,8 @@ Create Date: 2024-12-30 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '3781e22d8b01'
down_revision = '7826ab40b532'
@ -16,50 +16,38 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Add 'type' column to the 'channel' table
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'type' not in channel_cols:
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
op.add_column(
'channel',
sa.Column(
'type',
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
message_cols = {c['name'] for c in inspector.get_columns('message')}
if 'parent_id' not in message_cols:
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
op.add_column(
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
if 'message_reaction' not in existing_tables:
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
op.create_table(
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
if 'channel_member' not in existing_tables:
op.create_table(
'channel_member',
sa.Column(
'id', sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
op.create_table(
'channel_member',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
def downgrade():

View file

@ -6,13 +6,13 @@ Create Date: 2025-11-17 03:45:25.123939
"""
import json
import time
import uuid
import time
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '37f288994c47'
@ -22,13 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group_member' in existing_tables:
return # Already created — skip everything
# 1. Create new table
op.create_table(
'group_member',

View file

@ -8,8 +8,8 @@ Create Date: 2025-09-08 14:19:59.583921
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '38d63c18f30f'
@ -19,39 +19,50 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
existing_tables = set(inspector.get_table_names())
columns = inspector.get_columns('user')
# ── Create oauth_session table (idempotent) ───────────────────────
if 'oauth_session' not in existing_tables:
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
id_column = next((col for col in columns if col['name'] == 'id'), None)
# Create indexes (idempotent — no-ops when table was just created
# with the columns above, and safe to call if indexes already exist).
existing_indexes = (
{idx['name'] for idx in inspector.get_indexes('oauth_session')} if 'oauth_session' in existing_tables else set()
if id_column and not id_column.get('unique', False):
unique_constraints = inspector.get_unique_constraints('user')
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
with op.batch_alter_table('user') as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ['id']:
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
# Add unique constraint if missing
if ('id',) not in unique_columns:
batch_op.create_unique_constraint('uq_user_id', ['id'])
# Re-create correct primary key
batch_op.create_primary_key('pk_user_id', ['id'])
# Create oauth_session table
op.create_table(
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
'user_id',
sa.Text(),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
if 'idx_oauth_session_user_id' not in existing_indexes:
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
if 'idx_oauth_session_expires_at' not in existing_indexes:
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
if 'idx_oauth_session_user_provider' not in existing_indexes:
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
# Create indexes for better performance
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
def downgrade() -> None:

View file

@ -6,12 +6,12 @@ Create Date: 2024-10-09 21:02:35.241684
"""
import json
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, select, update, column
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import column, select, table, update
import json
revision = '3ab32c4b8f59'
down_revision = '1af9b942657b'

View file

@ -8,8 +8,8 @@ Create Date: 2025-08-21 02:07:18.078283
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3af16a1c9fb6'
@ -19,18 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'username' not in user_cols:
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
if 'bio' not in user_cols:
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
if 'gender' not in user_cols:
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
if 'date_of_birth' not in user_cols:
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
def downgrade() -> None:

View file

@ -1,72 +0,0 @@
"""add knowledge_directory table
Revision ID: 3c9b0ca343fd
Revises: a0b1c2d3e4f5
Create Date: 2026-05-13 21:58:40.832482
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3c9b0ca343fd'
down_revision: Union[str, None] = 'a0b1c2d3e4f5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'knowledge_directory' not in existing_tables:
# Create knowledge_directory table
op.create_table(
'knowledge_directory',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('knowledge_id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_id'], ['knowledge_directory.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'knowledge_id', 'parent_id', 'name', name='uq_knowledge_directory_knowledge_parent_name'
),
)
op.create_index('ix_knowledge_directory_knowledge_id', 'knowledge_directory', ['knowledge_id'])
op.create_index('ix_knowledge_directory_parent_id', 'knowledge_directory', ['parent_id'])
# Add directory_id column to knowledge_file
kf_cols = {c['name'] for c in inspector.get_columns('knowledge_file')}
if 'directory_id' not in kf_cols:
with op.batch_alter_table('knowledge_file') as batch:
batch.add_column(sa.Column('directory_id', sa.Text(), nullable=True))
batch.create_foreign_key(
'fk_knowledge_file_directory_id',
'knowledge_directory',
['directory_id'],
['id'],
ondelete='SET NULL',
)
batch.create_index('ix_knowledge_file_directory_id', ['directory_id'])
def downgrade() -> None:
# Remove directory_id from knowledge_file
with op.batch_alter_table('knowledge_file') as batch:
batch.drop_index('ix_knowledge_file_directory_id')
batch.drop_constraint('fk_knowledge_file_directory_id', type_='foreignkey')
batch.drop_column('directory_id')
# Drop knowledge_directory table
op.drop_index('ix_knowledge_directory_parent_id', table_name='knowledge_directory')
op.drop_index('ix_knowledge_directory_knowledge_id', table_name='knowledge_directory')
op.drop_table('knowledge_directory')

View file

@ -6,15 +6,16 @@ Create Date: 2025-12-02 06:54:19.401334
"""
import json
import time
import uuid
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
import open_webui.internal.db
import time
import json
import uuid
# revision identifiers, used by Alembic.
revision: str = '3e0e00844bb0'
@ -24,13 +25,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'knowledge_file' in existing_tables:
return # Already created — skip everything
op.create_table(
'knowledge_file',
sa.Column('id', sa.Text(), primary_key=True),

View file

@ -1,584 +0,0 @@
"""reshape config to per key rows
Revision ID: 3ff2c63645b8
Revises: 461111b60977
Create Date: 2026-06-17 00:50:51.477073
"""
import json
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '3ff2c63645b8'
down_revision: Union[str, None] = '461111b60977'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Maps every dot-notation blob path to its legacy env/config key name.
# Built from the legacy persistent config declarations in config.py.
BLOB_PATH_TO_KEY = {
'audio.stt.allowed_extensions': 'AUDIO_STT_ALLOWED_EXTENSIONS',
'audio.stt.azure.api_key': 'AUDIO_STT_AZURE_API_KEY',
'audio.stt.azure.base_url': 'AUDIO_STT_AZURE_BASE_URL',
'audio.stt.azure.locales': 'AUDIO_STT_AZURE_LOCALES',
'audio.stt.azure.max_speakers': 'AUDIO_STT_AZURE_MAX_SPEAKERS',
'audio.stt.azure.region': 'AUDIO_STT_AZURE_REGION',
'audio.stt.deepgram.api_key': 'DEEPGRAM_API_KEY',
'audio.stt.engine': 'AUDIO_STT_ENGINE',
'audio.stt.mistral.api_base_url': 'AUDIO_STT_MISTRAL_API_BASE_URL',
'audio.stt.mistral.api_key': 'AUDIO_STT_MISTRAL_API_KEY',
'audio.stt.mistral.use_chat_completions': 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS',
'audio.stt.model': 'AUDIO_STT_MODEL',
'audio.stt.openai.api_base_url': 'AUDIO_STT_OPENAI_API_BASE_URL',
'audio.stt.openai.api_key': 'AUDIO_STT_OPENAI_API_KEY',
'audio.stt.supported_content_types': 'AUDIO_STT_SUPPORTED_CONTENT_TYPES',
'audio.stt.whisper_model': 'WHISPER_MODEL',
'audio.tts.api_key': 'AUDIO_TTS_API_KEY',
'audio.tts.azure.speech_base_url': 'AUDIO_TTS_AZURE_SPEECH_BASE_URL',
'audio.tts.azure.speech_output_format': 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT',
'audio.tts.azure.speech_region': 'AUDIO_TTS_AZURE_SPEECH_REGION',
'audio.tts.engine': 'AUDIO_TTS_ENGINE',
'audio.tts.mistral.api_base_url': 'AUDIO_TTS_MISTRAL_API_BASE_URL',
'audio.tts.mistral.api_key': 'AUDIO_TTS_MISTRAL_API_KEY',
'audio.tts.model': 'AUDIO_TTS_MODEL',
'audio.tts.openai.api_base_url': 'AUDIO_TTS_OPENAI_API_BASE_URL',
'audio.tts.openai.api_key': 'AUDIO_TTS_OPENAI_API_KEY',
'audio.tts.openai.params': 'AUDIO_TTS_OPENAI_PARAMS',
'audio.tts.split_on': 'AUDIO_TTS_SPLIT_ON',
'audio.tts.voice': 'AUDIO_TTS_VOICE',
'auth.admin.email': 'ADMIN_EMAIL',
'auth.admin.show': 'SHOW_ADMIN_DETAILS',
'auth.api_key.allowed_endpoints': 'API_KEYS_ALLOWED_ENDPOINTS',
'auth.api_key.endpoint_restrictions': 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS',
'auth.enable_api_keys': 'ENABLE_API_KEYS',
'auth.jwt_expiry': 'JWT_EXPIRES_IN',
'automations.enable': 'ENABLE_AUTOMATIONS',
'automations.max_count': 'AUTOMATION_MAX_COUNT',
'automations.min_interval': 'AUTOMATION_MIN_INTERVAL',
'calendar.enable': 'ENABLE_CALENDAR',
'channels.enable': 'ENABLE_CHANNELS',
'code_execution.enable': 'ENABLE_CODE_EXECUTION',
'code_execution.engine': 'CODE_EXECUTION_ENGINE',
'code_execution.jupyter.auth': 'CODE_EXECUTION_JUPYTER_AUTH',
'code_execution.jupyter.auth_password': 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD',
'code_execution.jupyter.auth_token': 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN',
'code_execution.jupyter.timeout': 'CODE_EXECUTION_JUPYTER_TIMEOUT',
'code_execution.jupyter.url': 'CODE_EXECUTION_JUPYTER_URL',
'code_interpreter.enable': 'ENABLE_CODE_INTERPRETER',
'code_interpreter.engine': 'CODE_INTERPRETER_ENGINE',
'code_interpreter.jupyter.auth': 'CODE_INTERPRETER_JUPYTER_AUTH',
'code_interpreter.jupyter.auth_password': 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD',
'code_interpreter.jupyter.auth_token': 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN',
'code_interpreter.jupyter.timeout': 'CODE_INTERPRETER_JUPYTER_TIMEOUT',
'code_interpreter.jupyter.url': 'CODE_INTERPRETER_JUPYTER_URL',
'code_interpreter.prompt_template': 'CODE_INTERPRETER_PROMPT_TEMPLATE',
'direct.enable': 'ENABLE_DIRECT_CONNECTIONS',
'evaluation.arena.enable': 'ENABLE_EVALUATION_ARENA_MODELS',
'evaluation.arena.models': 'EVALUATION_ARENA_MODELS',
'file.image_compression_height': 'FILE_IMAGE_COMPRESSION_HEIGHT',
'file.image_compression_width': 'FILE_IMAGE_COMPRESSION_WIDTH',
'folders.enable': 'ENABLE_FOLDERS',
'folders.max_file_count': 'FOLDER_MAX_FILE_COUNT',
'google_drive.api_key': 'GOOGLE_DRIVE_API_KEY',
'google_drive.client_id': 'GOOGLE_DRIVE_CLIENT_ID',
'google_drive.enable': 'ENABLE_GOOGLE_DRIVE_INTEGRATION',
'image_generation.automatic1111.api_auth': 'AUTOMATIC1111_API_AUTH',
'image_generation.automatic1111.api_params': 'AUTOMATIC1111_PARAMS',
'image_generation.automatic1111.base_url': 'AUTOMATIC1111_BASE_URL',
'image_generation.comfyui.api_key': 'COMFYUI_API_KEY',
'image_generation.comfyui.base_url': 'COMFYUI_BASE_URL',
'image_generation.comfyui.nodes': 'COMFYUI_WORKFLOW_NODES',
'image_generation.comfyui.workflow': 'COMFYUI_WORKFLOW',
'image_generation.enable': 'ENABLE_IMAGE_GENERATION',
'image_generation.engine': 'IMAGE_GENERATION_ENGINE',
'image_generation.gemini.api_base_url': 'IMAGES_GEMINI_API_BASE_URL',
'image_generation.gemini.api_key': 'IMAGES_GEMINI_API_KEY',
'image_generation.gemini.endpoint_method': 'IMAGES_GEMINI_ENDPOINT_METHOD',
'image_generation.model': 'IMAGE_GENERATION_MODEL',
'image_generation.openai.api_base_url': 'IMAGES_OPENAI_API_BASE_URL',
'image_generation.openai.api_key': 'IMAGES_OPENAI_API_KEY',
'image_generation.openai.api_version': 'IMAGES_OPENAI_API_VERSION',
'image_generation.openai.params': 'IMAGES_OPENAI_API_PARAMS',
'image_generation.prompt.enable': 'ENABLE_IMAGE_PROMPT_GENERATION',
'image_generation.size': 'IMAGE_SIZE',
'image_generation.steps': 'IMAGE_STEPS',
'images.edit.comfyui.api_key': 'IMAGES_EDIT_COMFYUI_API_KEY',
'images.edit.comfyui.base_url': 'IMAGES_EDIT_COMFYUI_BASE_URL',
'images.edit.comfyui.nodes': 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES',
'images.edit.comfyui.workflow': 'IMAGES_EDIT_COMFYUI_WORKFLOW',
'images.edit.enable': 'ENABLE_IMAGE_EDIT',
'images.edit.engine': 'IMAGE_EDIT_ENGINE',
'images.edit.gemini.api_base_url': 'IMAGES_EDIT_GEMINI_API_BASE_URL',
'images.edit.gemini.api_key': 'IMAGES_EDIT_GEMINI_API_KEY',
'images.edit.model': 'IMAGE_EDIT_MODEL',
'images.edit.openai.api_base_url': 'IMAGES_EDIT_OPENAI_API_BASE_URL',
'images.edit.openai.api_key': 'IMAGES_EDIT_OPENAI_API_KEY',
'images.edit.openai.api_version': 'IMAGES_EDIT_OPENAI_API_VERSION',
'images.edit.size': 'IMAGE_EDIT_SIZE',
'ldap.enable': 'ENABLE_LDAP',
'ldap.group.enable_creation': 'ENABLE_LDAP_GROUP_CREATION',
'ldap.group.enable_management': 'ENABLE_LDAP_GROUP_MANAGEMENT',
'ldap.server.app_dn': 'LDAP_APP_DN',
'ldap.server.app_password': 'LDAP_APP_PASSWORD',
'ldap.server.attribute_for_groups': 'LDAP_ATTRIBUTE_FOR_GROUPS',
'ldap.server.attribute_for_mail': 'LDAP_ATTRIBUTE_FOR_MAIL',
'ldap.server.attribute_for_username': 'LDAP_ATTRIBUTE_FOR_USERNAME',
'ldap.server.ca_cert_file': 'LDAP_CA_CERT_FILE',
'ldap.server.ciphers': 'LDAP_CIPHERS',
'ldap.server.host': 'LDAP_SERVER_HOST',
'ldap.server.label': 'LDAP_SERVER_LABEL',
'ldap.server.port': 'LDAP_SERVER_PORT',
'ldap.server.search_filter': 'LDAP_SEARCH_FILTER',
'ldap.server.use_tls': 'LDAP_USE_TLS',
'ldap.server.users_dn': 'LDAP_SEARCH_BASE',
'ldap.server.validate_cert': 'LDAP_VALIDATE_CERT',
'memories.enable': 'ENABLE_MEMORIES',
'models.base_models_cache': 'ENABLE_BASE_MODELS_CACHE',
'models.default_metadata': 'DEFAULT_MODEL_METADATA',
'models.default_params': 'DEFAULT_MODEL_PARAMS',
'notes.enable': 'ENABLE_NOTES',
# OAuth — direct paths
'oauth.admin_roles': 'OAUTH_ADMIN_ROLES',
'oauth.allowed_domains': 'OAUTH_ALLOWED_DOMAINS',
'oauth.allowed_roles': 'OAUTH_ALLOWED_ROLES',
'oauth.audience': 'OAUTH_AUDIENCE',
'oauth.auto_redirect': 'OAUTH_AUTO_REDIRECT',
'oauth.blocked_groups': 'OAUTH_BLOCKED_GROUPS',
'oauth.client.timeout': 'OAUTH_CLIENT_TIMEOUT',
'oauth.enable_group_creation': 'ENABLE_OAUTH_GROUP_CREATION',
'oauth.enable_group_mapping': 'ENABLE_OAUTH_GROUP_MANAGEMENT',
'oauth.enable_role_mapping': 'ENABLE_OAUTH_ROLE_MANAGEMENT',
'oauth.enable_signup': 'ENABLE_OAUTH_SIGNUP',
'oauth.group_default_share': 'OAUTH_GROUP_DEFAULT_SHARE',
'oauth.merge_accounts_by_email': 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL',
'oauth.refresh_token_include_scope': 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE',
'oauth.roles_claim': 'OAUTH_ROLES_CLAIM',
'oauth.update_email_on_login': 'OAUTH_UPDATE_EMAIL_ON_LOGIN',
'oauth.update_name_on_login': 'OAUTH_UPDATE_NAME_ON_LOGIN',
'oauth.update_picture_on_login': 'OAUTH_UPDATE_PICTURE_ON_LOGIN',
# OAuth — generic provider paths
'oauth.client_id': 'OAUTH_CLIENT_ID',
'oauth.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.group_claim': 'OAUTH_GROUP_CLAIM',
'oauth.picture_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.provider_url': 'OPENID_PROVIDER_URL',
'oauth.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.scopes': 'OAUTH_SCOPES',
'oauth.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.timeout': 'OAUTH_TIMEOUT',
'oauth.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — OIDC nested paths (flattened)
'oauth.oidc.avatar_claim': 'OAUTH_PICTURE_CLAIM',
'oauth.oidc.client_id': 'OAUTH_CLIENT_ID',
'oauth.oidc.client_secret': 'OAUTH_CLIENT_SECRET',
'oauth.oidc.code_challenge_method': 'OAUTH_CODE_CHALLENGE_METHOD',
'oauth.oidc.email_claim': 'OAUTH_EMAIL_CLAIM',
'oauth.oidc.end_session_endpoint': 'OPENID_END_SESSION_ENDPOINT',
'oauth.oidc.group_claim': 'OAUTH_GROUP_CLAIM', # renamed from OAUTH_GROUPS_CLAIM
'oauth.oidc.oauth_timeout': 'OAUTH_TIMEOUT',
'oauth.oidc.provider_name': 'OAUTH_PROVIDER_NAME',
'oauth.oidc.provider_url': 'OPENID_PROVIDER_URL',
'oauth.oidc.redirect_uri': 'OPENID_REDIRECT_URI',
'oauth.oidc.scopes': 'OAUTH_SCOPES',
'oauth.oidc.sub_claim': 'OAUTH_SUB_CLAIM',
'oauth.oidc.token_endpoint_auth_method': 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD',
'oauth.oidc.username_claim': 'OAUTH_USERNAME_CLAIM',
# OAuth — provider-specific
'oauth.feishu.client_id': 'FEISHU_CLIENT_ID',
'oauth.feishu.client_secret': 'FEISHU_CLIENT_SECRET',
'oauth.feishu.redirect_uri': 'FEISHU_REDIRECT_URI',
'oauth.feishu.scope': 'FEISHU_OAUTH_SCOPE',
'oauth.github.client_id': 'GITHUB_CLIENT_ID',
'oauth.github.client_secret': 'GITHUB_CLIENT_SECRET',
'oauth.github.redirect_uri': 'GITHUB_CLIENT_REDIRECT_URI',
'oauth.github.scope': 'GITHUB_CLIENT_SCOPE',
'oauth.google.client_id': 'GOOGLE_CLIENT_ID',
'oauth.google.client_secret': 'GOOGLE_CLIENT_SECRET',
'oauth.google.redirect_uri': 'GOOGLE_REDIRECT_URI',
'oauth.google.scope': 'GOOGLE_OAUTH_SCOPE',
'oauth.microsoft.client_id': 'MICROSOFT_CLIENT_ID',
'oauth.microsoft.client_secret': 'MICROSOFT_CLIENT_SECRET',
'oauth.microsoft.login_base_url': 'MICROSOFT_CLIENT_LOGIN_BASE_URL',
'oauth.microsoft.picture_url': 'MICROSOFT_CLIENT_PICTURE_URL',
'oauth.microsoft.redirect_uri': 'MICROSOFT_REDIRECT_URI',
'oauth.microsoft.scope': 'MICROSOFT_OAUTH_SCOPE',
'oauth.microsoft.tenant_id': 'MICROSOFT_CLIENT_TENANT_ID',
# Ollama / OpenAI
'ollama.api_configs': 'OLLAMA_API_CONFIGS',
'ollama.base_urls': 'OLLAMA_BASE_URLS',
'ollama.enable': 'ENABLE_OLLAMA_API',
'onedrive.enable': 'ENABLE_ONEDRIVE_INTEGRATION',
'onedrive.sharepoint_tenant_id': 'ONEDRIVE_SHAREPOINT_TENANT_ID',
'onedrive.sharepoint_url': 'ONEDRIVE_SHAREPOINT_URL',
'openai.api_base_urls': 'OPENAI_API_BASE_URLS',
'openai.api_configs': 'OPENAI_API_CONFIGS',
'openai.api_keys': 'OPENAI_API_KEYS',
'openai.enable': 'ENABLE_OPENAI_API',
# RAG
'rag.content_extraction_engine': 'CONTENT_EXTRACTION_ENGINE',
'rag.datalab_marker_use_llm': 'DATALAB_MARKER_USE_LLM',
'rag.mistral_ocr_api_base_url': 'MISTRAL_OCR_API_BASE_URL',
'rag.azure_openai.api_key': 'RAG_AZURE_OPENAI_API_KEY',
'rag.azure_openai.api_version': 'RAG_AZURE_OPENAI_API_VERSION',
'rag.azure_openai.base_url': 'RAG_AZURE_OPENAI_BASE_URL',
'rag.bypass_embedding_and_retrieval': 'BYPASS_EMBEDDING_AND_RETRIEVAL',
'rag.chunk_min_size_target': 'CHUNK_MIN_SIZE_TARGET',
'rag.chunk_overlap': 'CHUNK_OVERLAP',
'rag.chunk_size': 'CHUNK_SIZE',
'rag.datalab_marker_additional_config': 'DATALAB_MARKER_ADDITIONAL_CONFIG',
'rag.datalab_marker_api_base_url': 'DATALAB_MARKER_API_BASE_URL',
'rag.datalab_marker_api_key': 'DATALAB_MARKER_API_KEY',
'rag.datalab_marker_disable_image_extraction': 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION',
'rag.datalab_marker_force_ocr': 'DATALAB_MARKER_FORCE_OCR',
'rag.datalab_marker_format_lines': 'DATALAB_MARKER_FORMAT_LINES',
'rag.datalab_marker_output_format': 'DATALAB_MARKER_OUTPUT_FORMAT',
'rag.datalab_marker_paginate': 'DATALAB_MARKER_PAGINATE',
'rag.datalab_marker_skip_cache': 'DATALAB_MARKER_SKIP_CACHE',
'rag.datalab_marker_strip_existing_ocr': 'DATALAB_MARKER_STRIP_EXISTING_OCR',
'rag.docling_api_key': 'DOCLING_API_KEY',
'rag.docling_params': 'DOCLING_PARAMS',
'rag.docling_server_url': 'DOCLING_SERVER_URL',
'rag.document_intelligence_endpoint': 'DOCUMENT_INTELLIGENCE_ENDPOINT',
'rag.document_intelligence_key': 'DOCUMENT_INTELLIGENCE_KEY',
'rag.document_intelligence_model': 'DOCUMENT_INTELLIGENCE_MODEL',
'rag.embedding_batch_size': 'RAG_EMBEDDING_BATCH_SIZE',
'rag.embedding_concurrent_requests': 'RAG_EMBEDDING_CONCURRENT_REQUESTS',
'rag.embedding_engine': 'RAG_EMBEDDING_ENGINE',
'rag.embedding_model': 'RAG_EMBEDDING_MODEL',
'rag.enable_async_embedding': 'ENABLE_ASYNC_EMBEDDING',
'rag.enable_hybrid_search': 'ENABLE_RAG_HYBRID_SEARCH',
'rag.enable_hybrid_search_enriched_texts': 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS',
'rag.enable_markdown_header_text_splitter': 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER',
'rag.external_document_loader_api_key': 'EXTERNAL_DOCUMENT_LOADER_API_KEY',
'rag.external_document_loader_url': 'EXTERNAL_DOCUMENT_LOADER_URL',
'rag.external_reranker_api_key': 'RAG_EXTERNAL_RERANKER_API_KEY',
'rag.external_reranker_timeout': 'RAG_EXTERNAL_RERANKER_TIMEOUT',
'rag.external_reranker_url': 'RAG_EXTERNAL_RERANKER_URL',
'rag.file.allowed_extensions': 'RAG_ALLOWED_FILE_EXTENSIONS',
'rag.file.max_count': 'RAG_FILE_MAX_COUNT',
'rag.file.max_size': 'RAG_FILE_MAX_SIZE',
'rag.full_context': 'RAG_FULL_CONTEXT',
'rag.hybrid_bm25_weight': 'RAG_HYBRID_BM25_WEIGHT',
'rag.mineru_api_key': 'MINERU_API_KEY',
'rag.mineru_api_mode': 'MINERU_API_MODE',
'rag.mineru_api_timeout': 'MINERU_API_TIMEOUT',
'rag.mineru_api_url': 'MINERU_API_URL',
'rag.mineru_file_extensions': 'MINERU_FILE_EXTENSIONS',
'rag.mineru_params': 'MINERU_PARAMS',
'rag.mistral_ocr_api_key': 'MISTRAL_OCR_API_KEY',
'rag.ollama.key': 'RAG_OLLAMA_API_KEY',
'rag.ollama.url': 'RAG_OLLAMA_BASE_URL',
'rag.openai_api_base_url': 'RAG_OPENAI_API_BASE_URL',
'rag.openai_api_key': 'RAG_OPENAI_API_KEY',
'rag.paddleocr_vl_base_url': 'PADDLEOCR_VL_BASE_URL',
'rag.paddleocr_vl_token': 'PADDLEOCR_VL_TOKEN',
'rag.pdf_extract_images': 'PDF_EXTRACT_IMAGES',
'rag.pdf_loader_mode': 'PDF_LOADER_MODE',
'rag.relevance_threshold': 'RAG_RELEVANCE_THRESHOLD',
'rag.reranking_batch_size': 'RAG_RERANKING_BATCH_SIZE',
'rag.reranking_engine': 'RAG_RERANKING_ENGINE',
'rag.reranking_model': 'RAG_RERANKING_MODEL',
'rag.template': 'RAG_TEMPLATE',
'rag.text_splitter': 'RAG_TEXT_SPLITTER',
'rag.tika_server_url': 'TIKA_SERVER_URL',
'rag.tiktoken_encoding_name': 'TIKTOKEN_ENCODING_NAME',
'rag.top_k': 'RAG_TOP_K',
'rag.top_k_reranker': 'RAG_TOP_K_RERANKER',
# RAG — Web
'rag.web.fetch.max_content_length': 'WEB_FETCH_MAX_CONTENT_LENGTH',
'rag.web.loader.concurrent_requests': 'WEB_LOADER_CONCURRENT_REQUESTS',
'rag.web.loader.engine': 'WEB_LOADER_ENGINE',
'rag.web.loader.external_web_loader_api_key': 'EXTERNAL_WEB_LOADER_API_KEY',
'rag.web.loader.external_web_loader_url': 'EXTERNAL_WEB_LOADER_URL',
'rag.web.loader.firecrawl_api_key': 'FIRECRAWL_API_KEY',
'rag.web.loader.firecrawl_api_url': 'FIRECRAWL_API_BASE_URL',
'rag.web.loader.firecrawl_timeout': 'FIRECRAWL_TIMEOUT',
'rag.web.loader.playwright_timeout': 'PLAYWRIGHT_TIMEOUT',
'rag.web.loader.playwright_ws_url': 'PLAYWRIGHT_WS_URL',
'rag.web.loader.ssl_verification': 'ENABLE_WEB_LOADER_SSL_VERIFICATION',
'rag.web.loader.timeout': 'WEB_LOADER_TIMEOUT',
'rag.web.search.azure_ai_search_api_key': 'AZURE_AI_SEARCH_API_KEY',
'rag.web.search.azure_ai_search_endpoint': 'AZURE_AI_SEARCH_ENDPOINT',
'rag.web.search.azure_ai_search_index_name': 'AZURE_AI_SEARCH_INDEX_NAME',
'rag.web.search.bing_search_v7_endpoint': 'BING_SEARCH_V7_ENDPOINT',
'rag.web.search.bing_search_v7_subscription_key': 'BING_SEARCH_V7_SUBSCRIPTION_KEY',
'rag.web.search.bocha_search_api_key': 'BOCHA_SEARCH_API_KEY',
'rag.web.search.brave_search_api_key': 'BRAVE_SEARCH_API_KEY',
'rag.web.search.brave_search_context_tokens': 'BRAVE_SEARCH_CONTEXT_TOKENS',
'rag.web.search.bypass_embedding_and_retrieval': 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL',
'rag.web.search.bypass_web_loader': 'BYPASS_WEB_SEARCH_WEB_LOADER',
'rag.web.search.concurrent_requests': 'WEB_SEARCH_CONCURRENT_REQUESTS',
'rag.web.search.ddgs_backend': 'DDGS_BACKEND',
'rag.web.search.domain.filter_list': 'WEB_SEARCH_DOMAIN_FILTER_LIST',
'rag.web.search.enable': 'ENABLE_WEB_SEARCH',
'rag.web.search.engine': 'WEB_SEARCH_ENGINE',
'rag.web.search.exa_api_key': 'EXA_API_KEY',
'rag.web.search.external_web_search_api_key': 'EXTERNAL_WEB_SEARCH_API_KEY',
'rag.web.search.external_web_search_url': 'EXTERNAL_WEB_SEARCH_URL',
'rag.web.search.google_pse_api_key': 'GOOGLE_PSE_API_KEY',
'rag.web.search.google_pse_engine_id': 'GOOGLE_PSE_ENGINE_ID',
'rag.web.search.jina_api_base_url': 'JINA_API_BASE_URL',
'rag.web.search.jina_api_key': 'JINA_API_KEY',
'rag.web.search.kagi_search_api_key': 'KAGI_SEARCH_API_KEY',
'rag.web.search.linkup_api_key': 'LINKUP_API_KEY',
'rag.web.search.linkup_search_params': 'LINKUP_SEARCH_PARAMS',
'rag.web.search.mojeek_search_api_key': 'MOJEEK_SEARCH_API_KEY',
'rag.web.search.ollama_cloud_api_key': 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY',
'rag.web.search.perplexity_api_key': 'PERPLEXITY_API_KEY',
'rag.web.search.perplexity_model': 'PERPLEXITY_MODEL',
'rag.web.search.perplexity_search_api_url': 'PERPLEXITY_SEARCH_API_URL',
'rag.web.search.perplexity_search_context_usage': 'PERPLEXITY_SEARCH_CONTEXT_USAGE',
'rag.web.search.result_count': 'WEB_SEARCH_RESULT_COUNT',
'rag.web.search.searchapi_api_key': 'SEARCHAPI_API_KEY',
'rag.web.search.searchapi_engine': 'SEARCHAPI_ENGINE',
'rag.web.search.searxng_language': 'SEARXNG_LANGUAGE',
'rag.web.search.searxng_query_url': 'SEARXNG_QUERY_URL',
'rag.web.search.serpapi_api_key': 'SERPAPI_API_KEY',
'rag.web.search.serpapi_engine': 'SERPAPI_ENGINE',
'rag.web.search.serper_api_key': 'SERPER_API_KEY',
'rag.web.search.serply_api_key': 'SERPLY_API_KEY',
'rag.web.search.serpstack_api_key': 'SERPSTACK_API_KEY',
'rag.web.search.serpstack_https': 'SERPSTACK_HTTPS',
'rag.web.search.sougou_api_sid': 'SOUGOU_API_SID',
'rag.web.search.sougou_api_sk': 'SOUGOU_API_SK',
'rag.web.search.tavily_api_key': 'TAVILY_API_KEY',
'rag.web.search.tavily_extract_depth': 'TAVILY_EXTRACT_DEPTH',
'rag.web.search.trust_env': 'WEB_SEARCH_TRUST_ENV',
'rag.web.search.yacy_password': 'YACY_PASSWORD',
'rag.web.search.yacy_query_url': 'YACY_QUERY_URL',
'rag.web.search.yacy_username': 'YACY_USERNAME',
'rag.web.search.yandex_web_search_api_key': 'YANDEX_WEB_SEARCH_API_KEY',
'rag.web.search.yandex_web_search_config': 'YANDEX_WEB_SEARCH_CONFIG',
'rag.web.search.yandex_web_search_url': 'YANDEX_WEB_SEARCH_URL',
'rag.web.search.youcom_api_key': 'YOUCOM_API_KEY',
'rag.youtube_loader_language': 'YOUTUBE_LOADER_LANGUAGE',
'rag.youtube_loader_proxy_url': 'YOUTUBE_LOADER_PROXY_URL',
# Tasks
'task.autocomplete.enable': 'ENABLE_AUTOCOMPLETE_GENERATION',
'task.autocomplete.input_max_length': 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH',
'task.autocomplete.prompt_template': 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE',
'task.follow_up.enable': 'ENABLE_FOLLOW_UP_GENERATION',
'task.follow_up.prompt_template': 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE',
'task.image.prompt_template': 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE',
'task.model.default': 'TASK_MODEL',
'task.model.external': 'TASK_MODEL_EXTERNAL',
'task.query.prompt_template': 'QUERY_GENERATION_PROMPT_TEMPLATE',
'task.query.retrieval.enable': 'ENABLE_RETRIEVAL_QUERY_GENERATION',
'task.query.search.enable': 'ENABLE_SEARCH_QUERY_GENERATION',
'task.tags.enable': 'ENABLE_TAGS_GENERATION',
'task.tags.prompt_template': 'TAGS_GENERATION_PROMPT_TEMPLATE',
'task.title.enable': 'ENABLE_TITLE_GENERATION',
'task.title.prompt_template': 'TITLE_GENERATION_PROMPT_TEMPLATE',
'task.tools.prompt_template': 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE',
'task.voice.prompt.enable': 'ENABLE_VOICE_MODE_PROMPT',
'task.voice.prompt_template': 'VOICE_MODE_PROMPT_TEMPLATE',
# Misc
'terminal_server.connections': 'TERMINAL_SERVER_CONNECTIONS',
'tool_server.connections': 'TOOL_SERVER_CONNECTIONS',
'ui.banners': 'WEBUI_BANNERS',
'ui.default_group_id': 'DEFAULT_GROUP_ID',
'ui.default_locale': 'DEFAULT_LOCALE',
'ui.default_models': 'DEFAULT_MODELS',
'ui.default_pinned_models': 'DEFAULT_PINNED_MODELS',
'ui.default_user_role': 'DEFAULT_USER_ROLE',
'ui.enable_community_sharing': 'ENABLE_COMMUNITY_SHARING',
'ui.enable_login_form': 'ENABLE_LOGIN_FORM',
'ui.enable_message_rating': 'ENABLE_MESSAGE_RATING',
'ui.enable_password_change_form': 'ENABLE_PASSWORD_CHANGE_FORM',
'ui.enable_signup': 'ENABLE_SIGNUP',
'ui.enable_user_webhooks': 'ENABLE_USER_WEBHOOKS',
'ui.model_order_list': 'MODEL_ORDER_LIST',
'ui.pending_user_overlay_content': 'PENDING_USER_OVERLAY_CONTENT',
'ui.pending_user_overlay_title': 'PENDING_USER_OVERLAY_TITLE',
'ui.prompt_suggestions': 'DEFAULT_PROMPT_SUGGESTIONS',
'ui.watermark': 'RESPONSE_WATERMARK',
'user.permissions': 'USER_PERMISSIONS',
'users.enable_status': 'ENABLE_USER_STATUS',
'webhook_url': 'WEBHOOK_URL',
'webui.url': 'WEBUI_URL',
}
STORAGE_KEY_REWRITES = {
'oauth.refresh_token_include_scope': 'oauth.refresh_token.include_scope',
'rag.openai_api_base_url': 'rag.openai.api_base_url',
'rag.openai_api_key': 'rag.openai.api_key',
'rag.ollama.url': 'rag.ollama.base_url',
'rag.ollama.key': 'rag.ollama.api_key',
'oauth.oidc.avatar_claim': 'oauth.picture_claim',
'oauth.oidc.client_id': 'oauth.client_id',
'oauth.oidc.client_secret': 'oauth.client_secret',
'oauth.oidc.code_challenge_method': 'oauth.code_challenge_method',
'oauth.oidc.email_claim': 'oauth.email_claim',
'oauth.oidc.end_session_endpoint': 'oauth.end_session_endpoint',
'oauth.oidc.group_claim': 'oauth.group_claim',
'oauth.oidc.oauth_timeout': 'oauth.timeout',
'oauth.oidc.provider_name': 'oauth.provider_name',
'oauth.oidc.provider_url': 'oauth.provider_url',
'oauth.oidc.redirect_uri': 'oauth.redirect_uri',
'oauth.oidc.scopes': 'oauth.scopes',
'oauth.oidc.sub_claim': 'oauth.sub_claim',
'oauth.oidc.token_endpoint_auth_method': 'oauth.token_endpoint_auth_method',
'oauth.oidc.username_claim': 'oauth.username_claim',
}
LEGACY_KEY_TO_STORAGE_KEY = {
legacy_key: STORAGE_KEY_REWRITES.get(blob_path, blob_path) for blob_path, legacy_key in BLOB_PATH_TO_KEY.items()
}
def _walk_blob(data: dict, prefix: str = '') -> dict:
"""Recursively walk a nested config blob, preserving known config values.
Some config values are intentionally dictionaries, e.g. OPENAI_API_CONFIGS
and OLLAMA_API_CONFIGS. Once the current path is a known config key, keep
that value intact instead of flattening its internals into orphaned rows.
"""
result = {}
for key, value in data.items():
path = f'{prefix}{key}' if not prefix else f'{prefix}.{key}'
if path in BLOB_PATH_TO_KEY or path in LEGACY_KEY_TO_STORAGE_KEY:
result[path] = value
elif isinstance(value, dict):
result.update(_walk_blob(value, path))
else:
result[path] = value
return result
def upgrade() -> None:
"""Reshape config from single-row JSON blob to per-key rows."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_old_config = {'id', 'data'}.issubset(config_columns)
has_new_config = {'key', 'value'}.issubset(config_columns)
# Ad-hoc table reference for reading the old schema
old_config = sa.table(
'config',
sa.column('id', sa.Integer),
sa.column('data', sa.JSON),
)
# 1. Read existing blob
blob_data = {}
if has_old_config:
try:
result = conn.execute(sa.select(old_config.c.data).order_by(old_config.c.id.desc()).limit(1))
row = result.fetchone()
if row and row[0]:
raw = row[0]
blob_data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
pass # Table might be partially migrated or empty
# 2. Preserve old blob table for rollback/inspection, then create per-key table.
if has_old_config:
if 'config_old' in table_names:
op.drop_table('config_old')
op.rename_table('config', 'config_old')
# 3. Create new per-key table
new_config = (
sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON()),
sa.column('updated_at', sa.BigInteger),
)
if has_new_config
else op.create_table(
'config',
sa.Column('key', sa.Text(), primary_key=True),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
)
# 4. Flatten blob and insert per-key rows
if blob_data:
flat = _walk_blob(blob_data)
# Keep stable dot-notation paths as the database keys.
# Known legacy env-style keys are rewritten to their dotted keys; unknown
# keys are still copied so custom/future config is not silently lost.
rows = {}
for blob_path, value in flat.items():
if blob_path in BLOB_PATH_TO_KEY:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
elif blob_path in LEGACY_KEY_TO_STORAGE_KEY:
storage_key = LEGACY_KEY_TO_STORAGE_KEY[blob_path]
else:
storage_key = STORAGE_KEY_REWRITES.get(blob_path, blob_path)
if storage_key not in rows:
rows[storage_key] = value
# Batch insert via SQLAlchemy table reference
if rows:
now = int(time.time())
op.bulk_insert(
new_config,
[{'key': k, 'value': v, 'updated_at': now} for k, v in rows.items()],
)
def downgrade() -> None:
"""Restore preserved old single-row config table when available."""
conn = op.get_bind()
inspector = sa.inspect(conn)
table_names = set(inspector.get_table_names())
if 'config_old' in table_names:
if 'config' in table_names:
op.drop_table('config')
op.rename_table('config_old', 'config')
return
config_columns = (
{column['name'] for column in inspector.get_columns('config')} if 'config' in table_names else set()
)
has_per_key_config = {'key', 'value'}.issubset(config_columns)
blob_data = {}
if has_per_key_config:
config = sa.table(
'config',
sa.column('key', sa.Text),
sa.column('value', sa.JSON),
)
for key, value in conn.execute(sa.select(config.c.key, config.c.value)):
blob_data[key] = json.loads(value) if isinstance(value, str) else value
op.drop_table('config')
if 'config' in table_names and not has_per_key_config:
return
old_config = op.create_table(
'config',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=True),
)
if blob_data:
op.bulk_insert(old_config, [{'data': blob_data, 'version': 0}])

View file

@ -1,40 +0,0 @@
"""add memory path and meta
Revision ID: 42e2978c7933
Revises: 7b3f2a9c1d4e
Create Date: 2026-06-29 05:35:50.565887
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '42e2978c7933'
down_revision: Union[str, None] = '7b3f2a9c1d4e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
if 'path' not in columns:
op.add_column('memory', sa.Column('path', sa.Text(), nullable=True))
if 'meta' not in columns:
op.add_column('memory', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
if 'meta' in columns:
op.drop_column('memory', 'meta')
if 'path' in columns:
op.drop_column('memory', 'path')

View file

@ -1,74 +0,0 @@
"""add missing primary keys to legacy peewee tables
Revision ID: 461111b60977
Revises: 3c9b0ca343fd
Create Date: 2026-05-14 04:38:14.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '461111b60977'
down_revision: Union[str, None] = '3c9b0ca343fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Tables bootstrapped by the old Peewee migration layer that may have
# UNIQUE(id) but no PRIMARY KEY constraint. Fresh Alembic installs
# already have correct PKs from 7e5b5dc7342b_init.py.
# 'tag' uses a composite PK since the same tag name can exist for multiple users.
LEGACY_TABLES = {
'auth': ['id'],
'chat': ['id'],
'chatidtag': ['id'],
'document': ['id'],
'file': ['id'],
'function': ['id'],
'memory': ['id'],
'model': ['id'],
'prompt': ['id'],
'tag': ['id', 'user_id'],
'tool': ['id'],
'user': ['id'],
}
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
for table_name, pk_columns in LEGACY_TABLES.items():
if table_name not in existing_tables:
continue
pk = inspector.get_pk_constraint(table_name)
pk_cols = pk.get('constrained_columns', [])
# Already has the correct PK — nothing to do
if sorted(pk_cols) == sorted(pk_columns):
continue
# Check that all PK columns exist
columns = {c['name'] for c in inspector.get_columns(table_name)}
if not all(c in columns for c in pk_columns):
continue
print(f"Promoting UNIQUE(id) -> PRIMARY KEY({', '.join(pk_columns)}) for '{table_name}'")
conn.execute(sa.text(f'DROP TABLE IF EXISTS _alembic_tmp_{table_name}'))
with op.batch_alter_table(table_name) as batch_op:
# Drop existing PK if any (e.g. on wrong column)
if pk_cols and pk.get('name'):
batch_op.drop_constraint(pk['name'], type_='primary')
batch_op.create_primary_key(f'pk_{table_name}', pk_columns)
def downgrade() -> None:
# Downgrade is a no-op — we don't want to remove PKs
pass

View file

@ -6,8 +6,8 @@ Create Date: 2024-10-23 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '4ace53fd72c8'
down_revision = 'af906e964978'
@ -16,18 +16,6 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {c['name']: c for c in inspector.get_columns('folder')}
created_at_col = columns.get('created_at')
if not created_at_col:
return
# Only convert if still DateTime — skip if already BigInteger
if isinstance(created_at_col['type'], sa.BigInteger):
return
# Perform safe alterations using batch operation
with op.batch_alter_table('folder', schema=None) as batch_op:
# Step 1: Remove server defaults for created_at and updated_at
@ -60,24 +48,20 @@ def upgrade():
def downgrade():
# Convert columns back to DateTime and restore defaults. Mirrors the
# upgrade's postgresql_using cast — without it, Postgres can't
# auto-cast BigInteger → timestamp and aborts with DatatypeMismatch.
# Downgrade: Convert columns back to DateTime and restore defaults
with op.batch_alter_table('folder', schema=None) as batch_op:
batch_op.alter_column(
'created_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(),
postgresql_using='to_timestamp(created_at)::timestamp without time zone',
server_default=sa.func.now(), # Restoring server default on downgrade
)
batch_op.alter_column(
'updated_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
postgresql_using='to_timestamp(updated_at)::timestamp without time zone',
server_default=sa.func.now(), # Restoring server default on downgrade
onupdate=sa.func.now(), # Restoring onupdate behavior if it was there
)

View file

@ -1,37 +0,0 @@
"""add context summary to chat message
Revision ID: 4c5ce3d2f27f
Revises: 3ff2c63645b8
Create Date: 2026-06-18 23:48:08.310063
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c5ce3d2f27f'
down_revision: Union[str, None] = '3ff2c63645b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' not in columns:
op.add_column('chat_message', sa.Column('context_summary', sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('chat_message')}
if 'context_summary' in columns:
op.drop_column('chat_message', 'context_summary')

View file

@ -8,9 +8,10 @@ Create Date: 2026-05-09 04:29:27.651341
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '4de81c2a3af1'
@ -19,55 +20,46 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import time
import uuid
from sqlalchemy import insert, select, update
from sqlalchemy.sql import column, table
import time
from sqlalchemy import select, update, insert
from sqlalchemy.sql import table, column
def upgrade() -> None:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'pinned_note' not in existing_tables:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
)
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(
select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)
).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[
{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now}
for note in notes
],
)
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
def downgrade() -> None:

View file

@ -1,36 +0,0 @@
"""Add memory (id, user_id) covering index
Revision ID: 55f1302ac17c
Revises: b0018471bbbe
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '55f1302ac17c'
down_revision: Union[str, None] = 'b0018471bbbe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' not in indexes:
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_id_user_id' in indexes:
op.drop_index('ix_memory_id_user_id', table_name='memory')

View file

@ -8,8 +8,9 @@ Create Date: 2026-04-19 16:20:58.162045
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '56359461a091'
@ -18,86 +19,58 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _index_exists(inspector, index_name, table_name):
"""Check if an index already exists on the given table."""
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
if 'calendar' not in tables:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
inspector.clear_cache()
if 'calendar' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_user', 'calendar'):
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
if 'calendar_event' not in tables:
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
inspector.clear_cache()
if 'calendar_event' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_calendar', 'calendar_event'):
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
if not _index_exists(inspector, 'ix_calendar_event_user_date', 'calendar_event'):
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
if 'calendar_event_attendee' not in tables:
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
inspector.clear_cache()
if 'calendar_event_attendee' in inspector.get_table_names():
if not _index_exists(inspector, 'ix_calendar_event_attendee_user', 'calendar_event_attendee'):
op.create_index(
'ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False
)
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
def downgrade() -> None:

View file

@ -6,8 +6,8 @@ Create Date: 2024-12-22 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '57c599a3cb57'
down_revision = '922e7a387820'
@ -16,36 +16,30 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'channel' not in existing_tables:
op.create_table(
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'message' not in existing_tables:
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
op.create_table(
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():

View file

@ -8,9 +8,9 @@ Create Date: 2025-12-10 15:11:39.424601
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '6283dc0e4d8d'
@ -20,38 +20,31 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'channel_file' not in existing_tables:
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
'channel_id', 'file_id', name='uq_channel_file_channel_file'
), # prevent duplicate entries
)
op.create_table(
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
'file_id',
sa.Text(),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
)
def downgrade() -> None:

View file

@ -6,11 +6,10 @@ Create Date: 2024-10-01 14:02:35.241684
"""
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql import column, select, table
import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
revision = '6a39f3d8e55c'
down_revision = 'c0fbf31ca0db'
@ -19,67 +18,62 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
if 'knowledge' not in existing_tables:
# Creating the 'knowledge' table
print('Creating knowledge table')
knowledge_table = op.create_table(
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = op.get_bind().execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
)
)
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
documents = conn.execute(
select(
document_table.c.collection_name,
document_table.c.user_id,
document_table.c.name,
document_table.c.title,
document_table.c.content,
document_table.c.timestamp,
# Insert data into knowledge table from document table
for doc in documents:
op.get_bind().execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp, # using created_at for both created_at and updated_at in project
)
)
# Insert data into knowledge table from document table
for doc in documents:
conn.execute(
knowledge_table.insert().values(
id=doc.collection_name,
user_id=doc.user_id,
description=doc.name,
meta={
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
updated_at=doc.timestamp,
)
)
def downgrade():
op.drop_table('knowledge')

View file

@ -1,64 +0,0 @@
"""repair double encoded user oauth
Revision ID: 6d09d1bf1f23
Revises: 1ce6ade7d93b
Create Date: 2026-08-10 23:20:20.374826
"""
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '6d09d1bf1f23'
down_revision: Union[str, None] = '1ce6ade7d93b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_user = sa.table(
'user',
sa.column('id', sa.Text),
sa.column('oauth', sa.JSON),
)
def _decode_json_object(value: str) -> dict | None:
try:
decoded = json.loads(value)
except Exception:
return None
return decoded if isinstance(decoded, dict) else None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'user' not in inspector.get_table_names():
return
user_columns = {c['name'] for c in inspector.get_columns('user')}
if 'oauth' not in user_columns:
return
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
for uid, oauth in rows:
if not isinstance(oauth, str):
continue
decoded = _decode_json_object(oauth)
if decoded is None:
continue
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth=decoded))
def downgrade() -> None:
pass

View file

@ -6,8 +6,8 @@ Create Date: 2024-12-23 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '7826ab40b532'
down_revision = '57c599a3cb57'
@ -16,15 +16,10 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
if 'access_control' not in file_cols:
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
op.add_column(
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():

View file

@ -1,44 +0,0 @@
"""add memory type
Revision ID: 7b3f2a9c1d4e
Revises: 4c5ce3d2f27f
Create Date: 2026-06-25 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '7b3f2a9c1d4e'
down_revision: Union[str, None] = '4c5ce3d2f27f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'type' not in columns:
op.add_column('memory', sa.Column('type', sa.String(), server_default='context', nullable=False))
if 'ix_memory_type' not in indexes:
op.create_index('ix_memory_type', 'memory', ['type'])
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column['name'] for column in inspector.get_columns('memory')}
indexes = {index['name'] for index in inspector.get_indexes('memory')}
if 'ix_memory_type' in indexes:
op.drop_index('ix_memory_type', table_name='memory')
if 'type' in columns:
op.drop_column('memory', 'type')

View file

@ -1,34 +1,44 @@
# Initial bootstrap migration version.
# Revision ID: 7e5b5dc7342b
# Revises: (none)
# Created on: 2024-06-24 13:15:33.808998
from __future__ import annotations
from typing import Sequence
import open_webui.internal.db # noqa: F401
"""init
Revision ID: 7e5b5dc7342b
Revises:
Create Date: 2024-06-24 13:15:33.808998
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import open_webui.internal.db
from open_webui.internal.db import JSONField
from open_webui.migrations.util import get_existing_tables
# revision identifiers, used by Alembic.
revision: str = '7e5b5dc7342b'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Initial schema table declarations
_INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
(
'auth',
[
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
# ### commands auto generated by Alembic - please adjust! ###
if 'auth' not in existing_tables:
op.create_table(
'auth',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('password', sa.Text(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'chat',
[
sa.PrimaryKeyConstraint('id'),
)
if 'chat' not in existing_tables:
op.create_table(
'chat',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@ -37,23 +47,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('share_id', sa.Text(), nullable=True),
sa.Column('archived', sa.Boolean(), nullable=True),
],
[sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('share_id')],
),
(
'chatidtag',
[
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('share_id'),
)
if 'chatidtag' not in existing_tables:
op.create_table(
'chatidtag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('tag_name', sa.String(), nullable=True),
sa.Column('chat_id', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'document',
[
sa.PrimaryKeyConstraint('id'),
)
if 'document' not in existing_tables:
op.create_table(
'document',
sa.Column('collection_name', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
@ -61,23 +72,24 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('content', sa.Text(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('collection_name'), sa.UniqueConstraint('name')],
),
(
'file',
[
sa.PrimaryKeyConstraint('collection_name'),
sa.UniqueConstraint('name'),
)
if 'file' not in existing_tables:
op.create_table(
'file',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('filename', sa.Text(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'function',
[
sa.PrimaryKeyConstraint('id'),
)
if 'function' not in existing_tables:
op.create_table(
'function',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@ -89,23 +101,23 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('is_global', sa.Boolean(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'memory',
[
sa.PrimaryKeyConstraint('id'),
)
if 'memory' not in existing_tables:
op.create_table(
'memory',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'model',
[
sa.PrimaryKeyConstraint('id'),
)
if 'model' not in existing_tables:
op.create_table(
'model',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('base_model_id', sa.Text(), nullable=True),
@ -114,33 +126,33 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('meta', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'prompt',
[
sa.PrimaryKeyConstraint('id'),
)
if 'prompt' not in existing_tables:
op.create_table(
'prompt',
sa.Column('command', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('command')],
),
(
'tag',
[
sa.PrimaryKeyConstraint('command'),
)
if 'tag' not in existing_tables:
op.create_table(
'tag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('data', sa.Text(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'tool',
[
sa.PrimaryKeyConstraint('id'),
)
if 'tool' not in existing_tables:
op.create_table(
'tool',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
@ -150,12 +162,12 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('valves', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
],
[sa.PrimaryKeyConstraint('id')],
),
(
'user',
[
sa.PrimaryKeyConstraint('id'),
)
if 'user' not in existing_tables:
op.create_table(
'user',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
@ -168,25 +180,25 @@ _INITIAL_TABLES: list[tuple[str, list[sa.Column], list]] = [
sa.Column('settings', JSONField(), nullable=True),
sa.Column('info', JSONField(), nullable=True),
sa.Column('oauth_sub', sa.Text(), nullable=True),
],
[
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key'),
sa.UniqueConstraint('oauth_sub'),
],
),
]
)
# ### end Alembic commands ###
# --- migration execution ---
def upgrade() -> None: # deploy initial schema tables
existing_tables = set(get_existing_tables())
for name, columns, constraints in _INITIAL_TABLES:
if name not in existing_tables:
op.create_table(name, *columns, *constraints)
# --- rollback function ---
def downgrade() -> None: # rollback initial schema tables
for table_name, _, _ in reversed(_INITIAL_TABLES):
op.drop_table(table_name)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
op.drop_table('tool')
op.drop_table('tag')
op.drop_table('prompt')
op.drop_table('model')
op.drop_table('memory')
op.drop_table('function')
op.drop_table('file')
op.drop_table('document')
op.drop_table('chatidtag')
op.drop_table('chat')
op.drop_table('auth')
# ### end Alembic commands ###

View file

@ -8,9 +8,9 @@ Create Date: 2025-12-10 16:07:58.001282
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '81cc2ce44d79'
@ -20,27 +20,20 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Add message_id column to channel_file table
cf_cols = {c['name'] for c in inspector.get_columns('channel_file')}
if 'message_id' not in cf_cols:
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
)
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'message_id',
sa.Text(),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
)
)
# Add data column to knowledge table
k_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'data' not in k_cols:
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
def downgrade() -> None:

View file

@ -6,13 +6,13 @@ Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
import time
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
log = logging.getLogger(__name__)
@ -56,13 +56,6 @@ def _flush_batch(conn, table, batch):
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'chat_message' in existing_tables:
return # Already created — skip everything
# Step 1: Create table
op.create_table(
'chat_message',
@ -92,6 +85,8 @@ def upgrade() -> None:
op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at'])
# Step 2: Backfill from existing chats
conn = op.get_bind()
chat_table = sa.table(
'chat',
sa.column('id', sa.Text()),

View file

@ -1,25 +0,0 @@
"""add chat message meta
Revision ID: 856c5b02fb54
Revises: 42e2978c7933
Create Date: 2026-07-16 01:39:39.291935
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '856c5b02fb54'
down_revision: Union[str, None] = '42e2978c7933'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('chat_message', 'meta')

View file

@ -8,9 +8,9 @@ Create Date: 2025-11-30 06:33:38.790341
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '90ef40d4714e'
@ -20,53 +20,42 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
# Update 'channel' table
channel_cols = {c['name'] for c in inspector.get_columns('channel')}
if 'is_private' not in channel_cols:
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
if 'archived_at' not in channel_cols:
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
if 'archived_by' not in channel_cols:
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
if 'deleted_at' not in channel_cols:
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
if 'deleted_by' not in channel_cols:
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
if 'updated_by' not in channel_cols:
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
# Update 'channel_member' table
cm_cols = {c['name'] for c in inspector.get_columns('channel_member')}
if 'role' not in cm_cols:
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
if 'invited_by' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
if 'invited_at' not in cm_cols:
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
# Create 'channel_webhook' table
if 'channel_webhook' not in existing_tables:
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
op.create_table(
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
'channel_id',
sa.Text(),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
pass
def downgrade() -> None:
@ -85,3 +74,5 @@ def downgrade() -> None:
# Drop 'channel_webhook' table
op.drop_table('channel_webhook')
pass

View file

@ -6,8 +6,8 @@ Create Date: 2024-11-14 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '922e7a387820'
down_revision = '4ace53fd72c8'
@ -16,55 +16,70 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'group' not in existing_tables:
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
op.create_table(
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# Add 'access_control' column to 'model' table
model_cols = {c['name'] for c in inspector.get_columns('model')}
if 'access_control' not in model_cols:
op.add_column('model', sa.Column('access_control', sa.JSON(), nullable=True))
if 'is_active' not in model_cols:
op.add_column(
'model',
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true()),
)
op.add_column(
'model',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'is_active' column to 'model' table
op.add_column(
'model',
sa.Column(
'is_active',
sa.Boolean(),
nullable=False,
server_default=sa.sql.expression.true(),
),
)
# Add 'access_control' column to 'knowledge' table
knowledge_cols = {c['name'] for c in inspector.get_columns('knowledge')}
if 'access_control' not in knowledge_cols:
op.add_column('knowledge', sa.Column('access_control', sa.JSON(), nullable=True))
op.add_column(
'knowledge',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'prompt' table
prompt_cols = {c['name'] for c in inspector.get_columns('prompt')}
if 'access_control' not in prompt_cols:
op.add_column('prompt', sa.Column('access_control', sa.JSON(), nullable=True))
op.add_column(
'prompt',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'tools' table
tool_cols = {c['name'] for c in inspector.get_columns('tool')}
if 'access_control' not in tool_cols:
op.add_column('tool', sa.Column('access_control', sa.JSON(), nullable=True))
op.add_column(
'tool',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():
op.drop_table('group')
# Drop 'access_control' column from 'model' table
op.drop_column('model', 'access_control')
# Drop 'is_active' column from 'model' table
op.drop_column('model', 'is_active')
# Drop 'access_control' column from 'knowledge' table
op.drop_column('knowledge', 'access_control')
# Drop 'access_control' column from 'prompt' table
op.drop_column('prompt', 'access_control')
# Drop 'access_control' column from 'tools' table
op.drop_column('tool', 'access_control')

View file

@ -1,54 +0,0 @@
"""add automation folder id
Revision ID: 959eaac8f909
Revises: 55f1302ac17c
Create Date: 2026-07-26 19:19:31.345756
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = '959eaac8f909'
down_revision: str | None = '55f1302ac17c'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
if context.is_offline_mode():
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'folder_id' not in columns:
op.add_column('automation', sa.Column('folder_id', sa.Text(), nullable=True))
if 'ix_automation_user_folder' not in indexes:
op.create_index('ix_automation_user_folder', 'automation', ['user_id', 'folder_id'])
def downgrade() -> None:
if context.is_offline_mode():
op.drop_index('ix_automation_user_folder', table_name='automation')
op.drop_column('automation', 'folder_id')
return
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {col['name'] for col in inspector.get_columns('automation')}
indexes = {index['name'] for index in inspector.get_indexes('automation')}
if 'ix_automation_user_folder' in indexes:
op.drop_index('ix_automation_user_folder', table_name='automation')
if 'folder_id' in columns:
op.drop_column('automation', 'folder_id')

View file

@ -1,219 +0,0 @@
"""add current_message_id to chat
Revision ID: 9a1b2c3d4e5f
Revises: 856c5b02fb54
Create Date: 2026-07-23 00:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '9a1b2c3d4e5f'
down_revision: Union[str, None] = '856c5b02fb54'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
BATCH_SIZE = 150
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'current_message_id' not in columns:
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
chat = sa.table(
'chat',
sa.column('id', sa.String()),
sa.column('chat', sa.Text()),
sa.column('current_message_id', sa.Text()),
)
chat_message = sa.table(
'chat_message',
sa.column('id', sa.Text()),
sa.column('chat_id', sa.Text()),
sa.column('parent_id', sa.Text()),
sa.column('created_at', sa.BigInteger()),
)
has_chat_message = 'chat_message' in inspector.get_table_names()
result = conn.execute(
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
yield_per=BATCH_SIZE,
stream_results=True,
)
)
while True:
rows = result.fetchmany(BATCH_SIZE)
if not rows:
break
batch_chat_ids: list[str] = []
candidates_by_chat: dict[str, list[str]] = {}
current_by_chat: dict[str, str | None] = {}
json_messages_by_chat: dict[str, dict[str, dict]] = {}
for row in rows:
values = row._mapping
chat_id = values['id']
prefix = f'{chat_id}-'
batch_chat_ids.append(chat_id)
current_by_chat[chat_id] = values['current_message_id']
chat_data = {}
if isinstance(values['chat'], dict):
chat_data = values['chat']
elif isinstance(values['chat'], str):
try:
parsed = json.loads(values['chat'])
chat_data = parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError, json.JSONDecodeError):
pass
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
candidates_by_chat[chat_id] = []
for candidate in (
values['current_message_id'],
history.get('currentId'),
chat_data.get('currentId'),
chat_data.get('branchPointMessageId'),
):
if not isinstance(candidate, str) or not candidate:
continue
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
if candidate not in candidates_by_chat[chat_id]:
candidates_by_chat[chat_id].append(candidate)
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
if not messages and isinstance(chat_data.get('messages'), list):
messages = {
message['id']: message
for message in chat_data['messages']
if isinstance(message, dict) and message.get('id')
}
if messages:
json_messages_by_chat[chat_id] = {
message_id: {
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
}
for message_id, message in messages.items()
}
resolved: dict[str, str] = {}
if has_chat_message:
candidate_ids = {
f'{chat_id}-{candidate}'
for chat_id, candidates in candidates_by_chat.items()
for candidate in candidates
}
if candidate_ids:
valid_by_chat: dict[str, set[str]] = {}
for row in conn.execute(
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
chat_message.c.chat_id.in_(batch_chat_ids),
chat_message.c.id.in_(candidate_ids),
)
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if message_id:
valid_by_chat.setdefault(chat_id, set()).add(message_id)
for chat_id, candidates in candidates_by_chat.items():
valid_ids = valid_by_chat.get(chat_id, set())
for candidate in candidates:
if candidate in valid_ids:
resolved[chat_id] = candidate
break
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
messages_by_chat: dict[str, dict[str, dict]] = {}
if unresolved_chat_ids:
for row in conn.execute(
sa.select(
chat_message.c.chat_id,
chat_message.c.id,
chat_message.c.parent_id,
chat_message.c.created_at,
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
):
values = row._mapping
chat_id = values['chat_id']
prefix = f'{chat_id}-'
message_id = values['id']
if message_id and message_id.startswith(prefix):
message_id = message_id[len(prefix) :]
if not message_id:
continue
parent_id = values['parent_id']
if parent_id and parent_id.startswith(prefix):
parent_id = parent_id[len(prefix) :]
messages_by_chat.setdefault(chat_id, {})[message_id] = {
'parent_id': parent_id,
'created_at': values['created_at'] or 0,
}
for chat_id, messages in messages_by_chat.items():
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
for chat_id in batch_chat_ids:
if chat_id in resolved:
continue
messages = json_messages_by_chat.get(chat_id, {})
valid_candidate = next(
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
None,
)
if valid_candidate:
resolved[chat_id] = valid_candidate
elif messages:
parent_ids = {
message['parent_id'] for message in messages.values() if message.get('parent_id') in messages
}
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
resolved[chat_id] = max(
leaf_ids or list(messages),
key=lambda message_id: messages[message_id].get('created_at') or 0,
)
updates = [
{'chat_id': chat_id, 'current_message_id': message_id}
for chat_id, message_id in resolved.items()
if message_id and message_id != current_by_chat.get(chat_id)
]
if updates:
conn.execute(
sa.update(chat)
.where(chat.c.id == sa.bindparam('update_chat_id'))
.values(current_message_id=sa.bindparam('update_current_message_id')),
[
{
'update_chat_id': row['chat_id'],
'update_current_message_id': row['current_message_id'],
}
for row in updates
],
)
def downgrade() -> None:
op.drop_column('chat', 'current_message_id')

View file

@ -6,8 +6,8 @@ Create Date: 2025-05-03 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = '9f0c9cd09105'
down_revision = '3781e22d8b01'
@ -16,22 +16,17 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'note' not in existing_tables:
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
op.create_table(
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():

View file

@ -6,7 +6,6 @@ Create Date: 2025-09-15 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = 'a0b1c2d3e4f5'
@ -16,12 +15,7 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {idx['name'] for idx in inspector.get_indexes('memory')}
if 'ix_memory_user_id' not in existing_indexes:
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
def downgrade():

View file

@ -8,8 +8,9 @@ Create Date: 2026-02-11 09:30:00.000000
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = 'a1b2c3d4e5f6'

View file

@ -8,8 +8,8 @@ Create Date: 2026-03-29 22:15:00.000000
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
@ -19,14 +19,8 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'tasks' not in columns:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
if 'summary' not in columns:
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:

View file

@ -8,8 +8,8 @@ Create Date: 2025-09-27 02:24:18.058455
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a5c220713937'
@ -19,18 +19,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
msg_cols = {c['name'] for c in inspector.get_columns('message')}
# Add 'reply_to_id' column to the 'message' table for replying to messages
if 'reply_to_id' not in msg_cols:
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
op.add_column(
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
pass
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column('message', 'reply_to_id')
pass

View file

@ -6,8 +6,8 @@ Create Date: 2024-10-20 17:02:35.241684
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# Revision identifiers, used by Alembic.
revision = 'af906e964978'
@ -17,28 +17,23 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if 'feedback' not in existing_tables:
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
# ### Create feedback table ###
op.create_table(
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
def downgrade():

View file

@ -1,32 +0,0 @@
"""add user variables
Revision ID: b0018471bbbe
Revises: c49178636c78
Create Date: 2026-07-24 01:21:46.457057
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b0018471bbbe'
down_revision: Union[str, None] = 'c49178636c78'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('user')]
if 'variables' not in columns:
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'variables')

View file

@ -6,13 +6,15 @@ Create Date: 2025-11-28 04:55:31.737538
"""
import json
import time
from typing import Sequence, Union
import open_webui.internal.db
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import json
import time
# revision identifiers, used by Alembic.
revision: str = 'b10670c03dd5'
@ -20,49 +22,20 @@ down_revision: Union[str, None] = '2f1211949ecc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# ── Ad-hoc table references for Core DML ─────────────────────────────────
# These are lightweight table() / column() references used only inside this
# migration for SELECT / UPDATE / INSERT — they do NOT create or alter
# anything on disk.
_user = sa.table(
'user',
sa.column('id', sa.Text),
sa.column('oauth_sub', sa.Text),
sa.column('oauth', sa.JSON),
sa.column('api_key', sa.Text),
sa.column('info', sa.Text),
sa.column('settings', sa.Text),
)
_api_key = sa.table(
'api_key',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('key', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
"""
SQLite requires manual removal of any user-created indexes referencing
a column before ALTER TABLE ... DROP COLUMN can succeed.
NOTE: PRAGMAs have no Core equivalent raw text is unavoidable here.
SQLite requires manual removal of any indexes referencing a column
before ALTER TABLE ... DROP COLUMN can succeed.
"""
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
for idx in indexes:
index_name = idx[1] # index name
# Skip system-managed autoindexes (PK / UNIQUE constraints) — they
# cannot be dropped directly and will disappear when the column is
# removed via batch_alter_table.
if index_name.startswith('sqlite_autoindex_'):
continue
# Get indexed columns
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
indexed_cols = [row[2] for row in idx_info]
indexed_cols = [row[2] for row in idx_info] # col names
if column_name in indexed_cols:
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
@ -71,30 +44,33 @@ def _convert_column_to_json(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.Text))
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == 'sqlite':
# 1. Add temporary column
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for uid, raw in rows:
for row in rows:
uid, raw = row
if raw is None:
parsed = None
else:
try:
parsed = json.loads(raw)
except Exception:
parsed = None
parsed = None # fallback safe behavior
conn.execute(
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_json', sa.JSON)))
.where(sa.column('id') == uid)
.values({f'{column}_json': parsed})
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f'{column}_json', new_column_name=column)
else:
@ -111,18 +87,15 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
t = sa.table(table, sa.column('id', sa.Text), sa.column(column, sa.JSON))
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
rows = conn.execute(sa.select(t.c.id, t.c[column])).fetchall()
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for uid, raw in rows:
conn.execute(
sa.update(sa.table(table, sa.column('id'), sa.column(f'{column}_text', sa.Text)))
.where(sa.column('id') == uid)
.values({f'{column}_text': json.dumps(raw) if raw is not None else None})
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{'val': json.dumps(raw) if raw else None, 'id': uid},
)
op.drop_column(table, column)
@ -138,110 +111,115 @@ def _convert_column_to_text(table: str, column: str):
def upgrade() -> None:
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json('user', 'settings')
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
user_columns = {c['name'] for c in inspector.get_columns('user')}
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
# ── Add new columns (idempotent) ──────────────────────────────────
for col_name, col_type in [
('profile_banner_image_url', sa.Text()),
('timezone', sa.String()),
('presence_state', sa.String()),
('status_emoji', sa.String()),
('status_message', sa.Text()),
('status_expires_at', sa.BigInteger()),
('oauth', sa.JSON()),
]:
if col_name not in user_columns:
op.add_column('user', sa.Column(col_name, col_type, nullable=True))
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if '@' in oauth_sub:
provider, sub = oauth_sub.split('@', 1)
else:
provider, sub = 'oidc', oauth_sub
# Convert info (TEXT/JSONField) → JSON (skip if already JSON)
user_col_types = {c['name']: c['type'] for c in inspector.get_columns('user')}
if isinstance(user_col_types.get('info'), sa.Text):
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON (skip if already JSON)
if isinstance(user_col_types.get('settings'), sa.Text):
_convert_column_to_json('user', 'settings')
oauth_json = json.dumps({provider: {'sub': sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{'oauth': oauth_json, 'id': uid},
)
# ── Create api_key table (idempotent) ─────────────────────────────
if 'api_key' not in existing_tables:
op.create_table(
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
now = int(time.time())
# ── Migrate oauth_sub → oauth JSON (only if old column still exists)
if 'oauth_sub' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth_sub).where(_user.c.oauth_sub.is_not(None))).fetchall()
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text("""
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""),
{
'id': f'key_{uid}',
'user_id': uid,
'key': api_key,
'created_at': now,
'updated_at': now,
},
)
for uid, oauth_sub in rows:
if oauth_sub:
provider, sub = oauth_sub.split('@', 1) if '@' in oauth_sub else ('oidc', oauth_sub)
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth={provider: {'sub': sub}}))
if conn.dialect.name == 'sqlite':
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
# ── Migrate api_key column → api_key table (only if old column still exists)
if 'api_key' in user_columns:
rows = conn.execute(sa.select(_user.c.id, _user.c.api_key).where(_user.c.api_key.is_not(None))).fetchall()
now = int(time.time())
for uid, key_val in rows:
if key_val:
conn.execute(
sa.insert(_api_key).values(
id=f'key_{uid}',
user_id=uid,
key=key_val,
created_at=now,
updated_at=now,
)
)
# ── Drop legacy columns (idempotent) ──────────────────────────────
cols_to_drop = {'api_key', 'oauth_sub'} & user_columns
if cols_to_drop:
if conn.dialect.name == 'sqlite':
for col in cols_to_drop:
_drop_sqlite_indexes_for_column('user', col, conn)
with op.batch_alter_table('user') as batch_op:
for col in cols_to_drop:
batch_op.drop_column(col)
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('api_key')
batch_op.drop_column('oauth_sub')
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
conn = op.get_bind()
rows = conn.execute(sa.select(_user.c.id, _user.c.oauth).where(_user.c.oauth.is_not(None))).fetchall()
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
for uid, oauth in rows:
for uid, oauth in users:
try:
data = oauth if isinstance(oauth, dict) else json.loads(oauth)
data = json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get('sub')
oauth_sub = f'{provider}@{sub}'
except Exception:
oauth_sub = None
conn.execute(sa.update(_user).where(_user.c.id == uid).values(oauth_sub=oauth_sub))
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{'oauth_sub': oauth_sub, 'id': uid},
)
op.drop_column('user', 'oauth')
# --- Restore api_key field ---
# --- 2. Restore api_key field ---
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
keys = conn.execute(sa.select(_api_key.c.user_id, _api_key.c.key)).fetchall()
# Restore values from api_key
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
for uid, key in keys:
conn.execute(sa.update(_user).where(_user.c.id == uid).values(api_key=key))
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{'key': key, 'id': uid},
)
# Drop new table
op.drop_table('api_key')
with op.batch_alter_table('user') as batch_op:

View file

@ -8,8 +8,8 @@ Create Date: 2026-02-13 14:19:00.000000
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
@ -19,12 +19,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
user_cols = {c['name'] for c in inspector.get_columns('user')}
if 'scim' not in user_cols:
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
def downgrade() -> None:

View file

@ -6,8 +6,9 @@ Create Date: 2026-04-01 04:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
@ -17,14 +18,9 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('chat')]
if 'last_read_at' not in columns:
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
def downgrade():

View file

@ -19,17 +19,10 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# ### commands auto generated by Alembic - please adjust! ###
if 'hash' not in file_cols:
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
if 'data' not in file_cols:
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
if 'updated_at' not in file_cols:
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
def downgrade():

View file

@ -9,8 +9,8 @@ Create Date: 2026-04-16 23:00:00.000000
import time
import uuid
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
@ -61,21 +61,18 @@ access_grant_t = sa.table(
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# 1. Create shared_chat table (idempotent)
if 'shared_chat' not in tables:
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
@ -99,51 +96,31 @@ def upgrade():
if not original:
continue
# Check if shared_chat record already exists (idempotent)
existing_shared = conn.execute(
sa.select(shared_chat_t.c.id).where(shared_chat_t.c.id == share_token)
).fetchone()
if not existing_shared:
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Check if access_grant record already exists (idempotent)
existing_grant = conn.execute(
sa.select(access_grant_t.c.id).where(
sa.and_(
access_grant_t.c.resource_type == 'shared_chat',
access_grant_t.c.resource_id == original_chat_id,
access_grant_t.c.principal_type == 'user',
access_grant_t.c.principal_id == '*',
access_grant_t.c.permission == 'read',
)
)
).fetchone()
if not existing_grant:
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
# 3. Clean up old phantom rows
conn.execute(

View file

@ -6,12 +6,11 @@ Create Date: 2024-10-20 17:02:35.241684
"""
import json
import sqlalchemy as sa
from alembic import op
from sqlalchemy import JSON, String, Text, and_
from sqlalchemy.sql import column, table
import sqlalchemy as sa
import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
revision = 'c29facfe716b'
down_revision = 'c69f45358db4'
@ -20,13 +19,8 @@ depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
file_cols = {c['name'] for c in inspector.get_columns('file')}
# 1. Add the `path` column to the "file" table.
if 'path' not in file_cols:
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
# Use Alembic's default batch_op for dialect compatibility.

Some files were not shown because too many files have changed in this diff Show more