diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d50d38d8484..2f44750fe0d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,8 +10,15 @@ 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. + + **Before submitting, make sure you've checked the following:** +- [ ] **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. diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml.disabled similarity index 100% rename from .github/workflows/build-release.yml rename to .github/workflows/build-release.yml.disabled diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml.disabled similarity index 100% rename from .github/workflows/docker-build.yaml rename to .github/workflows/docker-build.yaml.disabled diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml.disabled similarity index 80% rename from .github/workflows/format-backend.yaml rename to .github/workflows/format-backend.yaml.disabled index 562e6aa1c13..ee2d689d897 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml.disabled @@ -40,10 +40,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install black + pip install "ruff>=0.15.5" - - name: Format backend - run: npm run format:backend - - - name: Check for changes after format - run: git diff --exit-code + - name: Ruff format check + run: ruff format --check . --exclude .venv --exclude venv diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml.disabled similarity index 100% rename from .github/workflows/format-build-frontend.yaml rename to .github/workflows/format-build-frontend.yaml.disabled diff --git a/.github/workflows/publish-flex-image.yml b/.github/workflows/publish-flex-image.yml new file mode 100644 index 00000000000..2e4bdb61cd0 --- /dev/null +++ b/.github/workflows/publish-flex-image.yml @@ -0,0 +1,83 @@ +name: Publish flex image to ECR + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag for ECR (e.g. v0.9.6). Use the upstream release tag verbatim — keep the 0. prefix.' + required: true + type: string + environment: + description: 'Target environment' + required: true + type: choice + options: + - dev + - prod + default: dev + +permissions: + id-token: write + contents: read + +jobs: + publish: + name: Build flex@${{ github.sha }} → open-webui-${{ inputs.environment }}:${{ inputs.version }} + # ARM-native runner — matches Fargate ARM target, no QEMU emulation needed. + runs-on: ubuntu-24.04-arm + env: + AWS_REGION: ${{ secrets.AWS_REGION }} + REPOSITORY: open-webui-${{ inputs.environment }} + steps: + - uses: actions/checkout@v6 + + - uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ inputs.environment == 'prod' && secrets.AWS_ROLE_ARN_PROD || secrets.AWS_ROLE_ARN_DEV }} + aws-region: ${{ env.AWS_REGION }} + + - uses: docker/setup-buildx-action@v4 + + - id: ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Refuse to overwrite an existing tag + run: | + if aws ecr describe-images \ + --repository-name "$REPOSITORY" \ + --region "$AWS_REGION" \ + --image-ids imageTag="${{ inputs.version }}" \ + >/dev/null 2>&1; then + echo "::error title=Tag exists::${REPOSITORY}:${{ inputs.version }} already exists in ECR. Promotion is intentionally not idempotent — delete the existing tag manually or pick a different version." + exit 1 + fi + + - name: Build and push (linux/arm64) + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/arm64 + push: true + tags: ${{ steps.ecr.outputs.registry }}/${{ env.REPOSITORY }}:${{ inputs.version }} + build-args: | + BUILD_HASH=${{ github.sha }} + USE_PERMISSION_HARDENING=false + + - name: Show pushed image + run: | + aws ecr describe-images \ + --repository-name "$REPOSITORY" \ + --region "$AWS_REGION" \ + --image-ids imageTag="${{ inputs.version }}" \ + --query 'imageDetails[0].{Digest:imageDigest,Tags:imageTags,Pushed:imagePushedAt,SizeBytes:imageSizeInBytes}' \ + --output table + + - name: Next-step reminder + run: | + cat <> "$GITHUB_OUTPUT" - - - name: Ruff check - if: steps.changed.outputs.files != '' - uses: astral-sh/ruff-action@v3 - with: - args: check ${{ steps.changed.outputs.files }} - - - name: Ruff format - if: steps.changed.outputs.files != '' - uses: astral-sh/ruff-action@v3 - with: - args: format --check ${{ steps.changed.outputs.files }} diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 00000000000..226d7dfe738 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,254 @@ +name: Upstream Sync + +on: + schedule: + # Daily at 09:00 UTC (5am ET). Exits cleanly when no new upstream release. + - cron: '0 9 * * *' + workflow_dispatch: + inputs: + target_tag: + description: 'Specific upstream tag to sync to (e.g. v0.9.6). Leave blank to auto-detect the latest v* tag.' + required: false + type: string + default: '' + force: + description: 'Open a PR even if flex already contains the target tag (useful for re-running a failed sync)' + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout flex with full history and tags + uses: actions/checkout@v6 + with: + ref: flex + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git and fetch upstream tags + run: | + git config user.name 'flex-upstream-sync-bot' + git config user.email 'flex-upstream-sync-bot@users.noreply.github.com' + git remote add upstream https://github.com/open-webui/open-webui.git + # --force: this fork's tag history diverges from upstream for some + # legacy tags (e.g. v0.6.40 points to a different commit). We always + # want upstream's tag values when resolving the sync target. + git fetch upstream --tags --prune --force + + - name: Resolve target tag + id: target + env: + INPUT_TAG: ${{ inputs.target_tag }} + run: | + if [ -n "${INPUT_TAG}" ]; then + TARGET="${INPUT_TAG}" + echo "Using explicit target_tag input: ${TARGET}" + else + # Latest upstream release tag, strict semver pattern. Excludes + # pre-releases (v0.9.6-rc1) and oddball tags. + TARGET=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1) + if [ -z "${TARGET}" ]; then + echo "::error::No upstream v*.*.* tags found. Did upstream fetch fail?" + exit 1 + fi + echo "Auto-detected latest upstream tag: ${TARGET}" + fi + echo "target=${TARGET}" >> "$GITHUB_OUTPUT" + + - name: Resolve current base on flex + id: base + run: | + # Most recent tag that's an ancestor of flex. With upstream's tags + # fetched, this is the upstream release flex is currently based on. + if BASE=$(git describe --tags --abbrev=0 flex 2>/dev/null); then + echo "flex's most-recent ancestor tag: ${BASE}" + else + BASE="" + echo "::warning::No tag is an ancestor of flex. First-time sync?" + fi + echo "base=${BASE}" >> "$GITHUB_OUTPUT" + + - name: Decide whether to sync + id: decide + env: + TARGET: ${{ steps.target.outputs.target }} + BASE: ${{ steps.base.outputs.base }} + FORCE: ${{ inputs.force }} + run: | + if [ "${TARGET}" = "${BASE}" ] && [ "${FORCE}" != "true" ]; then + echo "::notice title=Up to date::flex is already on ${TARGET}. Nothing to do." + echo "sync=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # If BASE is set, verify TARGET is actually newer (not older — guard + # against operator-supplied target_tag pointing to a past release). + if [ -n "${BASE}" ] && [ "${FORCE}" != "true" ]; then + if ! git merge-base --is-ancestor "${BASE}" "${TARGET}"; then + echo "::error::Target ${TARGET} is not a descendant of current base ${BASE}. Refusing to sync backward. Pass force=true to override." + exit 1 + fi + fi + + echo "Will sync flex from ${BASE:-} → ${TARGET}" + echo "sync=true" >> "$GITHUB_OUTPUT" + + - name: Create throwaway branch + if: steps.decide.outputs.sync == 'true' + id: branch + env: + TARGET: ${{ steps.target.outputs.target }} + run: | + BRANCH="upstream-sync/${TARGET}-$(date -u +%Y%m%d-%H%M%S)" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + git checkout -b "${BRANCH}" + + - name: Rebase onto target tag with deterministic conflict rules + if: steps.decide.outputs.sync == 'true' + id: rebase + env: + TARGET: ${{ steps.target.outputs.target }} + BASE: ${{ steps.base.outputs.base }} + run: | + set +e + + { + echo "# Upstream sync conflict resolution log" + echo "" + echo "Rebasing \`flex\` from \`${BASE:-}\` onto upstream \`${TARGET}\`." + echo "" + echo "| File | Rule applied | Outcome |" + echo "|------|--------------|---------|" + } > conflict-resolution-log.md + + : > manual-review.txt + + git rebase "${TARGET}" + while [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; do + CONFLICTS=$(git diff --name-only --diff-filter=U) + + if [ -z "$CONFLICTS" ]; then + # Rebase paused but no unmerged paths — likely an empty/applied commit + git rebase --continue 2>/dev/null || git rebase --skip + continue + fi + + for f in $CONFLICTS; do + case "$f" in + *.png|*.ico|*.wasm) + git checkout --ours -- "$f" + git add "$f" + echo "| \`$f\` | binary → ours | resolved |" >> conflict-resolution-log.md + ;; + package-lock.json|*/package-lock.json|uv.lock|*/uv.lock) + git checkout --theirs -- "$f" + git add "$f" + echo "| \`$f\` | lockfile → theirs | resolved |" >> conflict-resolution-log.md + ;; + functions/*|static/static/providers/*|README_FLEXION.md) + git checkout --ours -- "$f" + git add "$f" + echo "| \`$f\` | flexion-unique → ours | resolved |" >> conflict-resolution-log.md + ;; + *) + # Shared source — leave conflict markers in the file content, + # but `git add` so the rebase can continue. The committed file + # will contain <<<<<<< markers for the human to resolve in the PR. + git add "$f" + echo "| \`$f\` | shared source — markers preserved | **manual review required** |" >> conflict-resolution-log.md + echo "$f" >> manual-review.txt + ;; + esac + done + + git rebase --continue 2>/dev/null + REBASE_STATUS=$? + if [ $REBASE_STATUS -ne 0 ] && [ ! -d .git/rebase-merge ] && [ ! -d .git/rebase-apply ]; then + echo "::error::Rebase aborted unexpectedly after attempting to continue." + exit 1 + fi + done + set -e + + if [ -s manual-review.txt ]; then + sort -u manual-review.txt -o manual-review.txt + MANUAL_COUNT=$(wc -l < manual-review.txt | tr -d ' ') + echo "manual_count=${MANUAL_COUNT}" >> "$GITHUB_OUTPUT" + else + echo "manual_count=0" >> "$GITHUB_OUTPUT" + fi + + - name: Push throwaway branch and open PR + if: steps.decide.outputs.sync == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.branch.outputs.branch }} + MANUAL_COUNT: ${{ steps.rebase.outputs.manual_count }} + TARGET: ${{ steps.target.outputs.target }} + BASE: ${{ steps.base.outputs.base }} + run: | + git push -u origin "$BRANCH" + + BODY_FILE=$(mktemp) + cat conflict-resolution-log.md > "$BODY_FILE" + + { + echo "" + echo "## Release" + echo "" + echo "Syncing \`flex\` from **${BASE:-}** to **${TARGET}**." + echo "" + } >> "$BODY_FILE" + + if [ "${MANUAL_COUNT:-0}" -gt 0 ]; then + { + echo "## HITL review checklist" + echo "" + echo "${MANUAL_COUNT} file(s) require manual resolution. The conflict markers" + echo "(\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) are still in the committed content." + echo "" + echo "- [ ] Check out this branch, resolve markers, push" + echo "- [ ] Run \`npm run build\` locally" + echo "- [ ] Run \`docker build .\` locally" + echo "- [ ] Verify Flexion branding, OAuth flow, and BAG integration still work" + echo "- [ ] Mark this PR ready for review" + echo "- [ ] After merge, publish ${TARGET} to ECR via \`publish-flex-image\` workflow" + echo "" + echo "
Files needing manual review (${MANUAL_COUNT})" + echo "" + echo '```' + cat manual-review.txt + echo '```' + echo "" + echo "
" + } >> "$BODY_FILE" + DRAFT_FLAG="--draft" + else + { + echo "## Clean rebase" + echo "" + echo "All conflicts were resolved by deterministic rules. No manual review needed." + echo "" + echo "- [ ] Local smoke check: \`npm run build\`, \`docker build .\`" + echo "- [ ] Merge" + echo "- [ ] Publish ${TARGET} to ECR via \`publish-flex-image\` workflow" + } >> "$BODY_FILE" + DRAFT_FLAG="" + fi + + gh pr create $DRAFT_FLAG \ + --base flex \ + --head "$BRANCH" \ + --title "chore: upstream-sync flex onto ${TARGET}" \ + --body-file "$BODY_FILE" diff --git a/.opencode/sessions/flex-v0.9.5-upstream-sync.md b/.opencode/sessions/flex-v0.9.5-upstream-sync.md new file mode 100644 index 00000000000..026c88197b0 --- /dev/null +++ b/.opencode/sessions/flex-v0.9.5-upstream-sync.md @@ -0,0 +1,259 @@ +# Session: FlexChat v0.8.10 → v0.9.5 Upstream Sync + Production Deployment + +**Branch**: flex +**Issue**: N/A (maintenance / upstream sync) +**Created**: 2026-05-18 +**Status**: in-progress (production stabilization ongoing) + +--- + +## Goal + +1. Rebase the `flex` branch from upstream open-webui v0.8.10 → v0.9.5 (801 commits) +2. Build and deploy the new image to production (`chat.cloud.flexion.us`) +3. Document the process and create a GitHub Actions pipeline for future automated syncs + +--- + +## Approach + +- **Rebase strategy** (not merge) — matches the established `feat: rebase Flexion customizations onto vX.Y.Z` pattern +- **Throwaway branch** — all rebase work on `flex-rebase-onto-v0.9.5`, never directly on `flex` +- **HITL gate** — draft PR for human review before `flex` is updated +- **CI pipeline** — `workflow_dispatch` only, Bedrock Claude for AI conflict resolution, draft PR output + +--- + +## Session Log + +- **2026-05-18**: Full upstream sync session — rebase, production deployment, incident response + +--- + +## Key Decisions + +### Rebase Strategy +- Rebase (not merge) onto `upstream/main` to maintain clean linear history +- Throwaway branch `flex-rebase-onto-v0.9.5` used — never rebase `flex` directly +- Backup tag `flex-backup-pre-rebase-20260518` created and pushed to origin before any rebase work + +### CI Pipeline Design +- `workflow_dispatch` only (manual trigger) — human always approves before `flex` is updated +- Conflict resolution hierarchy: binary → `--ours` | lock files → `--theirs` | Flexion-unique → `--ours` | shared source → Bedrock Claude +- LLM capped at 10 files; clean rebases still get a draft PR +- OIDC federation for AWS credentials (no static keys) +- `SYNC_PAT` required (not `GITHUB_TOKEN`) — needed to push branches containing `.github/workflows/` + +### OAUTH_ALLOWED_ROLES Decision +- v0.9.5 strictly enforces `OAUTH_ALLOWED_ROLES` — users not in any matching group get `ACCESS_PROHIBITED` +- v0.8.10 silently fell through to `DEFAULT_USER_ROLE=user` when no match found +- No single all-staff Google group exists at Flexion that all employees are in +- **Decision**: Set `ENABLE_OAUTH_ROLE_MANAGEMENT=false` — rely on `OAUTH_ALLOWED_DOMAINS` (flexion.us) for access control instead of group membership + +### WEBUI_SECRET_KEY +- Currently regenerated on every task start (16 bytes — below recommended 32) +- Causes `InvalidToken` errors on OAuth sessions from previous deployments +- **TODO**: Pin to a fixed Secrets Manager secret to survive redeploys + +--- + +## Bugs Found During Testing + +### Bug 1: `FileResponse` not imported in `models.py` +- **File**: `backend/open_webui/routers/models.py` line 35 +- **Symptom**: 500 on every model profile image request (`/api/v1/models/model/profile/image`) +- **Root cause**: `FileResponse` used at line 599 but never imported — `NameError` → 500 +- **Fix**: Added `FileResponse` to `from fastapi.responses import ...` +- **Commit**: `29299e736` + +### Bug 2: `model_recommendation_template` not async +- **Files**: `backend/open_webui/utils/task.py`, `backend/open_webui/routers/tasks.py` +- **Symptom**: `TypeError: Object of type coroutine is not JSON serializable` on model recommendation endpoint +- **Root cause**: `prompt_template()` became `async` in v0.9.5; Flexion's `model_recommendation_template` still called it synchronously +- **Fix**: Made `model_recommendation_template` async, added `await` at call site +- **Commit**: `e156747d1` + +### Bug 3: `STATIC_DIR` not imported in `models.py` +- **File**: `backend/open_webui/routers/models.py` line 40 +- **Symptom**: `NameError: name 'STATIC_DIR' is not defined` on startup → container crash +- **Root cause**: `STATIC_DIR` is no longer a global in v0.9.5 — needs explicit import from `open_webui.config` +- **Fix**: Added `STATIC_DIR` to `from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR` +- **Commit**: `e156747d1` + +### Bug 4: Dockerfile build failure (OrbStack `no_proxy` IPv6 CIDR) +- **File**: `Dockerfile` line 141 +- **Symptom**: Build fails with `httpx.InvalidURL: Invalid port: 'b51a:cc66:f0::'` during model download step +- **Root cause**: OrbStack injects IPv6 CIDR ranges (e.g. `fd07:b51a:cc66::/64`) into `no_proxy`; `httpx` cannot parse CIDR notation as URL patterns +- **Fix**: Strip IPv6 CIDR entries from `no_proxy`/`NO_PROXY` at the top of the failing `RUN` block +- **Commit**: `29299e736` + +--- + +## Production Deployment Incidents (2026-05-18) + +### Incident 1: CDK deploy dropped all OAuth config (rev 27) +- **Cause**: `yarn cdk:deploy:aws:prod` run without sourcing `.env.prod` — all env vars (`GOOGLE_CLIENT_ID`, `OPENAI_API_KEY`, etc.) were absent, so CDK deployed with empty config +- **Symptom**: Google Sign In button disappeared from login page +- **Resolution**: Rolled back to rev 26 via `aws ecs update-service --task-definition :26`, then redeployed with `source .env.prod && yarn cdk:deploy:aws:prod` +- **Prevention**: `deploy:aws:prod` script now includes `--context useEcr=true`; `.env.prod` file created (gitignored) with all required vars + +### Incident 2: `ResourceInitializationError: ecr:GetAuthorizationToken denied` +- **Cause**: CDK deploy without `--context useEcr=true` skips the ECR permission block in the execution role inline policy, removing `ecr:GetAuthorizationToken` +- **Resolution**: Manually attached `arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy` to the execution role; added `--context useEcr=true` permanently to `deploy:aws:prod` script +- **PR**: flexion/flexion-open-webui-infra#458 + +### Incident 3: `EMAIL_TAKEN` error on Google login for existing users +- **Cause**: v0.9.5 moved `oauth_sub` from a dedicated text column to a JSON `oauth` column. Existing users have their sub in the old column; `get_user_by_oauth_sub()` returns null → falls through to signup → finds email exists → throws `EMAIL_TAKEN` +- **Resolution**: Set `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` — falls back to email lookup and migrates sub to new column on first login +- **PR**: flexion/flexion-open-webui-infra#458 + +### Incident 4: UI takes 90+ seconds to load after login +- **Cause**: Gemini pipe function (`functions/pipes/gemini.pipe.py`) reads `GOOGLE_API_KEY` but task definition only had `GOOGLE_GEMINI_API_KEY`. Empty key → Google auth timeout → 30s hang per function × 3 functions = 90s +- **Resolution**: Added `GOOGLE_API_KEY` as a second alias pointing to the same Secrets Manager secret as `GOOGLE_GEMINI_API_KEY` +- **PR**: flexion/flexion-open-webui-infra#458 + +### Incident 5: `ACCESS_PROHIBITED` for non-admin users +- **Cause**: v0.9.5 strictly enforces `OAUTH_ALLOWED_ROLES` — users not in a matching group are denied. `OAUTH_ALLOWED_ROLES` was never set in any historical task definition (v0.8.10 silently fell through). `all-employees@flexion.us` does not exist as a Google group. +- **Resolution**: Created `flexion-delivery-access@flexion.us` Google group; set `OAUTH_ALLOWED_ROLES=flexion-delivery-access@flexion.us`. Add all intended FlexChat users to this group in Google Admin. +- **PR**: flexion/flexion-open-webui-infra#458 + +### Incident 6: lgarceau@flexion.us lost chat history (duplicate user accounts) +- **Cause**: Multiple new user IDs created for `lgarceau@flexion.us` during the deployment chaos window (before `OAUTH_MERGE_ACCOUNTS_BY_EMAIL` was set). Chats belong to original user `03a4a27f`; currently logged in as `e2cd097e` +- **Status**: **UNRESOLVED** — requires DB migration to reassign chats from old user ID to current +- **Other users**: Unaffected — no one else successfully logged in during the broken window + +--- + +## Files Changed (open-webui repo, `flex` branch) + +| File | Change | +|------|--------| +| `backend/open_webui/routers/models.py` | Added `FileResponse` + `STATIC_DIR` imports | +| `backend/open_webui/utils/task.py` | Made `model_recommendation_template` async | +| `backend/open_webui/routers/tasks.py` | Added `await` to `model_recommendation_template` call | +| `Dockerfile` | Strip IPv6 CIDR from `no_proxy` before model download step | +| `README_FLEXION.md` | Expanded "Keeping Up with Upstream" from stub to full runbook | +| `.github/workflows/upstream-sync.yml` | New: GitHub Actions workflow with Bedrock AI conflict resolution | + +## Files Changed (flexion-open-webui-infra repo, PR #458) + +| File | Change | +|------|--------| +| `cdk-infra/lib/constructs/open-webui-service.ts` | Added `OAUTH_MERGE_ACCOUNTS_BY_EMAIL`, `GOOGLE_API_KEY`, `OAUTH_ALLOWED_ROLES`/`ENABLE_OAUTH_ROLE_MANAGEMENT` props | +| `cdk-infra/lib/cdk-infra-stack.ts` | Wired new props | +| `cdk-infra/bin/cdk-infra.ts` | Read new props from env vars | +| `cdk-infra/package.json` | Added `--context useEcr=true` to `deploy:aws:prod` script | +| `.gitignore` | Added `.env.prod`, `.env.dev`, `.env.local`, `.env` | + +--- + +## Upstream Sync Process (Documented) + +### One-time setup +```bash +git remote add upstream https://github.com/open-webui/open-webui.git +``` + +### Manual sync runbook (see README_FLEXION.md for full detail) +```bash +# 1. Fetch and create safety net +git fetch upstream && git fetch origin +git tag flex-backup-pre-rebase-$(date +%Y%m%d) flex +git push origin flex-backup-pre-rebase-$(date +%Y%m%d) + +# 2. Throwaway branch +git checkout -b flex-rebase-onto-vX.Y.Z flex + +# 3. Rebase +git rebase upstream/main + +# 4. Resolve conflicts (binary→--ours, lock→--theirs, flexion-unique→--ours, shared→manual) + +# 5. Verify +git merge-base --is-ancestor upstream/main flex-rebase-onto-vX.Y.Z && echo PASS +git log --oneline flex-rebase-onto-vX.Y.Z ^upstream/main | wc -l # expect N Flexion commits + +# 6. Push + draft PR +git push --force-with-lease origin flex-rebase-onto-vX.Y.Z +gh pr create --draft --base flex --head flex-rebase-onto-vX.Y.Z + +# 7. After review: update flex +git checkout flex +git push --force-with-lease origin flex-rebase-onto-vX.Y.Z:flex + +# 8. Update origin/main +git checkout main && git merge --ff-only upstream/main && git push origin main + +# 9. Cleanup +git branch -d flex-rebase-onto-vX.Y.Z +git push origin --delete flex-rebase-onto-vX.Y.Z +``` + +### Automated CI sync +- Workflow: `.github/workflows/upstream-sync.yml` +- Trigger: Actions → Upstream Sync → Run workflow +- Default: `dry_run: true` (safe to check drift without side effects) +- Required secrets: `SYNC_PAT`, `AWS_BEDROCK_ROLE_ARN`, `AWS_REGION` + +### Deploy after sync +```bash +# 1. Build and push image +cd open-webui +aws ecr get-login-password --region us-east-2 | \ + docker login --username AWS --password-stdin 380270640373.dkr.ecr.us-east-2.amazonaws.com +docker build -t 380270640373.dkr.ecr.us-east-2.amazonaws.com/open-webui-prod:latest . +docker push 380270640373.dkr.ecr.us-east-2.amazonaws.com/open-webui-prod:latest + +# 2. Deploy CDK (always source .env.prod first) +cd flexion-open-webui-infra +source .env.prod && yarn cdk:deploy:aws:prod +``` + +--- + +## Key Takeaways for Future Syncs + +### v0.9.5 Breaking Changes (Flexion-specific) +1. **`prompt_template()` is now async** — any Flexion utility that calls it must be `async` and use `await` +2. **`STATIC_DIR` is no longer a global** — must be explicitly imported from `open_webui.config` +3. **`OAUTH_ALLOWED_ROLES` is strictly enforced** — must be set or `ENABLE_OAUTH_ROLE_MANAGEMENT=false` +4. **`oauth_sub` moved to JSON column** — `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` required for existing users +5. **`FileResponse` must be explicitly imported** — not re-exported from fastapi.responses automatically + +### CDK Deploy Checklist (ALWAYS do before deploying) +```bash +# Must source .env.prod — CDK reads ALL config from env vars at synth time +source .env.prod && yarn cdk:deploy:aws:prod +``` + +### Things to Fix Before Next Sync +- [ ] Pin `WEBUI_SECRET_KEY` to a fixed Secrets Manager secret (currently regenerated each deploy → invalidates all sessions) +- [ ] Fix `lgarceau@flexion.us` duplicate user accounts (DB migration needed) +- [ ] Rotate Gemini API key (`AIzaSyAGSYHw7CIrizlaOEN0Mx0E8QLaEZup-5Y` is invalid) +- [ ] Enable ECS Exec on the service for future DB debugging +- [ ] Tag ECR images with version + date (not just `latest`) for easier rollback + +### Flexion Customization Inventory (conflict-risk files) +| File | Purpose | v0.9.5 Risk | +|------|---------|-------------| +| `backend/open_webui/utils/oauth.py` | Google Groups OAuth | High — auth infrastructure changes frequently | +| `backend/open_webui/routers/models.py` | Provider icons + model routing | Medium | +| `backend/open_webui/constants.py` | `TASKS.MODEL_RECOMMENDATION` enum | Low | +| `backend/open_webui/routers/tasks.py` | Model recommendation endpoint | Medium — task routing changed | +| `backend/open_webui/utils/task.py` | `model_recommendation_template()` | Low — but must stay async | +| `src/lib/components/chat/Navbar.svelte` | Flexion navbar | Medium | +| `src/lib/components/chat/Placeholder.svelte` | Flexion UI tweak | Low | +| `src/lib/apis/index.ts` | Flexion API additions | Medium | +| `functions/` (5 files) | Custom Flexion functions | None — Flexion-only | +| `static/static/providers/` (17 files) | Provider icons | None — Flexion-only | + +--- + +## Next Steps + +- [ ] Fix `ENABLE_OAUTH_ROLE_MANAGEMENT=false` — redeploy infra (PR #458) +- [ ] Fix `lgarceau@flexion.us` duplicate user — DB migration via one-off ECS task +- [ ] Pin `WEBUI_SECRET_KEY` in Secrets Manager +- [ ] Rotate Gemini API key +- [ ] Merge PR #458 once approved +- [ ] Kill the one-off diagnostic ECS task (`46cab7e6`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 250d70cc069..95d97dcea0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,585 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.5] - 2026-05-09 + +### Added + +- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491) +- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454) +- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635) +- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981) +- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats. + +### Fixed + +- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486) +- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494) +- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493) +- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508) +- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524) +- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524) +- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513) +- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506) +- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521) +- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518) +- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525) +- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534) +- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e) +- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9) +- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950) +- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a) +- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f) +- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503) + +### Changed + +- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497) +- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review. + +## [0.9.4] - 2026-05-09 + +### Fixed + +- 📜 **Chat scroll position on load.** Opening a chat conversation now reliably scrolls to the bottom of the message history, fixing a regression caused by `content-visibility: auto` where estimated element sizes prevented the initial scroll from reaching the true bottom. + +## [0.9.3] - 2026-05-09 + +### Added + +- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832) +- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258) +- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159) +- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329) +- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133) +- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840) +- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc) +- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384) +- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103) +- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be) +- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416) +- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462) +- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4) +- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420) +- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164) +- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216) +- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836) +- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166) +- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120) +- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425) +- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27) +- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244) +- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013) +- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412) +- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries. +- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context. +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded. + +### Fixed + +- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089) +- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162) +- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092) +- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176) +- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105) +- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264) +- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92) +- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164) +- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135) +- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424) +- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088) +- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2) +- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237) +- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376) +- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254) +- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810) +- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088) +- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370) +- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999) +- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297) +- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258) +- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391) +- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243) +- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c) +- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c) +- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263) +- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142) +- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090) +- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143) +- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152) +- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138) +- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149) +- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137) +- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169) +- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198) +- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125) +- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332) +- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa) +- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420) +- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468) +- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433) +- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319) +- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346) +- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377) +- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188) +- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166) +- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a) +- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be) +- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241) +- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096) +- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270) +- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897) +- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411) +- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844) +- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use. +- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components. +- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early. + +### Changed + +- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility. +- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420) + +## [0.9.2] - 2026-04-24 + +### Added + +- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [#23945](https://github.com/open-webui/open-webui/pull/23945) +- 🔥 **Firecrawl v2 API.** Firecrawl web loading now uses the v2 API directly with proper retry logic, exponential backoff on rate limits, and configurable timeout handling, improving reliability for both cloud and self-hosted Firecrawl setups. [#23934](https://github.com/open-webui/open-webui/pull/23934) +- ⏰ **Calendar event reminder customization.** Calendar events now support a configurable `reminder_minutes` parameter, allowing models to set custom reminder durations instead of the default 10-minute notification. +- 🔑 **Custom API key header.** Administrators can now configure a custom header name for API key authentication via the `CUSTOM_API_KEY_HEADER` environment variable, enabling compatibility with reverse proxies that use the `Authorization` header for their own authentication. +- 🔌 **OAuth session disconnection.** Users can now disconnect OAuth sessions for specific providers (e.g., MCP connections) through a new API endpoint, enabling cleaner re-authentication workflows. +- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918) +- ⚡ **Model list performance.** Model list API responses now strip base64 profile image data from paginated results, and model tags are fetched via a dedicated efficient query instead of loading all models. This significantly reduces payload sizes and improves workspace Models page responsiveness. +- ⚡ **Model avatar cache reuse.** Default model profile images now redirect to a shared static path instead of reading files from disk per-request, reducing repeated I/O and improving loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015) +- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load with preload links, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011) +- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048) +- 📊 **Telemetry gauge reliability.** OpenTelemetry user gauge callbacks now use synchronous database queries directly, eliminating cross-thread async bridging issues that could cause silent failures in metric collection. +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded. + +### Fixed + +- 🔧 **MCP task cancellation stability.** Interrupted MCP tool calls no longer cause CPU spikes or runaway cleanup behavior. MCP client disconnection now runs in the same asyncio task as connection, respecting cancel scope constraints, and chat-active events are properly shielded during cancellation. +- 🧠 **Persistent chat skill injection.** Skills mentioned in persisted chats now inject into the system prompt reliably. Skill ID extraction from `<$skillId|label>` message tags is now handled server-side, and tags are stripped before messages reach the model. +- 🗄️ **Async database driver migration.** The async database backend now uses psycopg (v3) instead of asyncpg, eliminating brittle SSL parameter translation and supporting native libpq connection strings including `sslmode`, `options`, and `target_session_attrs` without any stripping or conversion. +- 🐳 **Docker ARM64 reliability.** Docker images built for arm64 via QEMU cross-compilation no longer produce 0-byte corrupted Python dependencies. `UV_LINK_MODE=copy` is now set in the Dockerfile to force reliable file installation. +- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979) +- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009) +- 🔇 **Fetch URL null content guard.** The `fetch_url` built-in tool now safely handles `None` content returned by web loaders instead of crashing with a `TypeError`. +- 🌐 **OAuth discovery fallback.** OAuth protected resource discovery now falls back to well-known RFC 9728 URIs when the `WWW-Authenticate` header doesn't contain a `resource_metadata` link, improving compatibility with more MCP server implementations. +- 🔐 **Session token resolution.** Session user endpoints now gracefully handle missing `Authorization` headers by falling back to cookie and request state tokens, preventing errors when used behind forward-auth proxies. +- 🚫 **Direct API error responses.** Chat completion requests without a WebSocket channel (direct API calls) now return proper HTTP error responses instead of silently returning null on failure. +- 📡 **Cancelled response stream cleanup.** Cancelled chat generation now explicitly closes the upstream response body iterator, preventing orphaned async generators from spinning in anyio internals. +- 🔒 **Model profile image path safety.** Model profile image endpoints now validate and sanitize static asset redirect paths, preventing path traversal through encoded dots or malicious URL patterns. +- 📊 **RAG template validation UI.** The Documents settings page now displays a warning when RAG templates contain multiple `[context]` or `{{CONTEXT}}` placeholders, helping administrators avoid accidental redundant context injection. +- 🧩 **Automation model detection.** The `create_automation` tool now correctly detects the current model ID even when `model_id` is not yet set in metadata, falling back to the model dict. +- 🔄 **MCP resource content handling.** MCP tool results with the `resource` content type are now correctly detected and their `resource.text` payload is extracted, instead of being silently ignored. +- 🔄 **Ollama and OpenAI metadata forwarding.** Ollama and OpenAI proxy routes now forward request metadata to downstream handlers, ensuring consistent context propagation. +- 🧹 **Browser-native message virtualization.** The custom JavaScript-based message culling system (spacers, height caching, scroll listeners) was replaced with CSS `content-visibility: auto`, letting the browser natively skip rendering of off-screen messages without destroying component trees. This eliminates scroll jump artifacts and mount/destroy thrashing while preserving memory efficiency in long conversations. +- 📻 **Redis notification compatibility.** Redis pub/sub now handles missing or incompatible `client_name` support more gracefully, preventing connection errors with certain Redis configurations. + +### Changed + +- ⚙️ **psycopg v3 async driver.** The async database driver has been migrated from `asyncpg` to `psycopg` (v3). This is a transparent change for most deployments, but custom connection strings with `asyncpg`-specific parameters may need adjustment. +- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176. +- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility. + +## [0.9.1] - 2026-04-21 + +### Fixed + +- 🐛 **Missing `aiosqlite` dependency.** Fixed a startup crash (`ModuleNotFoundError: No module named 'aiosqlite'`) when installing Open WebUI via `pip` or `uv` by adding the missing `aiosqlite` package to `pyproject.toml`. The dependency was listed in `requirements.txt` but not in the published package metadata, so it was not installed automatically. [#23916](https://github.com/open-webui/open-webui/issues/23916) +- 🐛 **Missing `asyncpg` dependency.** Added the missing `asyncpg` package to `pyproject.toml` to prevent the same startup crash for PostgreSQL users. Like `aiosqlite`, it was present in `requirements.txt` but absent from the published package dependencies. + +## [0.9.0] - 2026-04-20 + +### Added + +- 🖥️ **Official Open WebUI Desktop App.** Open WebUI is now available as a native desktop app for Mac, Windows, and Linux. No Docker, no terminal, no setup. Runs Open WebUI locally without any server setup, or connects to your existing remote Open WebUI instances. Switch between multiple servers instantly from the sidebar. Comes with a system-wide floating chat bar (Shift+Cmd+I on macOS, Shift+Ctrl+I on Windows/Linux), system-wide push-to-talk, offline support after first launch, automatic updates, and zero telemetry. [#8262](https://github.com/open-webui/open-webui/issues/8262), [Desktop](https://github.com/open-webui/desktop) +- 🤖 **Scheduled chat automations.** You can now schedule the AI to run tasks automatically on a recurring basis: daily digests, periodic reports, anything you'd otherwise need to remember to ask for. Create and manage automations from the Automations page or directly in chat, with full run history and manual trigger controls. [#23303](https://github.com/open-webui/open-webui/pull/23303), [Commit](https://github.com/open-webui/open-webui/commit/5a2ff8b2e5b6f55a20f7ed491f818490eb535ea7), [Commit](https://github.com/open-webui/open-webui/commit/d30a0531d4add045c21a2368d6321a9b1906865f), [Commit](https://github.com/open-webui/open-webui/commit/bae5ff938ac88a3a647cc31ca8db1101015ae18b), [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2) +- 🧰 **Automation tools in chat.** Built-in chat tools can now create, update, list, pause, and delete scheduled automations directly in conversation when automation access is enabled. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2) +- ⏱️ **Automation scheduling limits.** Administrators can now set "AUTOMATION_MAX_COUNT" and "AUTOMATION_MIN_INTERVAL" to limit how many automations each non-admin user can create and prevent overly frequent schedules that could overload the system. [Commit](https://github.com/open-webui/open-webui/commit/406251c2f358ffabce4d631c98c6f2c879feae5c) +- 📋 **Task management tool.** AI models can now create, update, and track tasks within a chat conversation, breaking down complex requests into manageable steps with real-time status updates. [Commit](https://github.com/open-webui/open-webui/commit/bcb71bb5206ac01d97a39fde8ecf0e0541dde636) +- 🗓️ **Calendar workspace and event management.** Open WebUI now has a full Calendar workspace. Create and manage events, set up recurring schedules, get reminders via in-app toasts or browser notifications, and see your scheduled automations alongside your calendar. [#23880](https://github.com/open-webui/open-webui/pull/23880) +- 🔔 **Calendar reminders and alerts.** Calendar events now support reminder options from no alert up to one hour before start time, with upcoming alerts delivered through in-app toasts, browser notifications, and optional webhooks while avoiding duplicate sends. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be) +- ⚙️ **Scheduler reminder configuration.** Administrators can now configure calendar reminder processing with "SCHEDULER_POLL_INTERVAL" and "CALENDAR_ALERT_LOOKAHEAD_MINUTES", while existing "AUTOMATION_POLL_INTERVAL" setups continue to work as a legacy fallback. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be) +- ☁️ **Azure responses support.** Azure OpenAI connections now support the newer "/openai/v1" format, enabling chat, responses, and proxy calls to work correctly with that endpoint style. [#23484](https://github.com/open-webui/open-webui/pull/23484) +- 🤖 **Ollama responses support.** The Ollama proxy now supports the Responses API, letting clients use "/v1/responses" directly with Ollama-hosted models through Open WebUI. [#23483](https://github.com/open-webui/open-webui/pull/23483) +- 🧩 **Responses tool output rendering.** Built-in tool outputs in Responses API flows now render more consistently so downstream chat output is easier to interpret. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23482](https://github.com/open-webui/open-webui/pull/23482) +- 🔎 **Responses citation visibility.** Responses API flows now emit citation sources more consistently, making linked references easier to preserve and display in chat output. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23774](https://github.com/open-webui/open-webui/issues/23774) +- 📎 **Attach previously uploaded files.** The chat input menu now includes a Files tab for browsing and attaching previously uploaded files, eliminating the need to re-upload files you have already shared. [Commit](https://github.com/open-webui/open-webui/commit/edb8971c7dbd974322c3207c4655ff66479c3ee2) +- 🧷 **Default model terminal selection.** Workspace model editors can now preselect an Open Terminal connection, so new chats automatically start with the model’s configured terminal ready to use. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d), [#23605](https://github.com/open-webui/open-webui/issues/23605) +- 🎙️ **Mistral TTS support.** Mistral can now be used as a text-to-speech provider, with admin settings for the API key, base URL, voices, and model selection. [Commit](https://github.com/open-webui/open-webui/commit/4cee67e2be0c80a0b501073ea49a80d13efd1c41) +- 🎧 **STT preprocessing bypass option.** Administrators can now enable "AUDIO_STT_SKIP_PREPROCESSING" to send audio files directly to the speech-to-text backend, reducing memory and CPU consumption during large uploads for better transcription performance and stability on constrained deployments. [#23661](https://github.com/open-webui/open-webui/pull/23661) +- 🗑️ **Admin model deletion.** Administrators can now delete Ollama models directly from the model selector menu, making it easier to clean up unused or unwanted models. [Commit](https://github.com/open-webui/open-webui/commit/2388dd7dc3530b5dd5419c5d0bb1bcdcb7544099) +- 🔌 **Backend outlet filters for local and persisted chats.** Pipeline and function outlet filters now run reliably in backend completion flows for persisted chats and temporary local chats. [#3237](https://github.com/open-webui/open-webui/issues/3237), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67) +- 🎨 **Emoji shortcode support.** Typing a colon in the chat input now opens an emoji suggestion menu, making it easier to insert emojis using shortcodes like :wave:. [Commit](https://github.com/open-webui/open-webui/commit/2040095050056d01c61aa597c5010445449a42c7) +- 📌 **Recently used emojis.** The emoji picker now shows your most recently used emojis at the top, making it faster to find emojis you use often. [Commit](https://github.com/open-webui/open-webui/commit/64da99a32218171d41b3af5acc14783de8dbdf49) +- 👆 **Swipe to reply on mobile.** Swiping right on a message now triggers a reply, making it easier to respond on touch devices with a natural gesture. [Commit](https://github.com/open-webui/open-webui/commit/012ce95f27d57bea8911bd63bfb923443c5797ae) +- 📱 **Screen-awake voice recording.** Voice recording now keeps the screen awake during active dictation and safely re-acquires wake lock after visibility changes, helping prevent long transcriptions from being cut off on mobile devices. [#23145](https://github.com/open-webui/open-webui/issues/23145) +- 🔔 **Unread chat indicators.** Sidebar chats now show unread status and are marked as read when opened, making it easier to spot conversations with new activity. [Commit](https://github.com/open-webui/open-webui/commit/0638b9f56ce1ba8a496d0e84da2e7fa178b01a3f) +- 🔌 **WebSocket reconnect status feedback.** Open WebUI now warns when the real-time connection drops and confirms when it reconnects, while avoiding a reconnect message on the initial page load. [Commit](https://github.com/open-webui/open-webui/commit/1824e69a70e756cfcf543a9fbe4b0780d9b57292) +- 📍 **Pinned notes in sidebar.** Notes can now be pinned to the sidebar for quick access, and you can also create a new note directly from the pinned notes section. [Commit](https://github.com/open-webui/open-webui/commit/ecd74f220c7dd671d5705189a3f4493a3868c8bf), [Commit](https://github.com/open-webui/open-webui/commit/f1be85d997439b49fc143d2bcd2dc710f44446c8) +- 🗂️ **Model selector focus.** The model selector now resets its search only when it opens, making the popup feel more predictable while still focusing the search field automatically. [Commit](https://github.com/open-webui/open-webui/commit/b89019a8e1f96e01dc8e19a81ef8fb4f4eae3eef) +- 🗂️ **Model selector layout.** The model selector now behaves more predictably as a custom popup, and the completions playground uses a simpler model picker for easier selection. [Commit](https://github.com/open-webui/open-webui/commit/c40ea7f29d34fa9535cdf9ffe599f4429ff3f455) +- 🎚️ **Active filter valve shortcut.** Active filter badges now expose valve configuration directly in the chat input area, so filter tuning is faster during conversations. [Commit](https://github.com/open-webui/open-webui/commit/3c22afc5a67404047797921185aca984b10b45cd), [#23811](https://github.com/open-webui/open-webui/issues/23811), [#23813](https://github.com/open-webui/open-webui/pull/23813) +- 🎨 **Theme updates.** Other windows can now update the app theme directly, keeping the interface in sync when theme changes are triggered externally. [Commit](https://github.com/open-webui/open-webui/commit/9f1b279e88bd22dfff4d2531209536dea6a2f65e) +- 🚀 **Async performance and responsiveness improvements.** The core backend database and request paths now run asynchronously across the application, massively improving responsiveness and performance under concurrent load and reducing request blocking during heavy activity. [Commit](https://github.com/open-webui/open-webui/commit/27169124f220e5cea21c88601c731c3749496ab0), [Commit](https://github.com/open-webui/open-webui/commit/8936721414a17832852a90f3ee592af5a8b7232d) +- ⚡ **Drawer performance and memory optimization.** Drawer interactions now stay smoother over long sessions by removing stale keyboard listeners on teardown, which reduces memory growth and avoids accumulated event handling overhead. [#23724](https://github.com/open-webui/open-webui/pull/23724#issuecomment-4245840810) +- 🚀 **Chat history memory culling.** Long conversations now stay responsive no matter how many messages they contain. Off-screen messages are unloaded automatically and reloaded as you scroll, keeping memory usage low and the UI smooth on both desktop and mobile. [#23067](https://github.com/open-webui/open-webui/issues/23067), [Commit](https://github.com/open-webui/open-webui/commit/026903399be73ac4b6c226647110e5662d043a50), [Commit](https://github.com/open-webui/open-webui/commit/9dccd29c94875e6f0ac373c5802cb183296e47ff) +- 🧵 **Async file and knowledge processing performance.** File processing, knowledge reindexing, and channel message helper paths now consistently await async operations, preventing skipped processing steps and improving reliability and performance of indexing and tool responses. [Commit](https://github.com/open-webui/open-webui/commit/de27a121511a31606f250ba4033490797216a0eb) +- 🚀 **Persistent chat payload efficiency.** Persisted chats now use server-side history loading instead of repeatedly resending full message payloads, improving multimodal performance and reducing stale-history overwrite risk across devices. [#19064](https://github.com/open-webui/open-webui/issues/19064), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67) +- 🧵 **Non-blocking file storage operations.** Uploading, reading, transcribing, and deleting files now offloads storage I/O to background threads, keeping the application responsive during file-heavy workflows. [Commit](https://github.com/open-webui/open-webui/commit/4866bec0f238198a721c952fe18dd04ba643be33) +- 🏎️ **Streaming response performance.** Streaming responses now process each output line in a single step instead of two separate yields, reducing async overhead and improving responsiveness during long-running generations. [#23266](https://github.com/open-webui/open-webui/pull/23266) +- 🔎 **Faster mention parsing.** Chat text with HTML-like content, file paths, or tool output now parses mentions more efficiently, which helps keep typing and rendering responsive in messages that contain many '<' characters. [#23551](https://github.com/open-webui/open-webui/pull/23551) +- 🧪 **Code block rendering performance.** Code blocks now reuse a shared HTML unescape helper, reducing extra browser work when displaying encoded output in chat. [#23553](https://github.com/open-webui/open-webui/pull/23553) +- 🚀 **Inline code rendering performance.** Inline code tokens in streaming responses now fade in with a lightweight CSS animation, making chat output feel smoother while reducing interface overhead during rapid token updates. [#23258](https://github.com/open-webui/open-webui/pull/23258) +- 🎞️ **Streaming text token animation performance.** Streaming text tokens now use a lightweight CSS intro animation, making output feel smoother while reducing transition overhead and preventing tokens from fading out when generation completes. [#23257](https://github.com/open-webui/open-webui/pull/23257) +- 🎯 **Template token scan optimization.** Streaming responses now skip unnecessary token-replacement processing when no template markers are present, reducing per-update overhead and keeping chat output smoother during rapid generation. [#23161](https://github.com/open-webui/open-webui/pull/23161) +- 🔬 **Chinese text processing guard performance.** Streaming responses without Chinese characters now skip unnecessary Chinese-format processing checks, reducing per-update overhead and keeping output smoother during rapid generation. [#23162](https://github.com/open-webui/open-webui/pull/23162) +- 🧠 **HTML entity decode performance.** Streaming text decoding now avoids repeated document parsing for HTML entity handling, reducing memory churn and improving responsiveness in token-heavy chat output. [#23165](https://github.com/open-webui/open-webui/pull/23165) +- 🏷️ **Chat title update performance.** Chat title updates now run in a single database operation instead of multiple round trips, improving responsiveness and reducing overhead when titles are generated or renamed. [#23214](https://github.com/open-webui/open-webui/pull/23214) +- 📂 **Faster chat list queries performance.** Chat and folder lists now load more efficiently by fetching only the fields needed for sidebar views, improving responsiveness when browsing large conversation histories. [Commit](https://github.com/open-webui/open-webui/commit/0e5696de74cc0ba55b24cfc3d02efa83f08d7d3f) +- 📈 **Sidebar memory optimization.** Sidebar chat items now use shared drag-preview resources and safer listener cleanup, reducing memory growth and keeping large chat lists more responsive during long sessions. [#23209](https://github.com/open-webui/open-webui/pull/23209) +- 🧠 **Image viewer memory optimization.** Viewing images and SVGs now uses significantly less memory and performs faster, keeping the application snappy and responsive even when browsing through many media files during extended sessions. [#23236](https://github.com/open-webui/open-webui/pull/23236) +- 📡 **Optimized user activity tracking performance.** User activity updates now use a single database query instead of multiple operations, improving response times across all authenticated requests. [#23215](https://github.com/open-webui/open-webui/pull/23215) +- 👥 **Faster channel thread author loading.** Channel thread responses now load author details in a single batch query, reducing database overhead and improving responsiveness in threads with many participants. [#23795](https://github.com/open-webui/open-webui/pull/23795) +- 💨 **Optimized shared chat deletion.** Deleting shared chats by user is now faster and more memory-efficient by only loading necessary data. [#23216](https://github.com/open-webui/open-webui/pull/23216) +- 🗃️ **Faster chat tag loading.** Chat tag lookups now load only the metadata needed instead of full chat payloads, improving responsiveness for chats with large histories. [#23798](https://github.com/open-webui/open-webui/pull/23798) +- 📎 **Faster chat file deduplication.** Attaching files to chat messages now checks duplicates more efficiently, reducing overhead when handling larger file lists. [#23800](https://github.com/open-webui/open-webui/pull/23800) +- 📈 **Faster message diff checks.** Chat message and status updates now compare content more efficiently during streaming, making active conversations feel smoother and more responsive. [#23370](https://github.com/open-webui/open-webui/pull/23370) +- ⚖️ **Faster deep equality checks.** Chat message updates, model selection, note editing, code block refreshes, and rich text state comparisons now use deep equality checks that reduce unnecessary UI work and improve responsiveness in active sessions. [#23845](https://github.com/open-webui/open-webui/pull/23845) +- 🏃 **Faster knowledge access updates.** Updating access grants for knowledge items now completes with less backend overhead, making permission changes apply more quickly. [#23799](https://github.com/open-webui/open-webui/pull/23799) +- 🧹 **Mermaid render cleanup performance.** Mermaid diagrams now always clean up temporary render elements after failures, reducing DOM buildup and keeping repeated rendering more stable over time. [#23727](https://github.com/open-webui/open-webui/pull/23727) +- 🖼️ **Model image lookup efficiency.** Model profile image requests now reuse the current request database session, reducing per-request overhead and improving response efficiency. [#23796](https://github.com/open-webui/open-webui/pull/23796) +- 👤 **User endpoint query reduction.** Session-based user settings and status endpoints now avoid redundant user re-fetches, reducing unnecessary database load while preserving behavior. [#23794](https://github.com/open-webui/open-webui/pull/23794) +- 🚦 **Faster startup performance.** Open WebUI now checks for Torch MPS support only on macOS, avoiding unnecessary startup work on other platforms. [#23438](https://github.com/open-webui/open-webui/pull/23438) +- 🛡️ **Redis timeout consistency.** Redis connections now honor the "REDIS_SOCKET_CONNECT_TIMEOUT" setting across standard and cluster setups, helping workers fail faster when Redis is unreachable. [#23572](https://github.com/open-webui/open-webui/pull/23572) +- 🧰 **AIOHTTP pool controls.** Administrators can now tune shared outbound HTTP connection behavior with "AIOHTTP_POOL_CONNECTIONS", "AIOHTTP_POOL_CONNECTIONS_PER_HOST", and "AIOHTTP_POOL_DNS_TTL" for better control under high concurrency. [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135) +- ⏱️ **MCP tool server timeout configuration.** Administrators can now configure request timeouts for MCP tool server connections via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable. [Commit](https://github.com/open-webui/open-webui/commit/10b4b86ada93cd62d994c3179ff14dfd1a6e56f0) +- 🎫 **Static OAuth tool authentication.** Tool server authentication now works reliably for both "oauth_2.1" and "oauth_2.1_static" connection types, so OAuth-backed tool access is correctly detected and forwarded during chat requests. [Commit](https://github.com/open-webui/open-webui/commit/60676bfdcfbce1a69b3e97f2013f0cfd63371737) +- 🗄️ **Configurable storage local cache.** Administrators can now disable persistent local caching for cloud-backed uploads with the "STORAGE_LOCAL_CACHE" setting, reducing local disk usage by cleaning temporary upload copies after processing. [Commit](https://github.com/open-webui/open-webui/commit/8172c7e3d56918d1372be06b9369b58a3a88f6b1) +- 🚪 **Back-channel logout.** OpenID Connect providers can now trigger centralized logout through the "ENABLE_OAUTH_BACKCHANNEL_LOGOUT" setting, helping administrators invalidate user sessions more reliably across connected devices. [Commit](https://github.com/open-webui/open-webui/commit/0dd9f462ffb2f160bc4aebad182047f41874d250) +- 🛡️ **Expanded security header controls.** Administrators can now configure additional browser security headers, including "CONTENT_SECURITY_POLICY_REPORT_ONLY", "CROSS_ORIGIN_EMBEDDER_POLICY", "CROSS_ORIGIN_OPENER_POLICY", and "CROSS_ORIGIN_RESOURCE_POLICY", for stricter and more flexible deployment hardening. [Commit](https://github.com/open-webui/open-webui/commit/f246a66810fa4995d9494da3599c0fb297fb0213) +- 🖼️ **Image MIME fallback option.** Administrators can now enable "ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK" so image-to-base64 conversion can still detect common image types by file extension when MIME metadata is missing, improving compatibility on minimal container images and older file records. [Commit](https://github.com/open-webui/open-webui/commit/5127354b3eb4eaa71bc4ad68da69729e2196e7a4) +- 🛡️ **Public sharing permissions.** Public channels, models, notes, prompts, and tools now respect allowed access grants more consistently, helping administrators control who can share content more safely. [Commit](https://github.com/open-webui/open-webui/commit/9d3e0637c86292b8b92e7607097a83f1075d7cd8) +- 🆔 **Skill lookup by ID.** Skill instructions now include each skill’s ID, and the skill viewer now finds skills by ID in a case-insensitive way so attached skills are identified more reliably in chats. [Commit](https://github.com/open-webui/open-webui/commit/65ee771fd0d62d785ecbcf189e3f5b63858c11e6) +- 🏷️ **Source context metadata.** Retrieval source context now includes each source’s resource type and resource ID metadata, helping downstream model workflows preserve richer source identity during processing. [Commit](https://github.com/open-webui/open-webui/commit/c3c8c605d76a3b0ee067307f9cef6d081658e287) +- 🗂️ **Feedback filtering.** Administrators can now filter feedback history by model and export only the feedback they need. [Commit](https://github.com/open-webui/open-webui/commit/60e4d7517463690b3a87de38babc9ac561897c61) +- 📤 **CSV feedback export.** Feedback history can now be exported as either JSON or CSV, making it easier to analyze feedback in spreadsheet tools. [Commit](https://github.com/open-webui/open-webui/commit/342582676a5212bf196a69d11825cb407992f257) +- 📝 **Optional GET audit logging.** Administrators can now enable auditing for GET requests with the "ENABLE_AUDIT_GET_REQUESTS" setting when they need fuller request visibility. [Commit](https://github.com/open-webui/open-webui/commit/5ee791d5d28f236755243cb7d16d8737bb69ce36) +- 🕒 **Model access updates.** Changing a model’s access grants now updates its timestamp, so recently modified models stay easier to find and sort correctly. [Commit](https://github.com/open-webui/open-webui/commit/53eadb7df7281f5661cbe22c8b26b5aedaba3083) +- 💬 **Queued message handling.** Queued chat messages now send more reliably without advancing the queue too early, keeping follow-up prompts in the intended order. [Commit](https://github.com/open-webui/open-webui/commit/730e52a431d157dc62d72260668087437f1d52f4) +- 🔒 **Rendered content safety.** Placeholder descriptions and the pending account notice now render markdown with safer sanitization ordering, reducing the risk of unsafe HTML appearing in these views. [Commit](https://github.com/open-webui/open-webui/commit/253f416de3f2d3a939a6feef2a56413fd61cc70b) +- 🛡️ **Safer placeholder rendering.** Chat placeholder descriptions and the pending account notice now sanitize rendered markdown more consistently, reducing the risk of unsafe content being shown in these views. [Commit](https://github.com/open-webui/open-webui/commit/ae0316a30e01a2e5ff3f9d2f9f759c1cd6410f34) +- 🧮 **Usage analytics accuracy.** Token usage is now normalized before chat messages are saved, so model and user usage reports stay accurate across OpenAI-compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/4dea4fdf54e00ebaba8e3178128bf8709453d2a2) +- 🧩 **Richer Anthropic tool results.** Anthropic-compatible tool calls now preserve more tool result content types, including images and structured search or document outputs, so models can use fuller tool context instead of receiving only plain text fragments. [#23188](https://github.com/open-webui/open-webui/issues/23188), [Commit](https://github.com/open-webui/open-webui/commit/40f5b3d135190dc9a2d8e94dbb1b2cbcbd829132) +- 🖼️ **ComfyUI request reliability.** ComfyUI image generation and editing now use shared async connections with consistent SSL handling, making image uploads and workflow runs more reliable under concurrent load. [Commit](https://github.com/open-webui/open-webui/commit/5944eda0ff25a284f7157252683bccede741cbe7) +- 🎛️ **Reranking batch size control.** Administrators can now set "RAG_RERANKING_BATCH_SIZE" in Documents settings to control reranking workload size, helping balance retrieval speed and resource usage for their deployment. [Commit](https://github.com/open-webui/open-webui/commit/4d2f18981051205016bd24d39521e25a33581225) +- 🔗 **Shared chat access controls.** You can now control who has access to a shared chat by granting access to specific users or groups, instead of sharing with anyone who has the link. +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Translations for Irish, Catalan, German, Simplified Chinese, Hindi, and Portuguese (Brazil) were enhanced and expanded. + +### Fixed + +- 🛡️ **Model description XSS protection.** Model descriptions shown in chat placeholders are now sanitized before rendering, preventing malicious links from executing scripts and helping protect user sessions from takeover. [#23621](https://github.com/open-webui/open-webui/pull/23621) +- 🧠 **Memory search filtering.** Memory search now correctly filters by the query text instead of returning unrelated results. [Commit](https://github.com/open-webui/open-webui/commit/43e5905c133049036353978704b0abd179716749), [#23826](https://github.com/open-webui/open-webui/issues/23826) +- 📊 **Shared chat analytics consistency.** Usage and message-count analytics now count assistant activity consistently across regular and shared chats, improving accuracy in model, user, chat, and time-based reporting views. [Commit](https://github.com/open-webui/open-webui/commit/e29d145a1cff23122de16123a4cfda1b84abffbb) +- 🧭 **Safer in-flight chat navigation.** Sending a message no longer overwrites your active chat or causes duplicate background notifications when you switch conversations before a response finishes. [Commit](https://github.com/open-webui/open-webui/commit/dc6df52a917b49fa1264ac81a8cc74603f6155b3) +- 🗣️ **Pipeline error detail visibility.** Pipeline inlet and outlet failures now preserve and surface provider error details more reliably in chat error messages, making troubleshooting failed requests much clearer. [Commit](https://github.com/open-webui/open-webui/commit/d5e69f182cd7a6371ab25248f6432b277f83ef23) +- 📨 **Shared chat event routing.** Message update and send events now target the chat owner’s event channel, so shared chats receive the correct real-time updates instead of routing events to the acting user. [Commit](https://github.com/open-webui/open-webui/commit/47329b5032ba29716a7e7e973b07c6d9894968e0) +- 🔐 **Consistent outbound SSL handling.** External requests for tools, functions, terminals, webhooks, retrieval loaders, audio provider discovery, and OpenAI-compatible embedding calls now consistently apply the configured SSL client setting, improving reliability for deployments that require custom certificate or verification behavior. [Commit](https://github.com/open-webui/open-webui/commit/fd25152076ea7c310e42c9bacc5cd2b544eeae48), [Commit](https://github.com/open-webui/open-webui/commit/56c5bc1d3487020ab886d3332aacc1644c1d6123) +- 🖼️ **Image SSL setting support.** Image generation now respects the configured SSL session setting, preventing avoidable connection failures in strict certificate environments. [Commit](https://github.com/open-webui/open-webui/commit/128cf41fcedf2638fc8a6acd850d8b0409be1c4e), [#23777](https://github.com/open-webui/open-webui/issues/23777) +- 🗂️ **Folder ownership assignment hardening.** Folder create and update inputs now reject unexpected extra fields, preventing clients from overriding protected values like ownership through mass-assignment payloads. [#23648](https://github.com/open-webui/open-webui/pull/23648) +- 🔐 **Knowledge file deletion ownership checks.** Collaborators with knowledge base write access can no longer permanently delete files they do not own, preventing unintended file removal across other linked chats and knowledge bases. [Commit](https://github.com/open-webui/open-webui/commit/914ccf07ef158afe5588b97ed42778c93c439938), [#23636](https://github.com/open-webui/open-webui/pull/23636#issuecomment-4232439454) +- 🗑️ **Knowledge deletion reliability.** Deleting a knowledge base by ID now completes reliably without unexpected failures. [Commit](https://github.com/open-webui/open-webui/commit/7e453de4f7794ff386e285aa5951b94e926ec273), [#23776](https://github.com/open-webui/open-webui/issues/23776), [#23814](https://github.com/open-webui/open-webui/pull/23814) +- 🔐 **OAuth 2.1 PKCE enforcement.** OAuth 2.1 providers now default to S256 PKCE even when discovery metadata omits supported challenge methods, preventing login failures with providers that require PKCE by default. [#23667](https://github.com/open-webui/open-webui/issues/23667), [Commit](https://github.com/open-webui/open-webui/commit/050c4b97a95addc5eaeef86ba00631673a90dec4) +- 🔐 **Static OAuth scope handling.** Static OAuth credential flows now prioritize administrator-defined scopes and handle OAuth 2.1 static flow behavior more reliably. [Commit](https://github.com/open-webui/open-webui/commit/349ea4ea9e577f2cbfb4917ef5f52e5ac53c5b70), [#23668](https://github.com/open-webui/open-webui/issues/23668), [#23696](https://github.com/open-webui/open-webui/pull/23696), [#23783](https://github.com/open-webui/open-webui/pull/23783) +- 🔐 **Static OAuth tool registration reliability.** Static OAuth tool server registration now resolves and uses saved admin credentials more reliably, preventing registration failures when valid client credentials are provided. [#23670](https://github.com/open-webui/open-webui/issues/23670), [Commit](https://github.com/open-webui/open-webui/commit/2943955c529138c0e530fd07b6333a0052e3684e), [Commit](https://github.com/open-webui/open-webui/commit/c767bcaa739f76b1a4337dfd9d6be47adb504825) +- ⏳ **OAuth token expiry fallback.** OAuth sessions now always store a safe expiry value even when providers omit "expires_in" or "expires_at", so token refresh checks continue working and tool calls are less likely to fail later with unexpected authorization errors. [#23669](https://github.com/open-webui/open-webui/issues/23669), [Commit](https://github.com/open-webui/open-webui/commit/31406caa795173a59d5843d3601b891bf617cbaa) +- 🔑 **Anthropic x-api-key model access.** Anthropic-compatible clients can now authenticate with the "x-api-key" header across all relevant API routes, so model listing requests like GET "/api/v1/models" no longer fail with unauthorized errors. [#23319](https://github.com/open-webui/open-webui/issues/23319), [Commit](https://github.com/open-webui/open-webui/commit/611fe0c8a938539b73b559e84964f40c30bf436d) +- 🔑 **SSO password option visibility.** Account settings now hide password change controls when password-change access is disabled, avoiding misleading password options for SSO-focused setups. [#15292](https://github.com/open-webui/open-webui/issues/15292), [Commit](https://github.com/open-webui/open-webui/commit/cced77b584d6ea46c58fecddb2b3dd5e955c8417) +- 🔑 **Open Terminal MCP authentication.** Open Terminal MCP tool calls now include the configured API key when calling internal routes, preventing unauthorized errors for commands like file reads and command execution. [#106](https://github.com/open-webui/open-terminal/pull/106) +- 🧯 **Provider error freeze recovery.** Task-based chat requests now surface provider HTTP errors through normal failure handling, so content-filter and other upstream 4xx responses no longer leave chats stuck in a perpetual loading state. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5) +- 🔄 **Immediate outlet filter updates.** Assistant messages modified by outlet filters now appear correctly as soon as streaming completes, without requiring a page refresh. [#23829](https://github.com/open-webui/open-webui/pull/23829) +- 🌊 **Middleware cancellation reliability.** Long-running requests now complete more reliably by preventing middleware-level cancellations from interrupting in-flight database and embedding work, reducing unexpected failures and noisy error logs when connections close early. [#23709](https://github.com/open-webui/open-webui/pull/23709) +- 🚦 **Async vector search responsiveness.** File processing, memory updates, and knowledge retrieval no longer block the server event loop during vector database operations, so other chats and requests stay responsive while indexing or search is running. [#23706](https://github.com/open-webui/open-webui/pull/23706) +- 🗒️ **Notes chat llama.cpp compatibility.** Notes AI chat no longer sends empty assistant prefill messages that can conflict with reasoning-enabled llama.cpp responses, preventing immediate 400 errors in Notes conversations. [Commit](https://github.com/open-webui/open-webui/commit/fd93bd3414a1725219e14561bc5640b62f9fd4a1), [#23703](https://github.com/open-webui/open-webui/issues/23703#issuecomment-4243907629) +- 🧩 **Ollama thinking field preservation.** Messages modified by filters now keep the Ollama "thinking" field when sent to the model, so reasoning-aware workflows and custom filter-based passthrough setups work reliably. [Commit](https://github.com/open-webui/open-webui/commit/8bd23b91459914eb7df5b5a66567d3544e0da168), [#22508](https://github.com/open-webui/open-webui/issues/22508) +- 🧾 **Reasoning content preservation.** Assistant tool-call messages now retain reasoning content across turns, improving reliability for reasoning-heavy model workflows. [Commit](https://github.com/open-webui/open-webui/commit/3dd8255816898467246c81cba3c9bc48bc18d86d), [#23175](https://github.com/open-webui/open-webui/issues/23175), [#23742](https://github.com/open-webui/open-webui/pull/23742) +- 🧭 **Background task scoping for new chats.** Chat title and auto-tag generation now run only for the first message of a new conversation and only once in multi-model responses, preventing duplicate or incorrectly triggered background tasks in follow-up flows. [Commit](https://github.com/open-webui/open-webui/commit/f102060a6d85db4acd3d0bf5c25e976f36cd5533..a4ed16999eec9a654a37c2bb4c15ba5ecd1fa3b7) +- 📚 **Channel document context retention.** Channel conversations now preserve and load the correct stored message history so model responses can use uploaded and retrieved document context more reliably. [#23686](https://github.com/open-webui/open-webui/issues/23686), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6) +- ⏳ **Interrupted response recovery.** Assistant placeholder messages now start as incomplete and recover more safely after interrupted generations, preventing silent empty replies after refreshes or dropped requests. [#23176](https://github.com/open-webui/open-webui/issues/23176), [Commit](https://github.com/open-webui/open-webui/commit/c8ef7b028931263e8773cb60a7111d80d9572d26), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67) +- 🧰 **Large tool result rendering.** Tool call details now display large result payloads reliably in chat instead of intermittently showing empty output for bigger tool responses. [#18743](https://github.com/open-webui/open-webui/issues/18743), [Commit](https://github.com/open-webui/open-webui/commit/45e49d33e51f7720c00b564215484aff9b48b20c) +- 🧼 **Null-byte document sanitization.** PDF and other document ingests now sanitize null bytes and invalid surrogate characters before pgvector writes, preventing PostgreSQL upload failures and allowing affected files to index successfully. [#22992](https://github.com/open-webui/open-webui/issues/22992), [Commit](https://github.com/open-webui/open-webui/commit/8dba798cce9fb1efc5f6acc5f37b152662db78d7) +- 📝 **Knowledge text editor stability.** The Knowledge "Add Text Content" modal now uses a plain text editor, avoiding current rich text editor issues and keeping drafting behavior consistent with existing knowledge editing flows. [Commit](https://github.com/open-webui/open-webui/commit/cd55c3e21237e000c13c6f396bb95b261f3bda82) +- 🎤 **STT SSL setting consistency.** Speech and related outbound media requests now consistently use shared async HTTP sessions and honor the configured SSL verification setting, improving compatibility with self-signed deployments. [#23672](https://github.com/open-webui/open-webui/issues/23672), [Commit](https://github.com/open-webui/open-webui/commit/2ddcb30b9a519885422ba1f36cc3485a7d897bf8) +- 🎙️ **Mistral speech input format.** Mistral speech-to-text requests now use the correct chat-completions audio input format for better compatibility. [Commit](https://github.com/open-webui/open-webui/commit/34d569d564a8ef2702c647dbad83eac840b76b2e), [#23822](https://github.com/open-webui/open-webui/issues/23822) +- 🖼️ **Optional image size parameter.** Image generation no longer sends the "size" field when no size is configured, improving compatibility with providers that reject unsupported size arguments. [#23611](https://github.com/open-webui/open-webui/issues/23611), [Commit](https://github.com/open-webui/open-webui/commit/869cf9e848b741705dc058550fa1b3f70db47fe8) +- 🔎 **FireCrawl timeout reliability.** FireCrawl web loading now uses direct scrape requests and improved timeout handling for single-URL fetches, reducing empty results and premature timeout failures with local FireCrawl setups. [#23411](https://github.com/open-webui/open-webui/issues/23411), [Commit](https://github.com/open-webui/open-webui/commit/9c64d84ad90804bf7d891e4a5097c03c4d7044c3) +- 🖱️ **Custom action icon drag prevention.** Custom user-added action icons in chat responses are no longer accidentally draggable, so clicks and hover interactions behave consistently with built-in action icons. [#23412](https://github.com/open-webui/open-webui/pull/23412) +- 🖼️ **Image URL conversion reliability.** Sending image URLs to AI models no longer fails with "cannot pickle 'coroutine' object" errors, so image inputs now convert to base64 reliably during request processing. [#23685](https://github.com/open-webui/open-webui/pull/23685#issuecomment-4240424635) +- 📂 **Channel input menu dismissal.** In Workspace Channels, the message input dropdown now closes immediately after selecting "Upload Files" or "Capture", matching normal chat input behavior and preventing the menu from staying open unnecessarily. [#23684](https://github.com/open-webui/open-webui/pull/23684) +- 📋 **Clipboard copy scroll stability.** Copying content with the fallback clipboard method no longer triggers unwanted page scrolling during focus, keeping your current reading position stable. [Commit](https://github.com/open-webui/open-webui/commit/fc98000aa8d439bbff21a70370f5e962bf23f4bc) +- 🖼️ **Profile image URL validation.** Profile saves now accept valid Open WebUI profile-image paths, trusted external HTTP(S) avatar URLs, and safe raster data-image formats while rejecting unsafe URL patterns that could be abused. [#23389](https://github.com/open-webui/open-webui/pull/23389) +- 👤 **Partial user profile updates.** User update API requests can now modify only the fields you provide, so administrators no longer need to resubmit unchanged name, email, and profile image values when changing a single setting like role. [#23424](https://github.com/open-webui/open-webui/issues/23424), [Commit](https://github.com/open-webui/open-webui/commit/3c2c611ba91d794a1e73134ec41b0de2b3927677) +- 🚨 **Provider SSE error visibility.** Provider failures returned with streaming content types are now surfaced as proper API errors and logged clearly, so issues like context-window limits no longer fail silently during chat generation. [#23379](https://github.com/open-webui/open-webui/pull/23379) +- 🧵 **Queued prompt race prevention.** Chat request queues now prevent overlapping processing for the same chat, avoiding duplicate queue handling when multiple queue-processing triggers fire close together. [#23181](https://github.com/open-webui/open-webui/issues/23181), [Commit](https://github.com/open-webui/open-webui/commit/e10a00132eed54a0108fb6ac120e8229deef3656) +- 🛑 **Cancellation event delivery reliability.** Cancelled chat processing now safely emits task-cancel and error events only when an event emitter is available, while provider HTTP errors now also route through task-cancel handling so chats recover from blocked-loading states more reliably. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/51765b619c8584b042af68c3a5c87525a105ccd8), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5) +- 🔑 **OIDC key-rotation recovery.** OIDC login now retries token authorization with refreshed provider signing keys after a bad-signature failure, so logins recover automatically after identity-provider key rotation without requiring a service restart. [#23582](https://github.com/open-webui/open-webui/issues/23582), [Commit](https://github.com/open-webui/open-webui/commit/facb194a07486e847f0725a0a839e99b5864d37b) +- 🌍 **Non-ASCII tag filtering.** Prompt and model tag filters now handle non-Latin tags more reliably across SQLite and PostgreSQL, so tags like Cyrillic values return the expected items in Workspace lists. [#23381](https://github.com/open-webui/open-webui/issues/23381), [#23427](https://github.com/open-webui/open-webui/pull/23427), [Commit](https://github.com/open-webui/open-webui/commit/57784706e4fee75dec67e20b0d89a97351ac6256) +- 🏷️ **Prompt tag query accuracy.** Prompt tag filtering now uses JSON-element-aware queries so tag-based lookups return the correct prompts. [Commit](https://github.com/open-webui/open-webui/commit/e7e752f8e74e7b01fe2e6cb56f06e99312e1afe7), [#23386](https://github.com/open-webui/open-webui/pull/23386) +- 🗃️ **SQLite async pool compatibility.** SQLite async database setup no longer forces an explicit queue pool class, avoiding pool configuration conflicts in SQLite deployments. [Commit](https://github.com/open-webui/open-webui/commit/26b8ca5b5eeb144fae3fe6eaeae826150d8af826) +- 🧠 **Knowledge embedding deadlock prevention.** Knowledge file processing now runs blocking vector-save work in a worker thread while keeping async status updates reliable, preventing file processing from stalling during long embedding operations. [Commit](https://github.com/open-webui/open-webui/commit/d4b90f93bda2413ec8f040e61959acdb7b242061), [Commit](https://github.com/open-webui/open-webui/commit/22cfb3c673cbfa4a6bce26fde8e2e2754ce4963b) +- 🤖 **Automation worker async DB handling.** Automation claiming and run recording now use async database sessions consistently, improving worker stability for scheduled automations. [Commit](https://github.com/open-webui/open-webui/commit/cb6e77be3ec6ce00dd1f5b9ce3a655e6f65bc5da) +- 🕒 **Automation timezone scheduling.** Scheduled automations now calculate each user’s next run time using that user’s saved timezone, preventing run drift caused by server-time fallback. [Commit](https://github.com/open-webui/open-webui/commit/a4d62253df55c6307112eb76a6bfa29a7f538e21) +- 🔎 **Notes search matching.** Notes search now handles multi-word and hyphenated queries more reliably, so relevant notes and snippets are easier to find from partial phrase searches. [Commit](https://github.com/open-webui/open-webui/commit/a35926261646f8897ba71da1572ed5dff802e3be) +- 📐 **Display math rendering.** Chat markdown now correctly recognizes and renders "$$...$$" expressions as display math, improving reliability for multiline and escaped KaTeX content while keeping malformed delimiters from disrupting message rendering. [#23526](https://github.com/open-webui/open-webui/issues/23526), [Commit](https://github.com/open-webui/open-webui/commit/15b89b9218b7d2c7239c579aa3d23c2892227ac6) +- 🚫 **LDAP empty-password rejection.** LDAP login now rejects empty or whitespace-only passwords before bind attempts, preventing unauthenticated simple-bind behavior from granting access on permissive LDAP server configurations. [#23633](https://github.com/open-webui/open-webui/pull/23633) +- 🌐 **IPv6 SSRF address blocking.** URL validation now uses standard IP address checks for both IPv4 and IPv6, preventing private, loopback, link-local, reserved, and mapped-address SSRF bypasses through IPv6 hostname resolution. [#23453](https://github.com/open-webui/open-webui/pull/23453) +- 🔒 **API key endpoint restriction bypass.** API key endpoint restrictions are now enforced regardless of whether the key is sent through Authorization headers, cookies, or "x-api-key", preventing bypass through alternate key transport paths. [#23637](https://github.com/open-webui/open-webui/pull/23637) +- 🔐 **Channel sharing permission enforcement.** Channel creation and updates now enforce allowed access grant rules for public sharing, preventing unauthorized wildcard sharing on group channels. [#23638](https://github.com/open-webui/open-webui/pull/23638) +- 🛑 **Socket role invalidation.** Socket sessions now disconnect automatically when a user is demoted or deleted, preventing stale admin privileges from persisting until reconnect. [#23642](https://github.com/open-webui/open-webui/pull/23642) +- 🛂 **Tool server access checks.** Tool listing now correctly awaits server access checks, preventing users from seeing server-backed tools they do not have permission to use. [Commit](https://github.com/open-webui/open-webui/commit/d40f31982be3eed37e55e3f67b1eea9a5dc8c525) +- 🛑 **Task endpoint access control.** Global task listing and direct task stop endpoints are now restricted to administrators, while regular users can stop only their own chat tasks through a scoped chat endpoint. [#23454](https://github.com/open-webui/open-webui/pull/23454) +- 🧱 **Redis cache key isolation.** Tool server and terminal server cache entries now include the Redis key prefix, preventing multiple Open WebUI instances that share one Redis database from overwriting each other’s cached connection data. [#23649](https://github.com/open-webui/open-webui/pull/23649) +- 🧠 **Client session leak prevention.** Outbound provider requests now use a shared session pool with safer response cleanup and shutdown handling, preventing aiohttp session buildup and reducing memory growth during heavy concurrent API traffic. [#23540](https://github.com/open-webui/open-webui/issues/23540), [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135) +- 🧩 **Tool enum value handling.** Tool schema generation now safely handles enum values as strings, preventing failures when OpenAPI parameters include non-string enum entries. [#23597](https://github.com/open-webui/open-webui/issues/23597), [Commit](https://github.com/open-webui/open-webui/commit/4498e6faf2b1bdd1caa0e2c1c15d90a2790cd721) +- 🧷 **Responses model access control.** The OpenAI-compatible Responses endpoint now enforces per-model permissions, preventing non-admin users from accessing models they are not allowed to use. [#23481](https://github.com/open-webui/open-webui/pull/23481) +- 🛡️ **Collection process endpoint permissions.** Collection processing endpoints now enforce collection ownership checks for web and text processing requests. [Commit](https://github.com/open-webui/open-webui/commit/ba83613ff297bc82db660b5273f04672d744902f), [#23634](https://github.com/open-webui/open-webui/pull/23634) +- 📚 **Knowledge query access enforcement.** Knowledge-base collection queries now block unauthorized enumeration and require read access before returning results. [Commit](https://github.com/open-webui/open-webui/commit/860b90fd17d14ba00674621edd294dee150491d2), [#23635](https://github.com/open-webui/open-webui/pull/23635), [#23452](https://github.com/open-webui/open-webui/pull/23452) +- 🔍 **RAG collection query permissions.** Vector search collection queries now enforce access checks before retrieval results are returned. [Commit](https://github.com/open-webui/open-webui/commit/f44b7a01f5b854f47c1594a1ab5f72096f736262), [#23627](https://github.com/open-webui/open-webui/pull/23627) +- 🔗 **Chained base model access checks.** Chained base model execution now enforces per-model access rules to prevent unauthorized model usage. [Commit](https://github.com/open-webui/open-webui/commit/8acce144f99992b75c25f0e5038b16881ce9f066), [Commit](https://github.com/open-webui/open-webui/commit/50363ba66b19613a2fc0cab6a3f7f724a825135e), [#23647](https://github.com/open-webui/open-webui/pull/23647) +- ✍️ **Collaborative document write checks.** Collaborative document updates now require proper write permission before changes are accepted. [Commit](https://github.com/open-webui/open-webui/commit/638c7ab80216452910bdc59a19eb90e6b7244c6c), [Commit](https://github.com/open-webui/open-webui/commit/3271b013a8b30a882364679dcb40ffc9a89f037e), [#23624](https://github.com/open-webui/open-webui/pull/23624) +- 📥 **Model import ownership validation.** Model import now enforces ownership and access grant checks to prevent unauthorized imports. [Commit](https://github.com/open-webui/open-webui/commit/499129625bf96b2c03a6d057a2f91fdf07fd1c49), [#23628](https://github.com/open-webui/open-webui/pull/23628) +- 🚫 **Inactive member channel access.** Deactivated group members can no longer read or write channel content through direct API calls, so channel permissions now match active membership status. [#23623](https://github.com/open-webui/open-webui/pull/23623) +- 🎛️ **Ollama endpoint model permissions.** Restricted models are now protected on Ollama show, generate, embed, and embeddings endpoints, preventing authenticated users from using private models without read access. [#23631](https://github.com/open-webui/open-webui/pull/23631) +- 🧭 **Azure deployment path validation.** Azure model names are now validated and safely encoded before request URL construction, preventing path traversal attempts from reaching unintended Azure endpoints. [#23629](https://github.com/open-webui/open-webui/pull/23629) +- 👥 **Private channel member list access.** Standard channel member lists now require proper read permission, preventing unauthorized users from enumerating members of private channels by direct API calls. [#23625](https://github.com/open-webui/open-webui/pull/23625) +- 🌀 **Tool server schema recursion safety.** Tool server OpenAPI conversion now handles circular request schema references safely, preventing conversion crashes and ensuring one bad tool server spec does not break the full tool server list. [#23588](https://github.com/open-webui/open-webui/pull/23588), [Commit](https://github.com/open-webui/open-webui/commit/d3df8f1f372411314be9121fbf61d107939fa258) +- 🧱 **Safer file path handling.** File upload, transcription cache, and model download paths now use safer path construction helpers to reduce path parsing risks and improve cross-platform path safety. [Commit](https://github.com/open-webui/open-webui/commit/15f9a8f3f13f112c96cb1b16f88859f65de58346) +- 🧾 **Prompt save error feedback.** Saving prompt edits now shows a clear error toast if the save fails, so failed updates are visible instead of silently failing in the editor flow. [Commit](https://github.com/open-webui/open-webui/commit/36a81ad43b7c0d450079f818a7546eaa517e3d95) +- 🧾 **Tool call JSON rendering.** Tool call arguments and structured results now render as plain formatted JSON blocks instead of markdown code fences, preventing formatting quirks and making tool output easier to read consistently. [Commit](https://github.com/open-webui/open-webui/commit/a7d4c53f3adb80768b67e4a410b486b04a581521) +- 👥 **First-user admin race protection.** Concurrent first-time LDAP or OAuth registrations can no longer create multiple admin accounts, so only the true first account is promoted during initial setup. [#23626](https://github.com/open-webui/open-webui/pull/23626) +- 🔒 **SCIM token checks.** SCIM authentication now compares tokens in a safer way, helping prevent timing-based token guessing attacks. [#23577](https://github.com/open-webui/open-webui/pull/23577) +- 🔒 **Safer file access checks.** HTML file previews now treat missing or non-admin owners as inaccessible, preventing accidental access to files that should not be shown. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9) +- 🖼️ **ComfyUI request hangs.** Concurrent image generation and editing requests to ComfyUI now complete reliably instead of getting stuck when the same user starts multiple requests at once. [#23592](https://github.com/open-webui/open-webui/pull/23592), [#23591](https://github.com/open-webui/open-webui/issues/23591) +- 🧭 **Permission-aware built-in tools.** Built-in tools now consistently respect user feature permissions for memories, web search, image generation, code interpreter, notes, channels, and automations, preventing tools from being exposed to users without access. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2) +- 🛑 **Interrupted MCP cleanup stability.** Interrupted MCP tool calls no longer leave runaway cleanup behavior that can drive container CPU usage to 100%, keeping instances stable after cancellations or dropped connections. [#23143](https://github.com/open-webui/open-webui/issues/23143) +- 🚪 **OAuth redirect URI reliability.** OAuth login redirects now use provider client metadata more consistently, preventing incorrect HTTP callback URLs behind reverse proxies and improving sign-in reliability for providers such as Feishu. [#23203](https://github.com/open-webui/open-webui/pull/23203), [#23128](https://github.com/open-webui/open-webui/issues/23128) +- 🌐 **OAuth redirect handling.** OAuth provider token exchange now follows redirects automatically, improving sign-in reliability with identity providers that redirect token endpoint requests. [#23409](https://github.com/open-webui/open-webui/issues/23409), [Commit](https://github.com/open-webui/open-webui/commit/498ff8cdc3dd47000cdc60e5adcf36f4adfbe07d) +- ☁️ **OneDrive picker redirect handling.** OneDrive file picker authentication now uses the current app origin as the redirect URI, improving sign-in reliability when launching the picker from deployed environments. [#23450](https://github.com/open-webui/open-webui/issues/23450), [Commit](https://github.com/open-webui/open-webui/commit/21cc8281323d505d7d084cc496bd433063315c86) +- 🍪 **OAuth session cookie persistence.** OIDC sign-in now correctly sets the "oauth_session_id" cookie, so "system_oauth" connections can forward user OAuth tokens to upstream providers as expected. [#23251](https://github.com/open-webui/open-webui/pull/23251), [#23250](https://github.com/open-webui/open-webui/issues/23250) +- 🔑 **OAuth session cookie handling.** OAuth callback processing no longer fails on undefined cookie expiry data, so OAuth session cookies are stored correctly after sign-in. [#23207](https://github.com/open-webui/open-webui/pull/23207), [#23197](https://github.com/open-webui/open-webui/issues/23197) +- 🔏 **Ollama SSL handling.** Ollama model management and file uploads now respect the configured SSL verification setting, so self-signed certificates work when SSL verification is disabled. [#23503](https://github.com/open-webui/open-webui/issues/23503), [Commit](https://github.com/open-webui/open-webui/commit/e51b661af0e71a24f041428f328fcc6e97a15262) +- 🛡️ **OAuth avatar URL validation.** OAuth sign-in now validates profile picture URLs before fetching them, preventing invalid image links from causing login-time errors. [#23356](https://github.com/open-webui/open-webui/pull/23356) +- 🔑 **User invite token expiry.** New user invite logins now respect the configured "JWT_EXPIRES_IN" setting, so signup tokens expire as expected instead of using the default lifetime. [#23576](https://github.com/open-webui/open-webui/pull/23576) +- 🚪 **Channel access checks.** Channel actions now verify the current user when checking access, improving permission enforcement across channel views and message actions. [Commit](https://github.com/open-webui/open-webui/commit/4632f200a9ac98c915aee412b34e86c3d3c58bb1) +- 📣 **Channel message lookups.** Channel message details and pinning now work more reliably when the sender account is missing, avoiding failures in those views. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9) +- 📌 **Pinned webhook message handling.** Viewing pinned webhook messages now works reliably even when webhook profile data is missing, preventing server errors and frontend crashes in channel pinned message dialogs. [#23414](https://github.com/open-webui/open-webui/pull/23414) +- 🛡️ **Note edit permission enforcement.** Note saving now requires write access instead of read access, preventing unauthorized users from modifying notes while preserving expected collaboration permissions. [Commit](https://github.com/open-webui/open-webui/commit/584a9a0920d8c8c72fc89ccbac83c970b5a4bd4a) +- 🗂️ **Archived chats menu visibility.** The 'Archived Chats' option in the user menu is now shown reliably for all users, so non-admin accounts can consistently access archived conversations. [Commit](https://github.com/open-webui/open-webui/commit/07262fa62c2323fc7948389e5b5b8a5d1b72fade) +- 💾 **Error message persistence.** LLM errors that occur during streaming are now saved to the database even if the connection drops, so users can see what went wrong when they reconnect. [#23231](https://github.com/open-webui/open-webui/pull/23231) +- 🚫 **Missing message completion guard.** Chat completion finalization now skips invalid requests without a message identifier, preventing unnecessary error toasts caused by rare frontend concurrency timing. [#23184](https://github.com/open-webui/open-webui/pull/23184) +- 🧠 **Active message completion accuracy.** Switching chats or refreshing during generation no longer marks the currently streaming assistant message as finished too early, so thinking blocks and action buttons appear at the correct time. [#23171](https://github.com/open-webui/open-webui/issues/23171) +- 📞 **Call overlay visibility.** Incoming call events now open the call overlay and controls reliably, preventing cases where the call interface briefly appeared and then disappeared. [Commit](https://github.com/open-webui/open-webui/commit/ee9db91df02120e1e3651e8881734966b710ad52) +- 💬 **Prompt submission handling.** Chat messages now preserve attached files more reliably when prompts are sent, including queued messages and shared prompt actions. [Commit](https://github.com/open-webui/open-webui/commit/6d6dfbf02c893d72d85d4490cb41f1665b1f9f95) +- 🧾 **Prompt variable form saving.** Prompt variable forms now save reliably without runtime errors or an unresponsive save action, so input values and placeholders work correctly when applying prompt templates with variables. [#23225](https://github.com/open-webui/open-webui/issues/23225), [#23480](https://github.com/open-webui/open-webui/issues/23480) +- 🛟 **Task model fallback safety.** Task routing now handles missing default model entries safely, preventing task execution failures when the previously selected model is no longer available. [#23169](https://github.com/open-webui/open-webui/pull/23169) +- 📊 **Usage statistic preservation.** Follow-up generation no longer overwrites existing token usage fields, so stored usage statistics remain accurate for the main response. [#23152](https://github.com/open-webui/open-webui/issues/23152) +- 📝 **Writing block parsing reliability.** ":::writing" blocks now parse more reliably when headers or extra inline text are present, preventing malformed rendering and duplicate output artifacts. [#23174](https://github.com/open-webui/open-webui/issues/23174) +- 🧾 **Code block line break reliability.** Blank lines in submitted code blocks are now preserved more reliably instead of being collapsed. [Commit](https://github.com/open-webui/open-webui/commit/1be9627dd27ffe75957729a4a0d1682a98684f01), [#20302](https://github.com/open-webui/open-webui/issues/20302), [#23451](https://github.com/open-webui/open-webui/pull/23451) +- ✂️ **Citation spacing cleanup.** When citations are disabled for a model, citation markers and their leftover spacing are now removed together so punctuation and copied text remain cleanly formatted. [#23141](https://github.com/open-webui/open-webui/issues/23141) +- 🧰 **Pipe tool access.** Pipe functions now receive built-in and MCP tools in **tools**, so tools like Web Search and code execution are available when enabled. [#23365](https://github.com/open-webui/open-webui/issues/23365) +- 📚 **Batch file processing database handling.** Batch knowledge file processing now consistently uses the active database session, preventing failures caused by missing database context during file ownership checks and update writes. [#23137](https://github.com/open-webui/open-webui/issues/23137) +- ⚙️ **Default model parameter loading.** The "DEFAULT_MODEL_PARAMS" environment variable is now parsed and applied correctly, so default generation settings are honored reliably without being ignored at startup. [#23223](https://github.com/open-webui/open-webui/pull/23223) +- 🔧 **Web search settings save reliability.** Saving web search configuration now works without server errors, so administrators can update "WEB_FETCH_MAX_CONTENT_LENGTH" and related retrieval settings successfully from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/36d02aa1477aa1b4e7fb59d022f99693ebfa8667), [#23127](https://github.com/open-webui/open-webui/issues/23127) +- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#23488](https://github.com/open-webui/open-webui/pull/23488), [#23485](https://github.com/open-webui/open-webui/issues/23485) +- 🖼️ **Open Terminal file response handling.** Open Terminal tool responses now preserve binary content types in user-side connections, so image and non-text file reads work consistently instead of being forced into plain text. [#23125](https://github.com/open-webui/open-webui/issues/23125) +- 🖥️ **Terminal label casing.** Terminal names in the chat input now display exactly as stored instead of being automatically capitalized, so domain-style server names appear correctly. [#23518](https://github.com/open-webui/open-webui/pull/23518) +- 🖼️ **Gravatar profile photo saving.** Gravatar profile images can now be saved successfully from account settings, with clearer validation and error handling instead of failing with generic object errors. [#23156](https://github.com/open-webui/open-webui/issues/23156) +- 🪟 **Details expansion preference.** Tool call detail groups now honor the 'Always Expand Details' chat setting, so they open expanded by default when that preference is enabled. [#23262](https://github.com/open-webui/open-webui/pull/23262), [#23255](https://github.com/open-webui/open-webui/issues/23255) +- 🖱️ **Rapid sidebar action protection.** Archive and delete actions in the chat sidebar now ignore repeated clicks while a request is in progress, preventing duplicate requests and stacked error toasts. [#23172](https://github.com/open-webui/open-webui/issues/23172) +- 📲 **Mobile model selector positioning.** The mobile model selector dropdown now applies a constrained viewport width and left offset, preventing overflow and making model selection easier on small screens. [#23310](https://github.com/open-webui/open-webui/pull/23310) +- 🔽 **Task list toggle icons.** The task list collapse button now shows the correct arrow direction, making task sections easier to expand and collapse at a glance. [Commit](https://github.com/open-webui/open-webui/commit/f66b67c8b86b6f9d896a23c7bb53907c2e6b15d3), [#23354](https://github.com/open-webui/open-webui/issues/23354) +- ➕ **Attachment menu auto-close.** The chat attachment menu now closes immediately after selecting upload actions like file upload, camera capture, web attach, Google Drive, or OneDrive, preventing the menu from lingering on screen. [Commit](https://github.com/open-webui/open-webui/commit/4764dd5d3765c22384ed38cbc97a8170daa7a75f), [#23320](https://github.com/open-webui/open-webui/issues/23320) +- 🧹 **Per-chat draft clearing.** Sent message drafts are now cleared using the active chat key, so sent text no longer reappears in the input after a refresh. [Commit](https://github.com/open-webui/open-webui/commit/124b7e9154d7f3ca8a16f2b90621209ac8d6b8c1), [#23296](https://github.com/open-webui/open-webui/issues/23296) +- ✉️ **Context-aware input action button.** The input now shows the send action when text or files are present during generation, while keeping stop controls for truly empty input states to avoid action confusion. [Commit](https://github.com/open-webui/open-webui/commit/86472bb4453af7ea4e5ddc8d127b14d8e67733bc), [#23306](https://github.com/open-webui/open-webui/issues/23306) +- 📉 **Pyodide prompt cache stability.** Pyodide code interpreter context is now appended to the system prompt instead of user messages, preserving stable prefix caching across turns and reducing repeated token costs in long native tool-calling chats. [#23269](https://github.com/open-webui/open-webui/issues/23269) +- 🧪 **Temp chat outlet filtering.** Outlet filters now process temporary chats more reliably, preserving assistant output and usage data so local chat responses stay consistent when filter pipelines are enabled. [Commit](https://github.com/open-webui/open-webui/commit/70a6a24f143b221c787bc50b72582ee1e0c2dac0) + +### Changed + +- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility. +- 🧨 **Plugin async migration required.** Custom plugins for Tools, Functions, and Pipelines may require migration to the new async backend signatures after upgrading, so plugin maintainers should update handlers and database call patterns for compatibility and follow the 0.9.0 plugin migration guide. [Migration Guide](https://docs.openwebui.com/features/extensibility/plugin/migration/to-0.9.0) +- 🔄 **Automation terminal source.** Automations now use the terminal configured on the selected model instead of a separate per-automation terminal picker, keeping terminal behavior consistent between chat and scheduled runs. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d) +- 🚧 **OpenAI passthrough now opt-in.** Direct OpenAI catch-all proxy requests are now disabled by default and require enabling "ENABLE_OPENAI_API_PASSTHROUGH", so deployments relying on passthrough must explicitly turn it on after upgrading. [#23640](https://github.com/open-webui/open-webui/pull/23640) +- 🗄️ **SQLite WAL default enabled.** SQLite deployments now default to enabling write-ahead logging, improving concurrent read and write behavior without requiring manual configuration. [Commit](https://github.com/open-webui/open-webui/commit/2f9e326dba3b1087932cb6b8075ed1881bd1c6d6) + +## [0.8.12] - 2026-03-26 + +### Added + +- 🌐 **Translation updates.** Translations for Simplified Chinese, Catalan, Portuguese (Brazil), Finnish, and Lithuanian were enhanced and expanded. + +### Fixed + +- 🔒 **Terminal server connection security.** Terminal server verification and policy saving now proxy through the backend, preventing API key exposure and CORS errors when connecting to in-cluster services. [Commit](https://github.com/open-webui/open-webui/commit/a6413257079a52fa4487eda36543f3955d0fbd53), [Commit](https://github.com/open-webui/open-webui/commit/4567cdc0d9cb7b42b6eba7b676c0ced3f4850d31) +- 🛠️ **Terminal tools exception handling.** Exceptions in middleware.py due to invalid return values from get_terminal_tools() have been resolved. [Commit](https://github.com/open-webui/open-webui/commit/52a06bd48aff34fb2211aac2879f0cd028129267) +- 📦 **Missing beautifulsoup4 dependency.** Users can now start Open WebUI using uvx without encountering the "bs4 module missing" error. [Commit](https://github.com/open-webui/open-webui/commit/1994d65306bbcc7406584e1bfef82f5d353fc91c) +- 🔌 **API files list error.** The /api/v1/files/ endpoint no longer returns a 500 error, fixing a regression that prevented file listing via the API. [Commit](https://github.com/open-webui/open-webui/commit/11f52921dc21c2dc61c03f12bcdf6f19140a350c) +- 📜 **License data loading.** License data now loads correctly, displaying the expected color and logo in the interface. [Commit](https://github.com/open-webui/open-webui/commit/16335f866ea4cedf00c4971963622fcc1fe02d82) +- 👑 **Admin model visibility.** Administrators can now see models even when no access control is configured yet, allowing them to manage all available models. [Commit](https://github.com/open-webui/open-webui/commit/f3f8f9874f55282603c2650b91801640cb3f69cb) +- 📊 **Tool call embed visibility.** Rich UI embeds from tool calls (like visualizations) are now rendered outside collapsed groups and remain visible without requiring manual expansion. [Commit](https://github.com/open-webui/open-webui/commit/4c872a8d128757d4a6f311fb86bc382af2ba5d0d), [Commit](https://github.com/open-webui/open-webui/commit/308fa924a5b2b7e08cd1e8f15b9c8c96e1de8f02) + +## [0.8.11] - 2026-03-25 + +### Added + +- 🔀 **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9) +- 🔀 **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1) +- 📄 **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f) +- 🗺️ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847) +- 🛠️ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691) +- 🔒 **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523) +- 🔑 **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004) +- 🔑 **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652) +- 🔌 **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908) +- 🏮 **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4) +- ⚡ **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611) +- 🧩 **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd) +- 💨 **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947) +- 🚀 **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788) +- ✨ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516) +- 🧪 **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567) +- 📁 **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554) +- 📊 **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529) +- 🏥 **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507) +- 🔩 **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf) +- 📌 **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629) +- 🎯 **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc) +- 🗣️ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818) +- 📡 **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e) +- ✏️ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943) +- 🧭 **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9) +- 🗑️ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa) +- 📦 **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632) +- 🧠 **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083) +- 📦 **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015) +- ⚙️ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462) +- 🖥️ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30) +- ✏️ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e) +- 🍔 **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484) +- 📂 **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4) +- 🖇 **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774) +- 🤖 **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861) +- 📝 **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728) +- 💡 **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888) +- 📓 **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716) +- 🚩 **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372) +- ⌨️ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6) +- 💾 **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850) +- 💾 **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210) +- 🔐 **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13) +- 🎪 **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f) +- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded. + +### Fixed + +- 🔒 **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37) +- 🛡️ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb) +- 🛡️ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4) +- 🗝️ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6) +- 🔐 **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9) +- 🔏 **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809) +- 🚪 **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865) +- 🔍 **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea) +- 👤 **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3) +- 🎫 **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f) +- 🔏 **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc) +- 🔑 **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481) +- 🔍 **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543) +- 🔐 **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81) +- 🍪 **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab) +- 🔱 **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a) +- 🎚️ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201) +- 🔄 **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f) +- 🧽 **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982) +- 🧹 **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit — only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983) +- 💬 **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565) +- 🗄️ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568) +- 🫧 **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783) +- 🧩 **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f) +- 📝 **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2) +- 🪛 **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924) +- 🗂️ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784) +- 🧭 **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755) +- 🚩 **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461) +- 🔧 **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6) +- 🔩 **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605) +- 🔌 **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b) +- 🔌 **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873) +- 🏷️ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7) +- 📎 **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752) +- 🗣️ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880) +- 🧹 **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890) +- 💡 **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899) +- ⏱️ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891) +- 💬 **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff) +- 🧹 **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893) +- 🧮 **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17) +- 🪛 **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37) +- 🔗 **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4) +- 📜 **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208) +- 👤 **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d) +- 👥 **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547) +- 🔧 **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7) +- 🖼️ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485) +- 🖼️ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486) +- 🧲 **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400) +- 🔍 **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea) +- 🔍 **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c) +- 🔄 **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4) +- 🗄️ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616) +- 🌐 **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6) +- 📐 **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be) +- 🕵️ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869) +- 🔲 **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886) +- 🎨 **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889) +- 🔃 **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749) +- 🔎 **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782) +- 📋 **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912) +- 🗂️ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782) +- 🔤 **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (❤️, ☀️, ✅), keycap sequences (1️⃣), and ZWJ family sequences (👨‍👩‍👧‍👦). [#22915](https://github.com/open-webui/open-webui/pull/22915) +- ⏹️ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743) +- 🎨 **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862) +- 📊 **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894) +- 👻 **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234) +- 🐛 **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742) +- 🛠️ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695) +- 📋 **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754) +- 🎧 **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28) +- 📡 **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980) +- 🗝️ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055) +- 🔓 **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942) +- 🔃 **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e) +- 🧠 **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001) +- 🖥️ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746) +- 🖱️ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7) +- 📊 **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9) +- 🎯 **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings — including RAG knowledge bases, web access, code interpreter, and tool capabilities — instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20) +- 🧩 **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c) +- ✏️ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014) + +### Changed + +- 🪝 **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6) +- 🧩 **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454) +- 🧲 **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577) + ## [0.8.10] - 2026-03-08 ### Added @@ -3972,7 +4551,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality. - - Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag. - When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`. diff --git a/Dockerfile b/Dockerfile index d5c40f15e98..7a8e983429b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH} RUN npm run build ######## WebUI backend ######## -FROM python:3.11.14-slim-bookworm AS base +FROM python:3.11-slim-bookworm AS base # Use args ARG USE_CUDA @@ -135,7 +135,20 @@ RUN apt-get update && \ # install python dependencies COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt +# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds +ENV UV_LINK_MODE=copy + RUN set -e; \ + # Strip IPv6 CIDR entries from no_proxy — OrbStack injects ranges like fd07:b51a:cc66::/64 + # that httpx cannot parse as URL patterns, causing build failures during model downloads. + if [ -n "$no_proxy" ]; then \ + no_proxy=$(echo "$no_proxy" | tr ',' '\n' | grep -v ':.*/' | tr '\n' ',' | sed 's/,$//'); \ + export no_proxy; \ + fi; \ + if [ -n "$NO_PROXY" ]; then \ + NO_PROXY=$(echo "$NO_PROXY" | tr ',' '\n' | grep -v ':.*/' | tr '\n' ',' | sed 's/,$//'); \ + export NO_PROXY; \ + fi; \ pip3 install --no-cache-dir uv; \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use diff --git a/LICENSE b/LICENSE index faa0129c659..99f39e7feff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,5 @@ +Open WebUI License + Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek] All rights reserved. @@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met: contributors may be used to endorse or promote products derived from this software without specific prior written permission. -4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. - -5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. - -6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. +4. Notwithstanding any other provision of this License, and as a material + condition of the rights granted herein, licensees are strictly prohibited + from altering, removing, obscuring, or replacing any "Open WebUI" + branding, including but not limited to the name, logo, or any visual, + textual, or symbolic identifiers that distinguish the software and its + interfaces, in any deployment or distribution, except in the following + circumstances: (i) deployments or distributions where the total number + of end users (defined as individual natural persons with direct access + to the application) does not exceed fifty (50) within any rolling + thirty (30) day period; (ii) the licensee has obtained specific prior + written permission from the copyright holder; or (iii) where the + licensee has obtained a duly executed enterprise license expressly + permitting such modification. For all other cases, any removal or + alteration of the "Open WebUI" branding shall constitute a material + breach of license. + +Materials governed by prior licenses retain those original license +terms, as specified in LICENSE_HISTORY. + +By contributing to this project, you agree to the project's Contributor +License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT). THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE diff --git a/README.md b/README.md index a178b3271e8..3c4bee98c91 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ For more information, be sure to check out our [Open WebUI Documentation](https: - 💾 **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. -- 📚 **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, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query. +- 📚 **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. - 🔍 **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. @@ -172,8 +172,6 @@ After installation, you can access Open WebUI at [http://localhost:3000](http:// We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance. -Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment. - ### Troubleshooting Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s). diff --git a/README_FLEXION.md b/README_FLEXION.md new file mode 100644 index 00000000000..6a5ad8ec40b --- /dev/null +++ b/README_FLEXION.md @@ -0,0 +1,360 @@ +# FlexChat - Flexion's Open WebUI Fork + +FlexChat is Flexion's customized deployment of [Open WebUI](https://github.com/open-webui/open-webui), rebranded and configured to integrate with AWS Bedrock via the [Flexion Bedrock Access Gateway](https://github.com/flexion/bedrock-access-gateway). + +## Branch Strategy + +| Branch | Purpose | +|--------|---------| +| `flex` | **Flexion customizations** - Contains all Flexion-specific branding, configurations, and features. This is the primary branch for Flexion development. | +| `main` | Mirrors the upstream Open WebUI `main` branch. Used for tracking upstream releases. | +| `dev` | Mirrors the upstream Open WebUI `dev` branch. Used for tracking upstream development. | + +### Keeping Up with Upstream + +FlexChat tracks upstream [open-webui/open-webui](https://github.com/open-webui/open-webui). The `flex` branch is rebased onto upstream releases to incorporate new features and fixes while preserving Flexion customizations. + +#### One-Time Setup + +```bash +# Add upstream remote (if not already configured) +git remote add upstream https://github.com/open-webui/open-webui.git + +# Verify remotes +git remote -v +# origin git@github.com:flexion/open-webui (fetch) +# upstream https://github.com/open-webui/open-webui.git (fetch) +``` + +--- + +#### Option A — Automated Sync (Recommended) + +The **Upstream Sync** GitHub Actions workflow runs **on a daily schedule** (09:00 UTC). Each day it checks upstream for a new `v*.*.*` release tag, and if there is one newer than the tag `flex` is currently based on, it rebases onto that tag and opens a PR. On days with no new release, it exits cleanly without making changes. + +**Prerequisites — configure this secret once in repo Settings → Secrets and variables → Actions:** + +| Secret | Description | +|--------|-------------| +| `SYNC_PAT` | GitHub fine-grained PAT with `Contents: write`, `Pull requests: write`, and `Workflows: write` on this repo. Required because `GITHUB_TOKEN` cannot push branches that contain `.github/workflows/` files. Must be SSO-authorized for the `flexion` org. | + +**Manual runs (optional):** + +You can also trigger the workflow manually from **Actions → Upstream Sync → Run workflow**: + +- Leave `target_tag` blank to auto-detect the latest upstream `v*.*.*` tag (same logic as the schedule) +- Set `target_tag` to a specific tag (e.g. `v0.9.6`) to sync to that exact release +- Set `force: true` to open a sync PR even if `flex` is already on the target tag + +**What the workflow does:** +1. Fetches all upstream tags +2. Picks the target: the explicit `target_tag` input, or the latest `v*.*.*` tag from upstream +3. Determines flex's current base via `git describe --tags --abbrev=0 flex` +4. Exits cleanly if flex is already on the target (and `force` is not set) +5. Refuses to sync backward (target older than current base) unless `force: true` +6. Creates a throwaway branch `upstream-sync/-YYYYMMDD-HHMMSS` from `flex` +7. Rebases onto the target tag, applying these rules per conflicted file: + - Binary files (`*.png`, `*.ico`, `*.wasm`) → keeps Flexion's version (`--ours`) + - Lock files (`package-lock.json`, `uv.lock`) → takes upstream's version (`--theirs`); regenerate locally if needed + - Flexion-unique files (`functions/`, `static/static/providers/`, `README_FLEXION.md`) → keeps Flexion's version (`--ours`) + - Shared source files → conflict markers are committed as-is; the human resolves them in the PR +8. Pushes the throwaway branch and opens a PR targeting `flex` — draft if any manual review is required, ready-for-review if the rebase was clean +9. The PR body includes a conflict resolution log and (when applicable) a HITL review checklist with the list of files needing manual resolution + +**After the workflow opens a PR:** +1. Review the conflict resolution log in the PR description +2. If files need manual resolution: check out the branch, fix the markers, push, then mark the PR ready for review +3. Verify Flexion features still work (see checklist in PR body) +4. Approve and merge the PR +5. Fast-forward `flex` locally: + ```bash + git checkout flex + git pull origin flex + ``` +6. Publish the new release tag to ECR by running the **Publish flex image to ECR** workflow with `version= environment=dev` (and `environment=prod` once dev is verified) + +--- + +#### Option B — Manual Rebase Runbook + +Use this when you need direct control, or when the automated workflow encounters issues. + +**Step 1 — Safety prep** + +```bash +# Fetch latest from both remotes +git fetch upstream +git fetch origin + +# Create a backup tag (recovery point) +git tag flex-backup-pre-rebase-$(date +%Y%m%d) flex +git push origin flex-backup-pre-rebase-$(date +%Y%m%d) + +# Create a throwaway working branch (never rebase flex directly) +git checkout -b flex-rebase-onto-vX.Y.Z flex +``` + +**Step 2 — Rebase** + +```bash +git rebase upstream/main +``` + +**Step 3 — Resolve conflicts** (if any) + +Use this priority order for each conflicted file: + +| File Type | Command | Rationale | +|-----------|---------|-----------| +| Binary (`*.png`, `*.ico`, `*.wasm`) | `git checkout --ours && git add ` | Not text-mergeable; Flexion icons are custom | +| Lock files (`package-lock.json`, `uv.lock`) | `git checkout --theirs && git add ` | Regenerated deterministically; take upstream's | +| Flexion-unique (`functions/`, `static/static/providers/`, `README_FLEXION.md`) | `git checkout --ours && git add ` | Entirely Flexion additions; upstream never touches these | +| Shared source files (`oauth.py`, `models.py`, etc.) | Manual merge | Preserve Flexion intent, incorporate upstream structure | + +After resolving each file: `git add ` then `git rebase --continue` + +If a commit becomes empty after resolution: `git rebase --skip` + +If the rebase becomes unresolvable: `git rebase --abort` (your throwaway branch returns to its pre-rebase state) + +**Step 4 — Verify** + +```bash +# Confirm upstream/main is an ancestor of the rebased branch +git merge-base --is-ancestor upstream/main flex-rebase-onto-vX.Y.Z && echo "PASS" + +# Confirm Flexion commits are on top (should be 3) +git log --oneline flex-rebase-onto-vX.Y.Z ^upstream/main + +# Confirm no merge commits (clean linear history) +git log --merges flex-rebase-onto-vX.Y.Z ^upstream/main | wc -l # must be 0 +``` + +**Step 5 — Push and open draft PR** + +```bash +git push --force-with-lease origin flex-rebase-onto-vX.Y.Z + +gh pr create \ + --draft \ + --base flex \ + --head flex-rebase-onto-vX.Y.Z \ + --title "feat: rebase Flexion customizations onto vX.Y.Z" \ + --body "Upstream sync: vPREV → vX.Y.Z. See conflict log for details." +``` + +**Step 6 — After human review and approval** + +```bash +# Fast-forward flex to the rebased branch +git checkout flex +git merge --ff-only flex-rebase-onto-vX.Y.Z +git push --force-with-lease origin flex + +# Update origin/main to mirror upstream/main +git checkout main +git merge --ff-only upstream/main +git push origin main + +# Clean up throwaway branch +git branch -d flex-rebase-onto-vX.Y.Z +git push origin --delete flex-rebase-onto-vX.Y.Z +``` + +Commit message pattern: `feat: rebase Flexion customizations onto vX.Y.Z` + +--- + +#### Flexion Customization Inventory + +These files contain Flexion-specific changes that must survive every upstream sync: + +| File | Purpose | Conflict Risk | +|------|---------|---------------| +| `backend/open_webui/utils/oauth.py` | Google Groups OAuth implementation | High — upstream actively develops auth | +| `backend/open_webui/routers/models.py` | Custom model routing | Medium | +| `backend/open_webui/constants.py` | `TASKS.MODEL_RECOMMENDATION` enum value | Low — append-only | +| `backend/open_webui/routers/tasks.py` | `POST /model_recommendation/completions` endpoint | Medium — task routing may change | +| `backend/open_webui/utils/task.py` | `model_recommendation_template()` utility | Low — append-only | +| `src/lib/components/chat/Navbar.svelte` | Flexion navbar changes | Medium | +| `src/lib/components/chat/Placeholder.svelte` | Flexion UI tweak | Low | +| `src/lib/apis/index.ts` | Flexion API additions | Medium | +| `src/lib/components/chat/ModelHelperModal.svelte` | Model selector UI (Flexion-unique) | None — Flexion-only file | +| `functions/` (5 files) | Custom Flexion functions | None — Flexion-only directory | +| `static/static/providers/` (17 files) | Provider icons + metadata | None — Flexion-only directory | +| `README_FLEXION.md` | This file | None — Flexion-only file | +| `docs/oauth-google-groups.md` | OAuth documentation | None — Flexion-only file | + +## Local Development Setup + +### Prerequisites + +- Docker and Docker Compose +- AWS credentials configured (for Bedrock Access Gateway) +- [Flexion Bedrock Access Gateway](https://github.com/flexion/bedrock-access-gateway) running locally (or live connection) + +### Architecture Overview + +``` +┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐ +│ │ │ │ │ │ +│ FlexChat │────▶│ Bedrock Access Gateway │────▶│ AWS Bedrock │ +│ (Port 3000) │ │ (Port 8000) │ │ │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────────────┘ └─────────────────┘ +``` + +FlexChat connects to the Bedrock Access Gateway (BAG) which provides an OpenAI-compatible API facade for Amazon Bedrock models. + +### Step 1: Start the Bedrock Access Gateway + +Before running FlexChat, you need the Bedrock Access Gateway running locally. See the [BAG README_FLEXION.md](https://github.com/flexion/bedrock-access-gateway/blob/main/README_FLEXION.md) for detailed setup instructions. + +Quick start for BAG: + +```bash +# Clone the Bedrock Access Gateway repo +git clone https://github.com/flexion/bedrock-access-gateway.git +cd bedrock-access-gateway + +# Set up virtual environment +python3 -m venv .venv && source .venv/bin/activate +pip install -r src/requirements.txt + +# Configure AWS and start the gateway +export AWS_REGION=us-east-1 +export API_KEY=bedrock +export ALLOWED_MODEL_IDS='["anthropic.*", "us.anthropic.*", "us.meta.*"]' + +# Run on port 8000 +uvicorn api.app:app --host 0.0.0.0 --port 8000 +``` + +### Step 2: Configure FlexChat Environment + +Create or update your `.env` file in the FlexChat root directory: + +```bash +# Core Settings +ENV=dev +WEBUI_AUTH=FALSE +ENABLE_LOGIN_FORM=false + +# Model Configuration +BYPASS_MODEL_ACCESS_CONTROL=true +DEFAULT_MODELS=us.meta.llama3-1-8b-instruct-v1:0 + +# Bedrock Access Gateway Connection +# Points to the locally running BAG instance +OPENAI_API_BASE_URL=http://host.docker.interal:8000/api/v1 +OPENAI_API_KEY=bedrock + +# Disable Ollama (we're using Bedrock) +ENABLE_OLLAMA_API=false + +# User Settings +DEFAULT_USER_ROLE=user +ENABLE_API_KEYS=true +USER_PERMISSIONS_FEATURES_API_KEYS=true + + +WEBUI_AUTH=false +``` + +### Step 3: Run FlexChat with Docker Compose + +```bash +# Build and start FlexChat +docker-compose up --build + +# Or run in detached mode +docker-compose up -d --build +``` + +FlexChat will be available at `http://localhost:3000`. + +### Docker Compose Configuration + +The `docker-compose.yaml` is configured to: + +- Build FlexChat from the local Dockerfile +- Mount a persistent volume for data storage +- Load environment variables from `.env` +- Expose local port services like the BAG +- Add `host.docker.internal` for accessing host services (like the BAG) + +```yaml +services: + open-webui: + build: + context: . + dockerfile: Dockerfile + container_name: open-webui + volumes: + - open-webui:/app/backend/data + network_mode: 'host' + env_file: + - .env + extra_hosts: + - host.docker.internal:host-gateway + restart: unless-stopped +``` + +**Note:** The Ollama service is included in the compose file but is not required when using Bedrock. You can remove or comment out the Ollama service and its dependency if desired. + +## Connecting to Bedrock Access Gateway + +The `OPENAI_API_BASE_URL` environment variable is set to `http://host.docker.internal:8000/api/v1` to connect FlexChat to the locally running Bedrock Access Gateway. + +### Important Notes + +- **API Key:** The `OPENAI_API_KEY=bedrock` matches the default API key used by the BAG in local development mode. +- **Available Models:** The models available in FlexChat depend on the `ALLOWED_MODEL_IDS` configured in the Bedrock Access Gateway. + +### Updating OPENAI_API_BASE_URL for Docker + +If FlexChat is running in Docker and BAG is running on your host: + +```bash +# In .env, use host.docker.internal for Docker-to-host communication +OPENAI_API_BASE_URL=http://host.docker.internal:8000/api/v1 +``` + +## Flexion Customizations + +The `flex` branch includes the following Flexion-specific changes: + +### Branding +- Application name changed from "Open WebUI" to "FlexChat" +- Custom Flexion logo used for favicons and splash screens +- Updated site manifest and HTML title + +### Configuration Defaults +- Default integration with Bedrock Access Gateway +- Ollama disabled by default +- Google OAuth pre-configured (credentials required) + +## Troubleshooting + +### FlexChat can't connect to models + +1. Verify the Bedrock Access Gateway is running on port 8000 +2. Check that `OPENAI_API_BASE_URL` is correctly set +3. If using Docker, try using `0.0.0.0` and setting `network_mode: 'host'` + +### No models appearing in the UI + +1. Check BAG logs for any authentication errors +2. Verify your AWS credentials have Bedrock invoke permissions +3. Confirm `ALLOWED_MODEL_IDS` in BAG includes the models you expect + +### Docker build fails + +1. Ensure Docker has sufficient resources allocated +2. Try clearing Docker cache: `docker-compose build --no-cache` + +## Related Documentation + +- [Open WebUI Documentation](https://docs.openwebui.com/) +- [Flexion Bedrock Access Gateway](https://github.com/flexion/bedrock-access-gateway/blob/main/README_FLEXION.md) +- [Amazon Bedrock Documentation](https://docs.aws.amazon.com/bedrock/) diff --git a/backend/dev.sh b/backend/dev.sh index 042fbd9efa1..838b93f6536 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -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 '*' --reload +uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index acb70e17e21..92cadefe7ca 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -1,19 +1,19 @@ import base64 import os import random +import sys from pathlib import Path +from typing import Annotated import typer import uvicorn -from typing import Optional -from typing_extensions import Annotated app = typer.Typer() KEY_FILE = Path.cwd() / '.webui_secret_key' -def version_callback(value: bool): +def version_callback(value: bool) -> None: if value: from open_webui.env import VERSION @@ -23,7 +23,7 @@ def version_callback(value: bool): @app.command() def main( - version: Annotated[Optional[bool], typer.Option('--version', callback=version_callback)] = None, + version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None, ): pass @@ -66,15 +66,21 @@ def serve( os.environ['USE_CUDA_DOCKER'] = 'false' os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) - import open_webui.main # we need set environment variables before importing main + import open_webui.main # noqa: F401 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 + # asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py. + loop = 'none' if sys.platform == 'win32' else 'auto' + uvicorn.run( 'open_webui.main:app', host=host, port=port, forwarded_allow_ips='*', workers=UVICORN_WORKERS, + loop=loop, ) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 3cbeb366446..8fdc72fd80c 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -35,7 +36,7 @@ WEBUI_NAME, log, ) -from open_webui.internal.db import Base, get_db +from open_webui.internal.db import Base, get_db, get_async_db from open_webui.utils.redis import get_redis_connection @@ -90,6 +91,7 @@ def load_json_config(): def save_to_db(data): + """Sync save — used ONLY at startup/import time.""" with get_db() as db: existing_config = db.query(Config).first() if not existing_config: @@ -102,12 +104,39 @@ def save_to_db(data): db.commit() +async def async_save_to_db(data): + """Async save — used for ALL runtime config persistence.""" + from sqlalchemy import select + + async with get_async_db() as db: + result = await db.execute(select(Config).limit(1)) + existing_config = result.scalars().first() + if not existing_config: + new_config = Config(data=data, version=0) + db.add(new_config) + else: + existing_config.data = data + existing_config.updated_at = datetime.now() + db.add(existing_config) + await db.commit() + + def reset_config(): + """Sync reset — used ONLY at startup.""" with get_db() as db: db.query(Config).delete() db.commit() +async def async_reset_config(): + """Async reset — used at runtime.""" + from sqlalchemy import delete as sa_delete + + async with get_async_db() as db: + await db.execute(sa_delete(Config)) + await db.commit() + + # When initializing, check if config.json exists and migrate it to the database if os.path.exists(f'{DATA_DIR}/config.json'): data = load_json_config() @@ -144,6 +173,7 @@ def get_config_value(config_path: str): def save_config(config): + """Sync save — used ONLY at startup/import time.""" global CONFIG_DATA global PERSISTENT_CONFIG_REGISTRY try: @@ -159,6 +189,23 @@ def save_config(config): return True +async def async_save_config(config): + """Async save — used for ALL runtime config persistence.""" + global CONFIG_DATA + global PERSISTENT_CONFIG_REGISTRY + try: + await async_save_to_db(config) + CONFIG_DATA = config + + # Trigger updates on all registered PersistentConfig entries + for config_item in PERSISTENT_CONFIG_REGISTRY: + config_item.update() + except Exception as e: + log.exception(e) + return False + return True + + T = TypeVar('T') ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true' @@ -202,6 +249,7 @@ def update(self): log.info(f'Updated {self.env_name} to new value {self.value}') def save(self): + """Sync save — used ONLY at startup/import time.""" log.info(f"Saving '{self.env_name}' to the database") path_parts = self.config_path.split('.') sub_config = CONFIG_DATA @@ -213,6 +261,19 @@ def save(self): save_to_db(CONFIG_DATA) self.config_value = self.value + async def async_save(self): + """Async save — used for ALL runtime config persistence.""" + log.info(f"Saving '{self.env_name}' to the database") + path_parts = self.config_path.split('.') + sub_config = CONFIG_DATA + for key in path_parts[:-1]: + if key not in sub_config: + sub_config[key] = {} + sub_config = sub_config[key] + sub_config[path_parts[-1]] = self.value + await async_save_to_db(CONFIG_DATA) + self.config_value = self.value + class AppConfig: _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None @@ -246,12 +307,38 @@ def __setattr__(self, key, value): self._state[key] = value else: self._state[key].value = value - self._state[key].save() + + # At runtime (inside the event loop) persist via the async engine + # to avoid blocking the loop and contending with the async DB pool. + # At startup/import time, fall back to sync. + try: + loop = asyncio.get_running_loop() + loop.create_task(self._async_persist(key)) + except RuntimeError: + self._state[key].save() if self._redis and ENABLE_PERSISTENT_CONFIG: redis_key = f'{self._redis_key_prefix}:config:{key}' self._redis.set(redis_key, json.dumps(self._state[key].value)) + async def _async_persist(self, key): + """Persist a single config key via the async engine.""" + try: + await self._state[key].async_save() + except Exception as e: + log.error(f'Failed to async-persist config key {key}: {e}') + + def _sync_to_redis(self): + """Push all in-memory config values to Redis, e.g. after a bulk import.""" + if not self._redis or not ENABLE_PERSISTENT_CONFIG: + return + for key, pc in self._state.items(): + redis_key = f'{self._redis_key_prefix}:config:{key}' + try: + self._redis.set(redis_key, json.dumps(pc.value)) + except Exception as e: + log.error(f'Failed to sync config key {key} to Redis: {e}') + def __getattr__(self, key): if key not in self._state: raise AttributeError(f"Config key '{key}' not found") @@ -362,6 +449,18 @@ def __getattr__(self, key): os.environ.get('GOOGLE_REDIRECT_URI', ''), ) +GOOGLE_OAUTH_AUTHORIZE_PARAMS = {} +_google_oauth_authorize_params = os.environ.get('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '') +if _google_oauth_authorize_params: + try: + _parsed = json.loads(_google_oauth_authorize_params) + if isinstance(_parsed, dict): + GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + MICROSOFT_CLIENT_ID = PersistentConfig( 'MICROSOFT_CLIENT_ID', 'oauth.microsoft.client_id', @@ -642,6 +741,18 @@ def __getattr__(self, key): os.environ.get('OAUTH_AUDIENCE', ''), ) +OAUTH_AUTHORIZE_PARAMS = {} +_oauth_authorize_params = os.environ.get('OAUTH_AUTHORIZE_PARAMS', '') +if _oauth_authorize_params: + try: + _parsed = json.loads(_oauth_authorize_params) + if isinstance(_parsed, dict): + OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -658,11 +769,11 @@ def google_oauth_register(oauth: OAuth): **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=GOOGLE_REDIRECT_URI.value, + **({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) return client OAUTH_PROVIDERS['google'] = { - 'redirect_uri': GOOGLE_REDIRECT_URI.value, 'register': google_oauth_register, } @@ -683,7 +794,6 @@ def microsoft_oauth_register(oauth: OAuth): return client OAUTH_PROVIDERS['microsoft'] = { - 'redirect_uri': MICROSOFT_REDIRECT_URI.value, 'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value, 'register': microsoft_oauth_register, } @@ -708,7 +818,6 @@ def github_oauth_register(oauth: OAuth): return client OAUTH_PROVIDERS['github'] = { - 'redirect_uri': GITHUB_CLIENT_REDIRECT_URI.value, 'register': github_oauth_register, 'sub_claim': 'id', } @@ -750,7 +859,6 @@ def oidc_oauth_register(oauth: OAuth): OAUTH_PROVIDERS['oidc'] = { 'name': OAUTH_PROVIDER_NAME.value, - 'redirect_uri': OPENID_REDIRECT_URI.value, 'register': oidc_oauth_register, } @@ -894,6 +1002,7 @@ def feishu_oauth_register(oauth: OAuth): #################################### STORAGE_PROVIDER = os.environ.get('STORAGE_PROVIDER', 'local') # defaults to local, s3 +STORAGE_LOCAL_CACHE = os.environ.get('STORAGE_LOCAL_CACHE', 'true').lower() == 'true' S3_ACCESS_KEY_ID = os.environ.get('S3_ACCESS_KEY_ID', None) S3_SECRET_ACCESS_KEY = os.environ.get('S3_SECRET_ACCESS_KEY', None) @@ -1056,10 +1165,15 @@ def reachable(host: str, port: int) -> bool: ] OPENAI_API_BASE_URLS = PersistentConfig('OPENAI_API_BASE_URLS', 'openai.api_base_urls', OPENAI_API_BASE_URLS) +try: + _openai_api_configs_env = json.loads(os.environ.get('OPENAI_API_CONFIGS', '{}')) +except Exception: + _openai_api_configs_env = {} + OPENAI_API_CONFIGS = PersistentConfig( 'OPENAI_API_CONFIGS', 'openai.api_configs', - {}, + _openai_api_configs_env, ) # Get the actual OpenAI API key based on the base URL @@ -1099,6 +1213,12 @@ def reachable(host: str, port: int) -> bool: tool_server_connections, ) +OAUTH_CLIENT_TIMEOUT = PersistentConfig( + 'OAUTH_CLIENT_TIMEOUT', + 'oauth.client.timeout', + os.environ.get('OAUTH_CLIENT_TIMEOUT', ''), +) + #################################### # TERMINAL_SERVER #################################### @@ -1111,6 +1231,11 @@ def reachable(host: str, port: int) -> bool: terminal_server_connections, ) +try: + TERMINAL_PROXY_HEADERS = json.loads(os.environ.get('TERMINAL_PROXY_HEADERS', '{}')) +except Exception: + TERMINAL_PROXY_HEADERS = {} + #################################### # WEBUI #################################### @@ -1127,10 +1252,16 @@ def reachable(host: str, port: int) -> bool: ENABLE_LOGIN_FORM = PersistentConfig( 'ENABLE_LOGIN_FORM', - 'ui.ENABLE_LOGIN_FORM', + 'ui.enable_login_form', os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true', ) +ENABLE_PASSWORD_CHANGE_FORM = PersistentConfig( + 'ENABLE_PASSWORD_CHANGE_FORM', + 'ui.enable_password_change_form', + os.environ.get('ENABLE_PASSWORD_CHANGE_FORM', 'True').lower() == 'true', +) + ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true' DEFAULT_LOCALE = PersistentConfig( @@ -1195,16 +1326,28 @@ def reachable(host: str, port: int) -> bool: [], ) +try: + default_model_metadata = json.loads(os.environ.get('DEFAULT_MODEL_METADATA', '{}')) +except Exception as e: + log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}') + default_model_metadata = {} + DEFAULT_MODEL_METADATA = PersistentConfig( 'DEFAULT_MODEL_METADATA', 'models.default_metadata', - {}, + default_model_metadata, ) +try: + default_model_params = json.loads(os.environ.get('DEFAULT_MODEL_PARAMS', '{}')) +except Exception as e: + log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}') + default_model_params = {} + DEFAULT_MODEL_PARAMS = PersistentConfig( 'DEFAULT_MODEL_PARAMS', 'models.default_params', - {}, + default_model_params, ) DEFAULT_USER_ROLE = PersistentConfig( @@ -1238,6 +1381,7 @@ def reachable(host: str, port: int) -> bool: os.environ.get('RESPONSE_WATERMARK', ''), ) +IFRAME_CSP = os.environ.get('IFRAME_CSP', '') USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = ( os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true' @@ -1332,6 +1476,10 @@ def reachable(host: str, port: int) -> bool: os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) +USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = ( + os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' +) + USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true' ) @@ -1367,6 +1515,10 @@ def reachable(host: str, port: int) -> bool: USER_PERMISSIONS_CHAT_SHARE = os.environ.get('USER_PERMISSIONS_CHAT_SHARE', 'True').lower() == 'true' +USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = ( + os.environ.get('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' +) + USER_PERMISSIONS_CHAT_EXPORT = os.environ.get('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_STT = os.environ.get('USER_PERMISSIONS_CHAT_STT', 'True').lower() == 'true' @@ -1410,6 +1562,12 @@ def reachable(host: str, port: int) -> bool: USER_PERMISSIONS_FEATURES_MEMORIES = os.environ.get('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true' +USER_PERMISSIONS_FEATURES_AUTOMATIONS = ( + os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true' +) + +USER_PERMISSIONS_FEATURES_CALENDAR = os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true' + USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true' @@ -1441,6 +1599,8 @@ def reachable(host: str, port: int) -> bool: 'public_skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING, 'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING, 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, + 'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING, + 'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING, }, 'access_grants': { 'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, @@ -1479,6 +1639,8 @@ def reachable(host: str, port: int) -> bool: 'image_generation': USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, 'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, 'memories': USER_PERMISSIONS_FEATURES_MEMORIES, + 'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS, + 'calendar': USER_PERMISSIONS_FEATURES_CALENDAR, }, 'settings': { 'interface': USER_PERMISSIONS_SETTINGS_INTERFACE, @@ -1509,6 +1671,30 @@ def reachable(host: str, port: int) -> bool: os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true', ) +ENABLE_CALENDAR = PersistentConfig( + 'ENABLE_CALENDAR', + 'calendar.enable', + os.environ.get('ENABLE_CALENDAR', 'True').lower() == 'true', +) + +ENABLE_AUTOMATIONS = PersistentConfig( + 'ENABLE_AUTOMATIONS', + 'automations.enable', + os.environ.get('ENABLE_AUTOMATIONS', 'True').lower() == 'true', +) + +AUTOMATION_MAX_COUNT = PersistentConfig( + 'AUTOMATION_MAX_COUNT', + 'automations.max_count', + os.environ.get('AUTOMATION_MAX_COUNT', ''), +) + +AUTOMATION_MIN_INTERVAL = PersistentConfig( + 'AUTOMATION_MIN_INTERVAL', + 'automations.min_interval', + os.environ.get('AUTOMATION_MIN_INTERVAL', ''), +) + ENABLE_NOTES = PersistentConfig( 'ENABLE_NOTES', 'notes.enable', @@ -1577,7 +1763,7 @@ def reachable(host: str, port: int) -> bool: ENABLE_USER_WEBHOOKS = PersistentConfig( 'ENABLE_USER_WEBHOOKS', 'ui.enable_user_webhooks', - os.environ.get('ENABLE_USER_WEBHOOKS', 'True').lower() == 'true', + os.environ.get('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true', ) # FastAPI / AnyIO settings @@ -1909,6 +2095,12 @@ class BannerModel(BaseModel): os.environ.get('VOICE_MODE_PROMPT_TEMPLATE', ''), ) +ENABLE_VOICE_MODE_PROMPT = PersistentConfig( + 'ENABLE_VOICE_MODE_PROMPT', + 'task.voice.prompt.enable', + os.environ.get('ENABLE_VOICE_MODE_PROMPT', 'True').lower() == 'true', +) + DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant. Everything you say will be spoken aloud. @@ -2485,13 +2677,17 @@ class BannerModel(BaseModel): ) -ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' -ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' - ONEDRIVE_CLIENT_ID = os.environ.get('ONEDRIVE_CLIENT_ID', '') ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get('ONEDRIVE_CLIENT_ID_PERSONAL', ONEDRIVE_CLIENT_ID) ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get('ONEDRIVE_CLIENT_ID_BUSINESS', ONEDRIVE_CLIENT_ID) +ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' and bool( + ONEDRIVE_CLIENT_ID_PERSONAL +) +ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' and bool( + ONEDRIVE_CLIENT_ID_BUSINESS +) + ONEDRIVE_SHAREPOINT_URL = PersistentConfig( 'ONEDRIVE_SHAREPOINT_URL', 'onedrive.sharepoint_url', @@ -2685,6 +2881,18 @@ class BannerModel(BaseModel): os.getenv('MISTRAL_OCR_API_KEY', ''), ) +PADDLEOCR_VL_BASE_URL = PersistentConfig( + 'PADDLEOCR_VL_BASE_URL', + 'rag.paddleocr_vl_base_url', + os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080'), +) + +PADDLEOCR_VL_TOKEN = PersistentConfig( + 'PADDLEOCR_VL_TOKEN', + 'rag.paddleocr_vl_token', + os.getenv('PADDLEOCR_VL_TOKEN', ''), +) + BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig( 'BYPASS_EMBEDDING_AND_RETRIEVAL', 'rag.bypass_embedding_and_retrieval', @@ -2838,6 +3046,12 @@ class BannerModel(BaseModel): os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' ) +RAG_RERANKING_BATCH_SIZE = PersistentConfig( + 'RAG_RERANKING_BATCH_SIZE', + 'rag.reranking_batch_size', + int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')), +) + RAG_EXTERNAL_RERANKER_URL = PersistentConfig( 'RAG_EXTERNAL_RERANKER_URL', 'rag.external_reranker_url', @@ -3059,7 +3273,7 @@ class BannerModel(BaseModel): WEB_FETCH_MAX_CONTENT_LENGTH = PersistentConfig( 'WEB_FETCH_MAX_CONTENT_LENGTH', - 'rag.web.search.fetch_url_max_content_length', + 'rag.web.fetch.max_content_length', (int(os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH') else None), ) @@ -3092,7 +3306,7 @@ class BannerModel(BaseModel): WEB_SEARCH_TRUST_ENV = PersistentConfig( 'WEB_SEARCH_TRUST_ENV', 'rag.web.search.trust_env', - os.getenv('WEB_SEARCH_TRUST_ENV', 'False').lower() == 'true', + os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true', ) @@ -3150,6 +3364,12 @@ class BannerModel(BaseModel): os.getenv('BRAVE_SEARCH_API_KEY', ''), ) +BRAVE_SEARCH_CONTEXT_TOKENS = PersistentConfig( + 'BRAVE_SEARCH_CONTEXT_TOKENS', + 'rag.web.search.brave_search_context_tokens', + int(os.getenv('BRAVE_SEARCH_CONTEXT_TOKENS', '8192')), +) + KAGI_SEARCH_API_KEY = PersistentConfig( 'KAGI_SEARCH_API_KEY', 'rag.web.search.kagi_search_api_key', @@ -3787,6 +4007,19 @@ class BannerModel(BaseModel): ], ) +AUDIO_STT_ALLOWED_EXTENSIONS = PersistentConfig( + 'AUDIO_STT_ALLOWED_EXTENSIONS', + 'audio.stt.allowed_extensions', + [ + ext.strip() + for ext in os.environ.get( + 'AUDIO_STT_ALLOWED_EXTENSIONS', + 'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg', + ).split(',') + if ext.strip() + ], +) + AUDIO_STT_AZURE_API_KEY = PersistentConfig( 'AUDIO_STT_AZURE_API_KEY', 'audio.stt.azure.api_key', @@ -3908,6 +4141,18 @@ class BannerModel(BaseModel): os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3'), ) +AUDIO_TTS_MISTRAL_API_KEY = PersistentConfig( + 'AUDIO_TTS_MISTRAL_API_KEY', + 'audio.tts.mistral.api_key', + os.getenv('AUDIO_TTS_MISTRAL_API_KEY', ''), +) + +AUDIO_TTS_MISTRAL_API_BASE_URL = PersistentConfig( + 'AUDIO_TTS_MISTRAL_API_BASE_URL', + 'audio.tts.mistral.api_base_url', + os.getenv('AUDIO_TTS_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'), +) + #################################### # LDAP diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index ec1d0c60477..034a1a30488 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -9,7 +9,7 @@ class MESSAGES(str, Enum): class WEBHOOK_MESSAGES(str, Enum): DEFAULT = lambda msg='': f'{msg if msg else ""}' - USER_SIGNUP = lambda username='': (f'New user signed up: {username}' if username else 'New user signed up') + USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up' class ERROR_MESSAGES(str, Enum): @@ -80,8 +80,8 @@ def __str__(self) -> str: OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.' - FILE_TOO_LARGE = ( - lambda size='': f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}." + FILE_TOO_LARGE = lambda size='': ( + f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}." ) DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.' @@ -89,7 +89,18 @@ def __str__(self) -> str: 'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.' ) - INVALID_PASSWORD = lambda err='': (err if err else 'The password does not meet the required validation criteria.') + INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.' + + AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})' + 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' + + FEATURE_DISABLED = lambda name='': f'{name} is disabled' + INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}' + 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" class TASKS(str, Enum): @@ -106,3 +117,4 @@ def __str__(self) -> str: AUTOCOMPLETE_GENERATION = 'autocomplete_generation' FUNCTION_CALLING = 'function_calling' MOA_RESPONSE_GENERATION = 'moa_response_generation' + MODEL_RECOMMENDATION = 'model_recommendation' diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 043fa3c6dfb..903b3effcc1 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -1,21 +1,21 @@ +import datetime as dt import importlib.metadata import json import logging import os import pkgutil -import sys +import re import shutil +import sys import traceback -from datetime import datetime, timezone +from pathlib import Path from typing import Any from uuid import uuid4 -from pathlib import Path -from cryptography.hazmat.primitives import serialization -import re - import markdown from bs4 import BeautifulSoup +from cryptography.hazmat.primitives import serialization + from open_webui.constants import ERROR_MESSAGES #################################### @@ -43,7 +43,8 @@ DOCKER = os.environ.get('DOCKER', 'False').lower() == 'true' -# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance +# device type for embedding models - "cpu" (default), "cuda" (nvidia gpu required), or "mps" (apple silicon) +# choosing this correctly can lead to better performance USE_CUDA = os.environ.get('USE_CUDA_DOCKER', 'false') if USE_CUDA.lower() == 'true': @@ -60,13 +61,14 @@ else: DEVICE_TYPE = 'cpu' -try: - import torch +if sys.platform == 'darwin': + try: + import torch - if torch.backends.mps.is_available() and torch.backends.mps.is_built(): - DEVICE_TYPE = 'mps' -except Exception: - pass + if torch.backends.mps.is_available() and torch.backends.mps.is_built(): + DEVICE_TYPE = 'mps' + except Exception: + pass #################################### # LOGGING @@ -86,7 +88,7 @@ class JSONFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: log_entry: dict[str, Any] = { - 'ts': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec='milliseconds'), + 'ts': dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(timespec='milliseconds'), 'level': _LEVEL_MAP.get(record.levelname, record.levelname.lower()), 'msg': record.getMessage(), 'caller': record.name, @@ -179,7 +181,7 @@ def parse_section(section): try: changelog_path = BASE_DIR / 'CHANGELOG.md' - with open(str(changelog_path.absolute()), 'r', encoding='utf8') as file: + with open(str(changelog_path.absolute()), encoding='utf8') as file: changelog_content = file.read() except Exception: @@ -247,6 +249,26 @@ def parse_section(section): ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true' +#################################### +# ENABLE_PROFILE_IMAGE_URL_FORWARDING +#################################### + +# When True (default), the user and model profile-image endpoints +# honour external http(s) URLs stored in profile_image_url by issuing a +# 302 redirect to the original origin. Set to False to suppress the +# redirect (prevents client-side IP/UA/Referer leaks to attacker- +# controlled origins) and fall through to the default image instead. +ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get('ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True').lower() == 'true' + +PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset( + t.strip() + for t in os.environ.get( + 'PROFILE_IMAGE_ALLOWED_MIME_TYPES', + 'image/png,image/jpeg,image/gif,image/webp', + ).split(',') + if t.strip() +) + #################################### # WEBUI_BUILD_HASH #################################### @@ -338,7 +360,7 @@ def parse_section(section): DATABASE_POOL_SIZE = os.environ.get('DATABASE_POOL_SIZE', None) -if DATABASE_POOL_SIZE != None: +if DATABASE_POOL_SIZE is not None: try: DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE) except Exception: @@ -374,7 +396,35 @@ def parse_section(section): except Exception: DATABASE_POOL_RECYCLE = 3600 -DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'False').lower() == 'true' +DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'True').lower() == 'true' + +# SQLite PRAGMA tuning — these defaults are optimised for WAL-mode web-server +# workloads. Each can be overridden via its environment variable. +# Set any value to an empty string to skip that PRAGMA entirely. + +# PRAGMA synchronous: NORMAL (1) is safe with WAL and avoids an fsync per +# transaction. Valid values: OFF (0), NORMAL (1), FULL (2), EXTRA (3). +DATABASE_SQLITE_PRAGMA_SYNCHRONOUS = os.environ.get('DATABASE_SQLITE_PRAGMA_SYNCHRONOUS', 'NORMAL') + +# PRAGMA busy_timeout (ms): how long a connection waits for a write lock +# before raising SQLITE_BUSY. +DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT = os.environ.get('DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT', '5000') + +# PRAGMA cache_size: negative value = KiB. -65536 ≈ 64 MB page cache. +DATABASE_SQLITE_PRAGMA_CACHE_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_CACHE_SIZE', '-65536') + +# PRAGMA temp_store: MEMORY (2) keeps temp tables and indices in RAM. +# Valid values: DEFAULT (0), FILE (1), MEMORY (2). +DATABASE_SQLITE_PRAGMA_TEMP_STORE = os.environ.get('DATABASE_SQLITE_PRAGMA_TEMP_STORE', 'MEMORY') + +# PRAGMA mmap_size (bytes): memory-mapped I/O size. 268435456 ≈ 256 MB. +# Set to 0 to disable mmap. +DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', '268435456') + +# PRAGMA journal_size_limit (bytes): caps the WAL file size after checkpoint. +# Without this the WAL grows unbounded during write bursts and is never +# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default). +DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.environ.get('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864') DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None) if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None: @@ -425,6 +475,27 @@ def parse_section(section): except ValueError: REDIS_SOCKET_CONNECT_TIMEOUT = None +# Whether to enable TCP SO_KEEPALIVE on Redis client sockets. Opt-in: +# defaults to off so behavior is unchanged for existing deployments. When +# enabled, the kernel sends TCP keepalive probes on idle connections so +# half-closed sockets (e.g. after a silent firewall/LB reset or a NIC +# flap) are detected before the next command lands on them. +REDIS_SOCKET_KEEPALIVE = os.environ.get('REDIS_SOCKET_KEEPALIVE', 'False').lower() == 'true' + +# How often (in seconds) redis-py should PING an idle pooled connection +# before reusing it. Opt-in: defaults to unset (empty string) so behavior +# is unchanged for existing deployments. When set, should be shorter than +# the Redis server `timeout` setting and any firewall/LB idle timeout on +# the path to Redis, so stale connections are detected before a real +# command lands on them. Set to 0 or empty to disable. +REDIS_HEALTH_CHECK_INTERVAL = os.environ.get('REDIS_HEALTH_CHECK_INTERVAL', '') +try: + REDIS_HEALTH_CHECK_INTERVAL = int(REDIS_HEALTH_CHECK_INTERVAL) + if REDIS_HEALTH_CHECK_INTERVAL <= 0: + REDIS_HEALTH_CHECK_INTERVAL = None +except ValueError: + REDIS_HEALTH_CHECK_INTERVAL = None + REDIS_RECONNECT_DELAY = os.environ.get('REDIS_RECONNECT_DELAY', '') if REDIS_RECONNECT_DELAY == '': @@ -473,7 +544,14 @@ def parse_section(section): WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_EMAIL_HEADER', None) WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_NAME_HEADER', None) WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_GROUPS_HEADER', None) +WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_ROLE_HEADER', None) +# Custom header name for API key authentication. Defaults to 'x-api-key'. +# Useful when Open WebUI sits behind a reverse proxy / API gateway that +# already uses the Authorization header for its own authentication — set +# this to a unique header (e.g. 'X-OpenWebUI-Key') so the middleware +# checks the custom header instead and avoids the 401 short-circuit. +CUSTOM_API_KEY_HEADER = os.environ.get('CUSTOM_API_KEY_HEADER', 'x-api-key') ENABLE_PASSWORD_VALIDATION = os.environ.get('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true' PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get( @@ -494,6 +572,16 @@ def parse_section(section): BYPASS_MODEL_ACCESS_CONTROL = os.environ.get('BYPASS_MODEL_ACCESS_CONTROL', 'False').lower() == 'true' +# When enabled, skips pydub-based preprocessing (format conversion, compression, +# and chunked splitting) before sending files to processing engines. Useful when +# the upstream provider handles these steps or when ffmpeg is unavailable. +BYPASS_PYDUB_PREPROCESSING = os.environ.get('BYPASS_PYDUB_PREPROCESSING', 'False').lower() == 'true' + +# When disabled (default), the OpenAI catch-all proxy endpoint (/{path:path}) +# is blocked. Enable only if you need direct passthrough to upstream OpenAI- +# compatible APIs for endpoints not natively handled by Open WebUI. +ENABLE_OPENAI_API_PASSTHROUGH = os.environ.get('ENABLE_OPENAI_API_PASSTHROUGH', 'False').lower() == 'true' + WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get('WEBUI_AUTH_SIGNOUT_REDIRECT_URL', None) #################################### @@ -543,6 +631,12 @@ def parse_section(section): # Allows external apps to exchange OAuth tokens for OpenWebUI tokens ENABLE_OAUTH_TOKEN_EXCHANGE = os.environ.get('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true' +# Back-Channel Logout Configuration +# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout +# per OpenID Connect Back-Channel Logout 1.0 spec. +# Requires Redis for JWT revocation. +ENABLE_OAUTH_BACKCHANNEL_LOGOUT = os.environ.get('ENABLE_OAUTH_BACKCHANNEL_LOGOUT', 'False').lower() == 'true' + #################################### # SCIM Configuration #################################### @@ -579,7 +673,7 @@ def parse_section(section): -----BEGIN PUBLIC KEY----- {LICENSE_PUBLIC_KEY} -----END PUBLIC KEY----- -""".encode('utf-8') +""".encode() ) @@ -607,6 +701,15 @@ def parse_section(section): os.environ.get('ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION', 'False').lower() == 'true' ) +# When enabled, uses a hardcoded extension-to-MIME dictionary as a last-resort +# fallback when both mimetypes.guess_type() and file.meta.content_type fail to +# determine the content type. This can help on minimal container images (e.g. +# wolfi-base) that lack /etc/mime.types AND have legacy files without stored +# content_type metadata. +ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK = ( + os.environ.get('ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK', 'False').lower() == 'true' +) + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get('CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE', '1') if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == '': @@ -629,6 +732,13 @@ def parse_section(section): CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 +# WARNING: Experimental. Only enable if your upstream Responses API endpoint +# supports stateful sessions (i.e. server-side response storage with +# previous_response_id anchoring). Most proxies and third-party endpoints +# are stateless and will break if this is enabled. +ENABLE_RESPONSES_API_STATEFUL = os.environ.get('ENABLE_RESPONSES_API_STATEFUL', 'False').lower() == 'true' + + CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get('CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE', '') if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == '': @@ -723,6 +833,13 @@ def parse_section(section): AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true' +# When False (default), outbound HTTP requests do not follow 3xx redirects. +# This prevents redirect-based SSRF where a public URL 302-redirects to an +# internal address (RFC 1918, loopback, cloud-metadata 169.254.169.254). +# Set to True only if your deployment requires redirect following and you +# have other SSRF protections in place (e.g. egress firewall). +AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.environ.get('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true' + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( 'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST', os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'), @@ -752,6 +869,46 @@ def parse_section(section): os.environ.get('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True').lower() == 'true' ) +AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = os.environ.get('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER', '') + +if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER == '': + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT +else: + try: + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = int(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) + except Exception: + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT + + +#################################### +# AIOHTTP Connection Pool +#################################### + +AIOHTTP_POOL_CONNECTIONS = os.environ.get('AIOHTTP_POOL_CONNECTIONS', '') +if AIOHTTP_POOL_CONNECTIONS == '': + AIOHTTP_POOL_CONNECTIONS = None +else: + try: + AIOHTTP_POOL_CONNECTIONS = int(AIOHTTP_POOL_CONNECTIONS) + except ValueError: + AIOHTTP_POOL_CONNECTIONS = None + +AIOHTTP_POOL_CONNECTIONS_PER_HOST = os.environ.get('AIOHTTP_POOL_CONNECTIONS_PER_HOST', '') +if AIOHTTP_POOL_CONNECTIONS_PER_HOST == '': + AIOHTTP_POOL_CONNECTIONS_PER_HOST = None +else: + try: + AIOHTTP_POOL_CONNECTIONS_PER_HOST = int(AIOHTTP_POOL_CONNECTIONS_PER_HOST) + except ValueError: + AIOHTTP_POOL_CONNECTIONS_PER_HOST = None + +AIOHTTP_POOL_DNS_TTL = os.environ.get('AIOHTTP_POOL_DNS_TTL', '300') +try: + AIOHTTP_POOL_DNS_TTL = int(AIOHTTP_POOL_DNS_TTL) + if AIOHTTP_POOL_DNS_TTL < 0: + AIOHTTP_POOL_DNS_TTL = 300 +except ValueError: + AIOHTTP_POOL_DNS_TTL = 300 RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '') @@ -856,6 +1013,9 @@ def parse_section(section): AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS] AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path] +# When enabled, GET requests are also audited (disabled by default to avoid log noise) +ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true' + #################################### # OPENTELEMETRY diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 9bfe77c41e9..a3f99bb1824 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -34,9 +34,10 @@ load_function_module_by_id, get_function_module_from_cache, ) -from open_webui.utils.tools import get_tools +from open_webui.utils.access_control import check_model_access -from open_webui.env import GLOBAL_LOG_LEVEL +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, @@ -54,12 +55,12 @@ log = logging.getLogger(__name__) -def get_function_module_by_id(request: Request, pipe_id: str): - function_module, _, _ = get_function_module_from_cache(request, pipe_id) +async def get_function_module_by_id(request: Request, pipe_id: str): + function_module, _, _ = await get_function_module_from_cache(request, pipe_id) if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): Valves = function_module.Valves - valves = Functions.get_function_valves_by_id(pipe_id) + valves = await Functions.get_function_valves_by_id(pipe_id) if valves: try: @@ -74,12 +75,12 @@ def get_function_module_by_id(request: Request, pipe_id: str): async def get_function_models(request): - pipes = Functions.get_functions_by_type('pipe', active_only=True) + pipes = await Functions.get_functions_by_type('pipe', active_only=True) pipe_models = [] for pipe in pipes: try: - function_module = get_function_module_by_id(request, pipe.id) + function_module = await get_function_module_by_id(request, pipe.id) has_user_valves = False if hasattr(function_module, 'UserValves'): @@ -188,7 +189,7 @@ def get_pipe_id(form_data: dict) -> str: pipe_id, _ = pipe_id.split('.', 1) return pipe_id - def get_function_params(function_module, form_data, user, extra_params=None): + async def get_function_params(function_module, form_data, user, extra_params=None): if extra_params is None: extra_params = {} @@ -199,7 +200,7 @@ def get_function_params(function_module, form_data, user, extra_params=None): params = {'body': form_data} | {k: v for k, v in extra_params.items() if k in sig.parameters} if '__user__' in params and hasattr(function_module, 'UserValves'): - user_valves = Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id) + user_valves = await Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id) try: params['__user__']['valves'] = function_module.UserValves(**user_valves) except Exception as e: @@ -209,7 +210,7 @@ def get_function_params(function_module, form_data, user, extra_params=None): return params model_id = form_data.get('model') - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) metadata = form_data.pop('metadata', {}) @@ -226,18 +227,31 @@ def get_function_params(function_module, form_data, user, extra_params=None): if metadata: if all(k in metadata for k in ('session_id', 'chat_id', 'message_id')): - __event_emitter__ = get_event_emitter(metadata) - __event_call__ = get_event_call(metadata) + __event_emitter__ = await get_event_emitter(metadata) + __event_call__ = await get_event_call(metadata) __task__ = metadata.get('task', None) __task_body__ = metadata.get('task_body', None) oauth_token = None try: - if request.cookies.get('oauth_session_id', None): + oauth_session_id = request.cookies.get('oauth_session_id', None) + if oauth_session_id: oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get('oauth_session_id', None), + oauth_session_id, ) + + # Fallback: no cookie (automation, API key, etc.) — use most recent session + if oauth_token is None: + from open_webui.models.oauth_sessions import OAuthSessions + + sessions = await OAuthSessions.get_sessions_by_user_id(user.id) + if sessions: + best = max(sessions, key=lambda s: s.updated_at) + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + best.id, + ) except Exception as e: log.error(f'Error getting OAuth token: {e}') @@ -255,34 +269,28 @@ def get_function_params(function_module, form_data, user, extra_params=None): '__oauth_token__': oauth_token, '__request__': request, } - extra_params['__tools__'] = await get_tools( - request, - tool_ids, - user, - { - **extra_params, - '__model__': models.get(form_data['model'], None), - '__messages__': form_data['messages'], - '__files__': files, - }, - ) + extra_params['__tools__'] = metadata.get('tools', {}) if model_info: if model_info.base_model_id: form_data['model'] = model_info.base_model_id + if not BYPASS_MODEL_ACCESS_CONTROL: + bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL + await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass) + params = model_info.params.model_dump() if params: system = params.pop('system', None) form_data = apply_model_params_to_body_openai(params, form_data) - form_data = 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 = get_function_module_by_id(request, pipe_id) + function_module = await get_function_module_by_id(request, pipe_id) pipe = function_module.pipe - params = get_function_params(function_module, form_data, user, extra_params) + params = await get_function_params(function_module, form_data, user, extra_params) if form_data.get('stream', False): diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index b0545255a68..9a6576fd7c1 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -1,8 +1,10 @@ import os +import sys import json import logging -from contextlib import contextmanager +from contextlib import asynccontextmanager, contextmanager 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 ( @@ -15,10 +17,17 @@ DATABASE_POOL_TIMEOUT, 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_TEMP_STORE, + DATABASE_SQLITE_PRAGMA_MMAP_SIZE, + DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT, ENABLE_DB_MIGRATIONS, ) 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 scoped_session, sessionmaker, Session from sqlalchemy.pool import QueuePool, NullPool @@ -28,6 +37,86 @@ log = logging.getLogger(__name__) +# ── SSL URL normalization (used by sync engine & Alembic migrations) ─ +# +# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does +# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers +# below strip all SSL-related query params, normalise them, and +# reattach them in the canonical libpq form. +# +# The **async** engine now uses psycopg (v3), which speaks libpq +# natively, so it needs no translation at all — the DATABASE_URL is +# passed through as-is. +# ───────────────────────────────────────────────────────────────────── + + +def _pop_first(params: dict[str, list[str]], key: str) -> str | None: + """Pop a single-valued query param, returning ``None`` if absent.""" + values = params.pop(key, None) + return values[0] if values else None + + +def _is_postgres_url(url: str) -> bool: + """Return True if *url* looks like a PostgreSQL connection string.""" + return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://')) + + +def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]: + """Strip SSL query-string parameters from a PostgreSQL URL. + + Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps + canonical libpq key names (``sslmode``, ``sslrootcert``, …) to + their values. Non-PostgreSQL URLs are returned unchanged with an + empty dict. + """ + if not _is_postgres_url(url): + return url, {} + + parsed = urlparse(url) + qp = parse_qs(parsed.query, keep_blank_values=True) + + # Prefer sslmode (libpq canonical) over the bare ``ssl`` key. + sslmode_val = _pop_first(qp, 'sslmode') + ssl_val = _pop_first(qp, 'ssl') + ssl_mode = sslmode_val or ssl_val + + ssl_dict: dict[str, str] = {} + if ssl_mode: + ssl_dict['sslmode'] = ssl_mode + for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'): + val = _pop_first(qp, key) + if val: + ssl_dict[key] = val + + if not ssl_dict: + return url, ssl_dict + + cleaned_query = urlencode(qp, doseq=True) + return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict + + +def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str: + """Re-append SSL query-string parameters to a cleaned PostgreSQL URL. + + Used for psycopg2/libpq consumers that expect ``sslmode`` and the + certificate-file keys in the connection string. + """ + if not ssl_dict: + return url_without_ssl + + parts = [f'{k}={v}' for k, v in ssl_dict.items() if v] + if not parts: + return url_without_ssl + + sep = '&' if '?' in url_without_ssl else '?' + return f'{url_without_ssl}{sep}{"&".join(parts)}' + + +# Backwards-compatible aliases for external callers. +extract_ssl_mode_from_url = extract_ssl_params_from_url +reattach_ssl_mode_to_url = reattach_ssl_params_to_url + + class JSONField(types.TypeDecorator): impl = types.Text cache_ok = True @@ -53,10 +142,15 @@ def python_value(self, 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 + 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(DATABASE_URL.replace('postgresql://', 'postgres://')) + 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() @@ -72,14 +166,52 @@ def handle_peewee_migration(DATABASE_URL): db.close() # Assert if db connection has been closed - assert db.is_closed(), 'Database connection is still open.' + if db is not None: + assert db.is_closed(), 'Database connection is still open.' if ENABLE_DB_MIGRATIONS: handle_peewee_migration(DATABASE_URL) -SQLALCHEMY_DATABASE_URL = DATABASE_URL +# Normalize SSL params from the URL once; the sync engine needs them +# reattached in canonical libpq form for psycopg2. +_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL) + +# For psycopg2 (sync engine), re-append sslmode + cert-file params. +SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL + + +def _make_async_url(url: str) -> str: + """Convert a sync database URL to its async driver equivalent. + + The async engine uses psycopg (v3) which speaks libpq natively, + so all standard connection-string parameters (``sslmode``, + ``options``, ``target_session_attrs``, etc.) are passed through + without any translation. + """ + if url.startswith('sqlite+sqlcipher://'): + raise ValueError( + 'sqlite+sqlcipher:// URLs are not supported with async engine. ' + 'Use standard sqlite:// or postgresql:// instead.' + ) + if url.startswith('sqlite:///') or url.startswith('sqlite://'): + return url.replace('sqlite://', 'sqlite+aiosqlite://', 1) + # psycopg v3 — auto-selects async mode with create_async_engine + if url.startswith('postgresql+psycopg2://'): + return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1) + if url.startswith('postgresql://'): + return url.replace('postgresql://', 'postgresql+psycopg://', 1) + if url.startswith('postgres://'): + return url.replace('postgres://', 'postgresql+psycopg://', 1) + # For other dialects, return as-is and let SQLAlchemy handle it + return url + + +# ============================================================ +# SYNC ENGINE (used only for: startup migrations, config loading, +# Alembic, peewee migration, health checks) +# ============================================================ # Handle SQLCipher URLs if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): @@ -128,14 +260,32 @@ def create_sqlcipher_connection(): elif 'sqlite' in SQLALCHEMY_DATABASE_URL: engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}) - def on_connect(dbapi_connection, connection_record): + def _apply_sqlite_pragmas(dbapi_connection): + """Apply all configured SQLite PRAGMAs to a raw DBAPI connection.""" cursor = dbapi_connection.cursor() if DATABASE_ENABLE_SQLITE_WAL: cursor.execute('PRAGMA journal_mode=WAL') else: cursor.execute('PRAGMA journal_mode=DELETE') + + # Each PRAGMA is skipped when its env var is empty, allowing opt-out. + if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS: + cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}') + if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT: + cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}') + if DATABASE_SQLITE_PRAGMA_CACHE_SIZE: + cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}') + if DATABASE_SQLITE_PRAGMA_TEMP_STORE: + cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}') + if DATABASE_SQLITE_PRAGMA_MMAP_SIZE: + cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}') + if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT: + cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}') cursor.close() + def on_connect(dbapi_connection, connection_record): + _apply_sqlite_pragmas(dbapi_connection) + event.listen(engine, 'connect', on_connect) else: if isinstance(DATABASE_POOL_SIZE, int): @@ -155,6 +305,7 @@ def on_connect(dbapi_connection, connection_record): engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) +# Sync session — used ONLY for startup config loading (config.py runs at import time) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) metadata_obj = MetaData(schema=DATABASE_SCHEMA) Base = declarative_base(metadata=metadata_obj) @@ -162,6 +313,7 @@ def on_connect(dbapi_connection, connection_record): def get_session(): + """Sync session generator — used ONLY for startup/config operations.""" db = SessionLocal() try: yield db @@ -172,10 +324,96 @@ def get_session(): get_db = contextmanager(get_session) -@contextmanager -def get_db_context(db: Optional[Session] = None): - if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING: +# ============================================================ +# ASYNC ENGINE (used for ALL runtime database operations) +# ============================================================ + +# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed +# through as-is. SSL params, ``options``, ``target_session_attrs``, etc. +# all work without any stripping or translation. +ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL) + +# psycopg v3 cannot run in async mode under Windows' default +# ProactorEventLoop — switch to SelectorEventLoop before creating +# the async engine. This runs at import time, which is early enough +# to cover every entry point (workers, reload, direct invocations). +if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL): + import asyncio + + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: + # Generous default — async coroutines + no session sharing = high connection demand. + _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_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') + def _set_sqlite_pragmas(dbapi_connection, connection_record): + _apply_sqlite_pragmas(dbapi_connection) +else: + if isinstance(DATABASE_POOL_SIZE, int): + if DATABASE_POOL_SIZE > 0: + async_engine = create_async_engine( + ASYNC_SQLALCHEMY_DATABASE_URL, + pool_size=DATABASE_POOL_SIZE, + max_overflow=DATABASE_POOL_MAX_OVERFLOW, + pool_timeout=DATABASE_POOL_TIMEOUT, + pool_recycle=DATABASE_POOL_RECYCLE, + pool_pre_ping=True, + ) + else: + async_engine = create_async_engine( + ASYNC_SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + poolclass=NullPool, + ) + else: + async_engine = create_async_engine( + ASYNC_SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + ) + + +AsyncSessionLocal = async_sessionmaker( + bind=async_engine, + class_=AsyncSession, + autocommit=False, + autoflush=False, + expire_on_commit=False, +) + + +async def get_async_session(): + """Async session generator for FastAPI Depends().""" + async with AsyncSessionLocal() as db: + try: + yield db + finally: + await db.close() + + +@asynccontextmanager +async def get_async_db(): + """Async context manager for use outside of FastAPI dependency injection.""" + async with AsyncSessionLocal() as db: + try: + yield db + finally: + await db.close() + + +@asynccontextmanager +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 else: - with get_db() as session: + async with get_async_db() as session: yield session diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 7e3bde3c7c3..d47c88fc540 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -21,7 +21,7 @@ from aiocache import cached import aiohttp import anyio.to_thread -import requests + from redis import Redis @@ -46,7 +46,6 @@ from starlette_compress import CompressMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException -from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response, StreamingResponse from starlette.datastructures import Headers @@ -58,8 +57,15 @@ from starsessions.stores.redis import RedisStore from open_webui.utils import logger +from open_webui.utils.asgi_middleware import ( + AuthTokenMiddleware, + CommitSessionMiddleware, + RedirectMiddleware, + WebsocketUpgradeGuardMiddleware, +) from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware from open_webui.utils.logger import start_logger +from open_webui.utils.session_pool import get_session from open_webui.socket.main import ( MODELS, app as socket_app, @@ -67,6 +73,7 @@ periodic_session_pool_cleanup, get_event_emitter, get_models_in_use, + get_user_id_from_session_pool, ) from open_webui.routers import ( analytics, @@ -97,6 +104,8 @@ utils, scim, terminals, + automations, + calendar, ) from open_webui.routers.retrieval import ( @@ -107,13 +116,13 @@ ) -from sqlalchemy.orm import Session -from open_webui.internal.db import ScopedSession, engine, get_session +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import ScopedSession, engine, get_async_session from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel, Users -from open_webui.models.chats import Chats +from open_webui.models.chats import Chats, ChatForm from open_webui.config import ( # Ollama @@ -190,6 +199,7 @@ AUDIO_STT_ENGINE, AUDIO_STT_MODEL, AUDIO_STT_SUPPORTED_CONTENT_TYPES, + AUDIO_STT_ALLOWED_EXTENSIONS, AUDIO_STT_OPENAI_API_BASE_URL, AUDIO_STT_OPENAI_API_KEY, AUDIO_STT_AZURE_API_KEY, @@ -211,6 +221,8 @@ AUDIO_TTS_AZURE_SPEECH_REGION, AUDIO_TTS_AZURE_SPEECH_BASE_URL, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, + AUDIO_TTS_MISTRAL_API_KEY, + AUDIO_TTS_MISTRAL_API_BASE_URL, PLAYWRIGHT_WS_URL, PLAYWRIGHT_TIMEOUT, FIRECRAWL_API_BASE_URL, @@ -238,6 +250,7 @@ RAG_EXTERNAL_RERANKER_URL, RAG_EXTERNAL_RERANKER_API_KEY, RAG_EXTERNAL_RERANKER_TIMEOUT, + RAG_RERANKING_BATCH_SIZE, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, @@ -291,6 +304,8 @@ DOCUMENT_INTELLIGENCE_MODEL, MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY, + PADDLEOCR_VL_BASE_URL, + PADDLEOCR_VL_TOKEN, RAG_TEXT_SPLITTER, ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, TIKTOKEN_ENCODING_NAME, @@ -330,6 +345,7 @@ BING_SEARCH_V7_ENDPOINT, BING_SEARCH_V7_SUBSCRIPTION_KEY, BRAVE_SEARCH_API_KEY, + BRAVE_SEARCH_CONTEXT_TOKENS, EXA_API_KEY, PERPLEXITY_API_KEY, PERPLEXITY_MODEL, @@ -375,12 +391,17 @@ JWT_EXPIRES_IN, ENABLE_SIGNUP, ENABLE_LOGIN_FORM, + ENABLE_PASSWORD_CHANGE_FORM, ENABLE_API_KEYS, ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, API_KEYS_ALLOWED_ENDPOINTS, ENABLE_FOLDERS, FOLDER_MAX_FILE_COUNT, + ENABLE_AUTOMATIONS, + AUTOMATION_MAX_COUNT, + AUTOMATION_MIN_INTERVAL, ENABLE_CHANNELS, + ENABLE_CALENDAR, ENABLE_NOTES, ENABLE_USER_STATUS, ENABLE_COMMUNITY_SHARING, @@ -403,6 +424,7 @@ EVALUATION_ARENA_MODELS, # WebUI (OAuth) ENABLE_OAUTH_ROLE_MANAGEMENT, + OAUTH_SUB_CLAIM, OAUTH_ROLES_CLAIM, OAUTH_EMAIL_CLAIM, OAUTH_PICTURE_CLAIM, @@ -438,6 +460,7 @@ OAUTH_PROVIDERS, WEBUI_URL, RESPONSE_WATERMARK, + IFRAME_CSP, # Admin ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_ANALYTICS, @@ -458,17 +481,20 @@ IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, VOICE_MODE_PROMPT_TEMPLATE, + ENABLE_VOICE_MODE_PROMPT, QUERY_GENERATION_PROMPT_TEMPLATE, AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE, AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, AppConfig, reset_config, + async_reset_config, ) from open_webui.env import ( ENABLE_CUSTOM_MODEL_FALLBACK, LICENSE_KEY, AUDIT_EXCLUDED_PATHS, AUDIT_INCLUDED_PATHS, + ENABLE_AUDIT_GET_REQUESTS, AUDIT_LOG_LEVEL, CHANGELOG, REDIS_URL, @@ -509,6 +535,8 @@ WEBUI_ADMIN_NAME, ENABLE_EASTER_EGGS, LOG_FORMAT, + # OAuth Back-Channel Logout + ENABLE_OAUTH_BACKCHANNEL_LOGOUT, ) @@ -542,8 +570,10 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import ( get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, + resolve_oauth_client_info, OAuthManager, OAuthClientManager, OAuthClientInformationFull, @@ -554,19 +584,22 @@ from open_webui.tasks import ( redis_task_command_listener, list_task_ids_by_item_id, + has_active_tasks, + cleanup_task, create_task, stop_task, + stop_item_tasks, list_tasks, ) # Import from tasks.py from open_webui.utils.redis import get_sentinels_from_env -from open_webui.constants import ERROR_MESSAGES +from open_webui.constants import ERROR_MESSAGES, TASKS if SAFE_MODE: print('SAFE MODE ENABLED') - Functions.deactivate_all_functions() + # Functions.deactivate_all_functions() is awaited in lifespan below logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -613,21 +646,24 @@ async def lifespan(app: FastAPI): start_logger() if RESET_CONFIG_ON_START: - reset_config() + await async_reset_config() if LICENSE_KEY: get_license_data(app, LICENSE_KEY) # Create admin account from env vars if specified and no users exist if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD: - if create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME): + if await create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME): # Disable signup since we now have an admin app.state.config.ENABLE_SIGNUP = False + if SAFE_MODE: + await Functions.deactivate_all_functions() + # This should be blocking (sync) so functions are not deactivated on first /get_models calls # when the first user lands on the / route. log.info('Installing external dependencies of functions and tools...') - install_tool_and_function_dependencies() + await install_tool_and_function_dependencies() app.state.redis = get_redis_connection( redis_url=REDIS_URL, @@ -646,6 +682,10 @@ async def lifespan(app: FastAPI): asyncio.create_task(periodic_usage_pool_cleanup()) asyncio.create_task(periodic_session_pool_cleanup()) + from open_webui.utils.automations import scheduler_worker_loop + + asyncio.create_task(scheduler_worker_loop(app)) + if app.state.config.ENABLE_BASE_MODELS_CACHE: try: await get_all_models( @@ -672,36 +712,45 @@ async def lifespan(app: FastAPI): # Pre-fetch tool server specs so the first request doesn't pay the latency cost if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: + mock_request = Request( + { + 'type': 'http', + 'asgi.version': '3.0', + 'asgi.spec_version': '2.0', + 'method': 'GET', + 'path': '/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, + } + ) + log.info('Initializing tool servers...') try: - mock_request = Request( - { - 'type': 'http', - 'asgi.version': '3.0', - 'asgi.spec_version': '2.0', - 'method': 'GET', - 'path': '/internal', - 'query_string': b'', - 'headers': Headers({}).raw, - 'client': ('127.0.0.1', 12345), - 'server': ('127.0.0.1', 80), - 'scheme': 'http', - 'app': app, - } - ) await set_tool_servers(mock_request) log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)') + except Exception as e: + log.warning(f'Failed to initialize tool servers at startup: {e}') + try: await set_terminal_servers(mock_request) log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)') except Exception as e: - log.warning(f'Failed to initialize tool/terminal servers at startup: {e}') + log.warning(f'Failed to initialize terminal servers at startup: {e}') # Mark application as ready to accept traffic from a startup perspective. app.state.startup_complete = True yield + # Shutdown: clean up shared resources + from open_webui.utils.session_pool import close_session + + await close_session() + if hasattr(app.state, 'redis_task_command_listener'): app.state.redis_task_command_listener.cancel() @@ -829,6 +878,7 @@ async def lifespan(app: FastAPI): app.state.config.WEBUI_URL = WEBUI_URL app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM +app.state.config.ENABLE_PASSWORD_CHANGE_FORM = ENABLE_PASSWORD_CHANGE_FORM app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS @@ -863,7 +913,11 @@ async def lifespan(app: FastAPI): app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT +app.state.config.ENABLE_AUTOMATIONS = ENABLE_AUTOMATIONS +app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT +app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS +app.state.config.ENABLE_CALENDAR = ENABLE_CALENDAR app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING @@ -888,6 +942,7 @@ async def lifespan(app: FastAPI): migrate_access_control(model.get('meta', {})) app.state.config.EVALUATION_ARENA_MODELS = arena_models +app.state.config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM @@ -980,6 +1035,8 @@ async def lifespan(app: FastAPI): app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY +app.state.config.PADDLEOCR_VL_BASE_URL = PADDLEOCR_VL_BASE_URL +app.state.config.PADDLEOCR_VL_TOKEN = PADDLEOCR_VL_TOKEN app.state.config.MINERU_API_MODE = MINERU_API_MODE app.state.config.MINERU_API_URL = MINERU_API_URL app.state.config.MINERU_API_KEY = MINERU_API_KEY @@ -1007,6 +1064,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT +app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE app.state.config.RAG_TEMPLATE = RAG_TEMPLATE @@ -1054,6 +1112,7 @@ async def lifespan(app: FastAPI): app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY +app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = BRAVE_SEARCH_CONTEXT_TOKENS app.state.config.KAGI_SEARCH_API_KEY = KAGI_SEARCH_API_KEY app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY app.state.config.BOCHA_SEARCH_API_KEY = BOCHA_SEARCH_API_KEY @@ -1156,6 +1215,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_RERANKING_ENGINE, app.state.config.RAG_RERANKING_MODEL, reranking_function=app.state.rf, + reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE, ) ######################################## @@ -1240,6 +1300,7 @@ async def lifespan(app: FastAPI): app.state.config.STT_ENGINE = AUDIO_STT_ENGINE app.state.config.STT_MODEL = AUDIO_STT_MODEL app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES +app.state.config.STT_ALLOWED_EXTENSIONS = AUDIO_STT_ALLOWED_EXTENSIONS app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY @@ -1274,6 +1335,9 @@ async def lifespan(app: FastAPI): app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT +app.state.config.TTS_MISTRAL_API_KEY = AUDIO_TTS_MISTRAL_API_KEY +app.state.config.TTS_MISTRAL_API_BASE_URL = AUDIO_TTS_MISTRAL_API_BASE_URL + app.state.faster_whisper_model = None app.state.speech_synthesiser = None @@ -1309,6 +1373,7 @@ async def lifespan(app: FastAPI): app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE +app.state.config.ENABLE_VOICE_MODE_PROMPT = ENABLE_VOICE_MODE_PROMPT ######################################## @@ -1324,149 +1389,19 @@ async def lifespan(app: FastAPI): app.add_middleware(CompressMiddleware) -class RedirectMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - # Check if the request is a GET request - if request.method == 'GET': - path = request.url.path - query_params = dict(parse_qs(urlparse(str(request.url)).query)) - - redirect_params = {} - - # Check for the specific watch path and the presence of 'v' parameter - if path.endswith('/watch') and 'v' in query_params: - # Extract the first 'v' parameter - youtube_video_id = query_params['v'][0] - redirect_params['youtube'] = youtube_video_id - - if 'shared' in query_params and len(query_params['shared']) > 0: - # PWA share_target support - - text = query_params['shared'][0] - if text: - urls = re.match(r'https://\S+', text) - if urls: - from open_webui.retrieval.loaders.youtube import _parse_video_id - - if youtube_video_id := _parse_video_id(urls[0]): - redirect_params['youtube'] = youtube_video_id - else: - redirect_params['load-url'] = urls[0] - else: - redirect_params['q'] = text - - if redirect_params: - redirect_url = f'/?{urlencode(redirect_params)}' - return RedirectResponse(url=redirect_url) - - # Proceed with the normal flow of other requests - response = await call_next(request) - return response - - +# All HTTP middlewares below are pure-ASGI implementations. The previous +# `BaseHTTPMiddleware` / `@app.middleware('http')` versions wrapped the +# downstream app in an anyio task group whose cancel scope cancelled +# in-flight DB calls (and any other awaits) on client disconnect / +# response completion — which surfaced as noisy SQLAlchemy +# `terminate_force_close` tracebacks under aiosqlite and as random +# CancelledError storms across the request path. See +# `open_webui.utils.asgi_middleware` for the rationale. app.add_middleware(RedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) - - -class APIKeyRestrictionMiddleware: - def __init__(self, app): - self.app = app - - async def __call__(self, scope, receive, send): - if scope['type'] == 'http': - request = Request(scope) - auth_header = request.headers.get('Authorization') - token = None - - if auth_header: - parts = auth_header.split(' ', 1) - if len(parts) == 2: - token = parts[1] - - # Only apply restrictions if an sk- API key is used - if token and token.startswith('sk-'): - # Check if restrictions are enabled - if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: - allowed_paths = [ - path.strip() - for path in str(app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') - if path.strip() - ] - - request_path = request.url.path - - # Match exact path or prefix path - is_allowed = any( - request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths - ) - - if not is_allowed: - await JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={'detail': 'API key not allowed to access this endpoint.'}, - )(scope, receive, send) - return - - await self.app(scope, receive, send) - - -app.add_middleware(APIKeyRestrictionMiddleware) - - -@app.middleware('http') -async def commit_session_after_request(request: Request, call_next): - response = await call_next(request) - # log.debug("Commit session after request") - try: - ScopedSession.commit() - finally: - # CRITICAL: remove() returns the connection to the pool. - # Without this, connections remain "checked out" and accumulate - # as "idle in transaction" in PostgreSQL. - ScopedSession.remove() - return response - - -@app.middleware('http') -async def check_url(request: Request, call_next): - start_time = int(time.time()) - request.state.token = get_http_authorization_cred(request.headers.get('Authorization')) - # Fallback to cookie token for browser sessions - if request.state.token is None and request.cookies.get('token'): - from fastapi.security import HTTPAuthorizationCredentials - - request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=request.cookies.get('token')) - - # Fallback to x-api-key header for Anthropic Messages API routes - if request.state.token is None and request.headers.get('x-api-key'): - request_path = request.url.path - if request_path in ('/api/message', '/api/v1/messages'): - from fastapi.security import HTTPAuthorizationCredentials - - request.state.token = HTTPAuthorizationCredentials( - scheme='Bearer', credentials=request.headers.get('x-api-key') - ) - - request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS - response = await call_next(request) - process_time = int(time.time()) - start_time - response.headers['X-Process-Time'] = str(process_time) - return response - - -@app.middleware('http') -async def inspect_websocket(request: Request, call_next): - if '/ws/socket.io' in request.url.path and request.query_params.get('transport') == 'websocket': - upgrade = (request.headers.get('Upgrade') or '').lower() - connection = (request.headers.get('Connection') or '').lower().split(',') - # Check that there's the correct headers for an upgrade, else reject the connection - # This is to work around this upstream issue: https://github.com/miguelgrinberg/python-engineio/issues/367 - if upgrade != 'websocket' or 'upgrade' not in connection: - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={'detail': 'Invalid WebSocket upgrade request'}, - ) - return await call_next(request) +app.add_middleware(CommitSessionMiddleware) +app.add_middleware(AuthTokenMiddleware, fastapi_app=app) +app.add_middleware(WebsocketUpgradeGuardMiddleware) app.add_middleware( @@ -1519,6 +1454,8 @@ async def inspect_websocket(request: Request, call_next): app.include_router(analytics.router, prefix='/api/v1/analytics', tags=['analytics']) app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils']) app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals']) +app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations']) +app.include_router(calendar.router, prefix='/api/v1/calendars', tags=['calendars']) # SCIM 2.0 API for identity management if ENABLE_SCIM: @@ -1537,6 +1474,7 @@ async def inspect_websocket(request: Request, call_next): audit_level=audit_level, excluded_paths=AUDIT_EXCLUDED_PATHS, included_paths=AUDIT_INCLUDED_PATHS, + audit_get_requests=ENABLE_AUDIT_GET_REQUESTS, max_body_size=MAX_BODY_LOG_SIZE, ) ################################## @@ -1585,7 +1523,7 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v ) ) - models = get_filtered_models(models, user) + models = await get_filtered_models(models, user) log.debug( f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' @@ -1599,6 +1537,108 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)): return {'data': models} +class ModelUnloadForm(BaseModel): + model: str + + +@app.post('/api/models/unload') +async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)): + """ + Unified model unload endpoint. + Resolves the provider that owns the model and calls its native unload mechanism. + Supports: Ollama (keep_alive=0) and llama.cpp (/models/unload). + """ + model_id = form_data.model + + # --- Ollama provider --- + ollama_models = getattr(request.app.state, 'OLLAMA_MODELS', None) or {} + if model_id in ollama_models: + url_indices = ollama_models[model_id].get('urls', []) + errors = [] + for idx in url_indices: + url = request.app.state.config.OLLAMA_BASE_URLS[idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + ) + key = api_config.get('key', None) + + prefix_id = api_config.get('prefix_id', None) + actual_model = model_id + if prefix_id and actual_model.startswith(f'{prefix_id}.'): + actual_model = actual_model[len(f'{prefix_id}.') :] + + payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''}) + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), + } + async with session.post( + f'{url}/api/generate', + data=payload, + headers=headers, + ) as r: + if not r.ok: + errors.append({'url_idx': idx, 'error': await r.text()}) + except Exception as e: + log.exception(f'Failed to unload model on Ollama node {idx}: {e}') + errors.append({'url_idx': idx, 'error': str(e)}) + + if errors: + raise HTTPException( + status_code=500, + detail=f'Failed to unload model on {len(errors)} node(s): {errors}', + ) + return {'status': True} + + # --- OpenAI-compatible providers --- + openai_models = getattr(request.app.state, 'OPENAI_MODELS', None) or {} + if model_id in openai_models: + model_info = openai_models[model_id] + idx = model_info.get('urlIdx') + api_config = request.app.state.config.OPENAI_API_CONFIGS.get(str(idx), {}) + provider = api_config.get('provider', '') + base_url = request.app.state.config.OPENAI_API_BASE_URLS[idx] + key = ( + request.app.state.config.OPENAI_API_KEYS[idx] if idx < len(request.app.state.config.OPENAI_API_KEYS) else '' + ) + + if provider == 'llama.cpp': + root_url = base_url.rstrip('/').removesuffix('/v1') + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), + } + async with session.post( + f'{root_url}/models/unload', + json={'model': model_id}, + headers=headers, + ) as r: + if not r.ok: + detail = await r.text() + raise HTTPException(status_code=r.status, detail=detail) + return await r.json() + except HTTPException: + raise + except Exception as e: + log.exception(f'Failed to unload model via llama.cpp: {e}') + raise HTTPException(status_code=500, detail=str(e)) + else: + raise HTTPException( + status_code=400, + detail=f'Provider "{provider or "default"}" does not support model unloading', + ) + + raise HTTPException(status_code=404, detail=f'Model "{model_id}" not found') + + ################################## # Embeddings ################################## @@ -1651,12 +1691,12 @@ async def chat_completion( raise Exception('Model not found') model = request.app.state.MODELS[model_id] - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) # Check if user has access to the model if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL): try: - check_model_access(user, model) + await check_model_access(user, model) except Exception as e: raise e else: @@ -1704,13 +1744,31 @@ async def chat_completion( if model_info_params.get('reasoning_tags') is not None: reasoning_tags = model_info_params.get('reasoning_tags') + # parent_id signals intent: + # null → new chat (root message, no parent) + # value → follow-up (user message's parentId = prev assistant) + # absent → legacy caller, no chat management + is_new_chat = 'parent_id' in form_data and form_data['parent_id'] is None and not form_data.get('chat_id') + parent_id = form_data.pop('parent_id', None) + form_data.pop('new_chat', None) # Legacy field + + # Multi-model: {model_id: assistant_message_id} + # Single-model fallback: built from 'model' + 'id' + message_ids = form_data.pop('message_ids', None) + if not message_ids: + message_ids = {model_id: form_data.pop('id', None)} + else: + form_data.pop('id', None) + + user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None) metadata = { 'user_id': user.id, 'chat_id': form_data.pop('chat_id', None), - 'message_id': form_data.pop('id', None), - 'parent_message': form_data.pop('parent_message', None), - 'parent_message_id': form_data.pop('parent_id', None), + 'user_message': user_message, + 'user_message_id': user_message.get('id') if user_message else None, + 'assistant_message_id': form_data.pop('assistant_message_id', None), 'session_id': form_data.pop('session_id', None), + 'folder_id': form_data.pop('folder_id', None), 'filter_ids': form_data.pop('filter_ids', []), 'tool_ids': form_data.get('tool_ids', None), 'tool_servers': form_data.pop('tool_servers', None), @@ -1733,147 +1791,379 @@ async def chat_completion( }, } + if is_new_chat: + metadata['chat_id'] = str(uuid4()) + if metadata.get('chat_id') and user: - if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored - # Verify chat ownership — lightweight EXISTS check avoids - # deserializing the full chat JSON blob just to confirm the row exists - if ( - not Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin' - ): # admins can access any chat - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.DEFAULT(), + chat_id = metadata['chat_id'] + if not chat_id.startswith('local:') and not chat_id.startswith( + 'channel:' + ): # temporary/channel chats are not stored + if is_new_chat: + # Build the full history upfront with ALL assistant placeholders + user_message = metadata.get('user_message') or {} + user_message_id = user_message.get('id') if user_message else None + + history_messages = {} + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id] + + if user_message_id and user_message: + user_message['childrenIds'] = all_assistant_ids + history_messages[user_message_id] = user_message + + for target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + history_messages[assistant_message_id] = { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + } + + await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'New Chat', + 'models': list(message_ids.keys()), + 'history': { + 'currentId': all_assistant_ids[0] if all_assistant_ids else user_message_id, + 'messages': history_messages, + }, + 'messages': [ + {'role': 'user', 'content': user_message.get('content', '')}, + ] + if user_message_id + else [], + 'files': metadata.get('files') or [], + 'tags': [], + 'timestamp': int(time.time() * 1000), + }, + folder_id=metadata.get('folder_id'), + ), ) - # Insert chat files from parent message if any - parent_message = metadata.get('parent_message') or {} - parent_message_files = parent_message.get('files', []) - if parent_message_files: - try: - Chats.insert_chat_files( - metadata['chat_id'], - parent_message.get('id'), - [ - file_item.get('id') - for file_item in parent_message_files - if file_item.get('type') == 'file' - ], - user.id, + # Insert chat files from user message if any + user_message_files = user_message.get('files', []) + if user_message_files: + try: + await Chats.insert_chat_files( + chat_id, + user_message_id, + [ + file_item.get('id') + for file_item in user_message_files + if file_item.get('type') == 'file' + ], + user.id, + ) + except Exception as e: + log.debug(f'Error inserting chat files: {e}') + pass + else: + # Existing chat — verify ownership + if not await Chats.is_chat_owner(chat_id, user.id) and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.DEFAULT(), + ) + + # Persist chat-level files (knowledge collections, docs, etc.) + # The old frontend saveChatHandler did this on every message; + # now the backend owns persistence. + chat_files = metadata.get('files') + if chat_files is not None: + existing_chat = await Chats.get_chat_by_id(chat_id) + if existing_chat: + updated = {**existing_chat.chat, 'files': chat_files} + await Chats.update_chat_by_id(chat_id, updated) + + # Save user message to DB + user_message = metadata.get('user_message') or {} + if user_message and user_message.get('id'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + user_message['id'], + user_message, ) - except Exception as e: - log.debug(f'Error inserting chat files: {e}') - pass + + # Link grandparent → user message (childrenIds) + grandparent_id = user_message.get('parentId') + if grandparent_id: + grandparent = await Chats.get_message_by_id_and_message_id(chat_id, grandparent_id) + if grandparent: + child_ids = grandparent.get('childrenIds', []) + if user_message['id'] not in child_ids: + child_ids.append(user_message['id']) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, grandparent_id, {'childrenIds': child_ids} + ) + + # Insert chat files from user message if any + user_message_files = user_message.get('files', []) + if user_message_files: + try: + await Chats.insert_chat_files( + chat_id, + user_message.get('id'), + [ + file_item.get('id') + for file_item in user_message_files + if file_item.get('type') == 'file' + ], + user.id, + ) + except Exception as e: + log.debug(f'Error inserting chat files: {e}') + pass + + # Save ALL assistant placeholders + user_message_id = metadata.get('user_message_id') + all_assistant_ids = [assistant_id for assistant_id in message_ids.values() if assistant_id] + + # Link user message → all assistant messages (childrenIds) + if user_message_id and all_assistant_ids: + existing_user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id) + if existing_user_message: + child_ids = existing_user_message.get('childrenIds', []) + for assistant_id in all_assistant_ids: + if assistant_id not in child_ids: + child_ids.append(assistant_id) + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + user_message_id, + {'childrenIds': child_ids}, + ) + + # Save each assistant placeholder + for target_model_id, assistant_message_id in message_ids.items(): + if assistant_message_id: + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + assistant_message_id, + { + 'id': assistant_message_id, + 'parentId': user_message_id, + 'childrenIds': [], + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': target_model_id, + 'timestamp': int(time.time()), + }, + ) request.state.metadata = metadata form_data['metadata'] = metadata + except HTTPException: + raise except Exception as e: - log.debug(f'Error processing chat metadata: {e}') + log.warning(f'Error processing chat metadata: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) - async def process_chat(request, form_data, user, metadata, model): + async def process_chat(request, form_data, user, metadata, model, tasks=None): try: form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) response = await chat_completion_handler(request, form_data, user) - if metadata.get('chat_id') and metadata.get('message_id'): + + # When the upstream provider returns an error (e.g. HTTP 400 + # content-filter, quota exceeded), generate_chat_completion + # returns a JSONResponse instead of raising. Detect this and + # raise so the except-block below emits chat:message:error + + # chat:tasks:cancel, unblocking the frontend. + if isinstance(response, JSONResponse) and response.status_code >= 400: try: - if not metadata['chat_id'].startswith('local:'): - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'parentId': metadata.get('parent_message_id', None), - 'model': model_id, - }, - ) + error_body = json.loads(response.body.decode('utf-8', 'replace')) + detail = error_body.get('error', error_body) if isinstance(error_body, dict) else error_body + if isinstance(detail, dict): + detail = detail.get('message', detail.get('detail', str(detail))) except Exception: - pass + detail = f'Provider returned HTTP {response.status_code}' + raise Exception(detail) - ctx = build_chat_response_context(request, form_data, user, model, metadata, tasks, events) + ctx = await build_chat_response_context(request, form_data, user, model, metadata, tasks, events) return await process_chat_response(response, ctx) except asyncio.CancelledError: log.info('Chat processing was cancelled') try: - event_emitter = get_event_emitter(metadata) - await asyncio.shield( - event_emitter( - {'type': 'chat:tasks:cancel'}, - ) - ) - except Exception as e: + + async def emit_cancel_event(): + event_emitter = await get_event_emitter(metadata) + if event_emitter: + await event_emitter({'type': 'chat:tasks:cancel'}) + + await asyncio.shield(emit_cancel_event()) + except Exception: pass - finally: - raise # re-raise to ensure proper task cancellation handling + raise # re-raise to ensure proper task cancellation handling except Exception as e: - log.debug(f'Error processing chat payload: {e}') + error_detail = e.detail if isinstance(e, HTTPException) else str(e) + log.error('Error processing chat payload: %s', error_detail) if metadata.get('chat_id') and metadata.get('message_id'): # Update the chat message with the error try: - if not metadata['chat_id'].startswith('local:'): - Chats.upsert_message_to_chat_by_id_and_message_id( + if not metadata['chat_id'].startswith('local:') and not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { - 'parentId': metadata.get('parent_message_id', None), - 'error': {'content': str(e)}, + 'parentId': metadata.get('user_message_id', None), + 'error': {'content': error_detail}, }, ) - event_emitter = get_event_emitter(metadata) - await event_emitter( - { - 'type': 'chat:message:error', - 'data': {'error': {'content': str(e)}}, - } - ) - await event_emitter( - {'type': 'chat:tasks:cancel'}, - ) + event_emitter = await get_event_emitter(metadata) + if event_emitter: + await event_emitter( + { + 'type': 'chat:message:error', + 'data': {'error': {'content': error_detail}}, + } + ) + await event_emitter( + {'type': 'chat:tasks:cancel'}, + ) except Exception: pass + else: + # No chat_id/message_id → legacy/direct API path with no + # WebSocket error channel. We must surface the error as + # a proper HTTP response; without this the function would + # return None which FastAPI serializes as null. #23924 + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_detail, + ) finally: + # Clean up MCP clients. Each client is isolated so one + # failure doesn't skip the rest. + # + # NOTE: asyncio.wait_for() / asyncio.shield() must NOT be used + # here — they create new asyncio Tasks, which violate anyio + # cancel-scope task-ownership rules when the MCPClient's + # exit_stack contains anyio transport resources (streamable_http). + # Exiting those cancel scopes from the wrong task raises + # "Attempted to exit a cancel scope that isn't the current + # task's current cancel scope", which propagates as a + # BaseException through the finally block, discards the response + # return value, and surfaces as a 500 "No response returned." + # MCPClient.disconnect() already catches BaseException internally. try: if mcp_clients := metadata.get('mcp_clients'): - for client in reversed(mcp_clients.values()): - await client.disconnect() - except Exception as e: - log.debug(f'Error cleaning up: {e}') - pass - # Emit chat:active=false when task completes + for client in reversed(list(mcp_clients.values())): + try: + await client.disconnect() + except BaseException as e: + log.debug(f'Error disconnecting MCP client: {e}') + except BaseException as e: + log.debug(f'Error cleaning up MCP clients: {e}') + + # Deregister this task, then emit chat:active=false if no others remain try: - if metadata.get('chat_id'): - event_emitter = get_event_emitter(metadata, update_db=False) - if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': False}}) - except Exception as e: - log.debug(f'Error emitting chat:active: {e}') - - if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): - # Asynchronous Chat Processing - task_id, _ = await create_task( - request.app.state.redis, - process_chat(request, form_data, user, metadata, model), - id=metadata['chat_id'], - ) - # Emit chat:active=true when task starts - event_emitter = get_event_emitter(metadata, update_db=False) - if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': True}}) - return {'status': True, 'task_id': task_id} + chat_id = metadata.get('chat_id') + task_id = metadata.get('task_id') + if chat_id and task_id: + await cleanup_task(request.app.state.redis, task_id, chat_id) + if not await has_active_tasks(request.app.state.redis, chat_id): + event_emitter = await get_event_emitter(metadata, update_db=False) + if event_emitter: + try: + await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}})) + except asyncio.CancelledError: + pass + except Exception: + pass + + # Fan out: one task per model + if metadata.get('session_id') and metadata.get('chat_id'): + task_ids = [] + chat_id = metadata['chat_id'] + + for idx, (target_model_id, assistant_message_id) in enumerate(message_ids.items()): + if not assistant_message_id: + continue + + # Per-model metadata: own message_id + model + per_model_metadata = { + **metadata, + 'message_id': assistant_message_id, + } + + # Per-model form_data: own model + model_form_data = { + **form_data, + 'model': target_model_id, + 'metadata': per_model_metadata, + } + + # Resolve the model object for this specific model + resolved_model = request.app.state.MODELS.get(target_model_id, model) + + # Only the first model runs title/tags generation; + # subsequent models only run follow-ups. + task_id, _ = await create_task( + request.app.state.redis, + process_chat( + request, + model_form_data, + user, + per_model_metadata, + resolved_model, + tasks + if idx == 0 + else { + k: v + for k, v in (tasks or {}).items() + if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION) + } + or None, + ), + id=chat_id, + ) + per_model_metadata['task_id'] = task_id + task_ids.append(task_id) + + # Emit chat:active=true + if task_ids: + event_emitter = await get_event_emitter( + {**metadata, 'message_id': list(message_ids.values())[0]}, + update_db=False, + ) + if event_emitter: + await event_emitter({'type': 'chat:active', 'data': {'active': True}}) + + return { + 'status': True, + 'task_ids': task_ids, + 'chat_id': chat_id, + } else: - return await process_chat(request, form_data, user, metadata, model) + # Legacy/direct: single model, synchronous + metadata['message_id'] = list(message_ids.values())[0] + return await process_chat(request, form_data, user, metadata, model, tasks) # Alias for chat_completion (Legacy) generate_chat_completions = chat_completion generate_chat_completion = chat_completion +# Expose as app.state so internal callers (e.g. automations) can +# use the full pipeline without importing from main.py (avoids circular deps). +app.state.CHAT_COMPLETION_HANDLER = chat_completion + ################################## # @@ -1937,6 +2227,8 @@ async def generate_messages( @app.post('/api/chat/completed') async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)): + """Deprecated: outlet filters now run inline during chat completion. + Kept for backward compatibility with external integrations.""" try: model_item = form_data.pop('model_item', {}) @@ -1970,7 +2262,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user=De @app.post('/api/tasks/stop/{task_id}') -async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_verified_user)): +async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_admin_user)): try: result = await stop_task(request.app.state.redis, task_id) return result @@ -1979,15 +2271,21 @@ async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_ve @app.get('/api/tasks') -async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)): +async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)): return {'tasks': await list_tasks(request.app.state.redis)} -@app.get('/api/tasks/chat/{chat_id}') +@app.get('/api/tasks/chat/{chat_id:path}') async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id(chat_id) - if chat is None or chat.user_id != user.id: - return {'task_ids': []} + if chat_id.startswith('local:') or chat_id.startswith('channel:'): + socket_id = chat_id[len('local:') :] + owner_id = get_user_id_from_session_pool(socket_id) + if owner_id != user.id and user.role != 'admin': + return {'task_ids': []} + else: + chat = await Chats.get_chat_by_id(chat_id) + if chat is None or (chat.user_id != user.id and user.role != 'admin'): + return {'task_ids': []} task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id) @@ -1995,6 +2293,21 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De return {'task_ids': task_ids} +@app.post('/api/tasks/chat/{chat_id:path}/stop') +async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): + if chat_id.startswith('local:') or chat_id.startswith('channel:'): + socket_id = chat_id[len('local:') :] + owner_id = get_user_id_from_session_pool(socket_id) + if owner_id != user.id and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + else: + chat = await Chats.get_chat_by_id(chat_id) + if chat is None or (chat.user_id != user.id and user.role != 'admin'): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + result = await stop_item_tasks(request.app.state.redis, chat_id) + return result + + ################################## # # Config Endpoints @@ -2026,9 +2339,9 @@ async def get_app_config(request: Request): detail='Invalid token', ) if data is not None and 'id' in data: - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) - user_count = Users.get_num_users() + user_count = await Users.get_num_users() onboarding = False if user is None: @@ -2049,6 +2362,7 @@ async def get_app_config(request: Request): 'enable_api_keys': app.state.config.ENABLE_API_KEYS, 'enable_signup': app.state.config.ENABLE_SIGNUP, 'enable_login_form': app.state.config.ENABLE_LOGIN_FORM, + 'enable_password_change_form': app.state.config.ENABLE_PASSWORD_CHANGE_FORM, 'enable_websocket': ENABLE_WEBSOCKET_SUPPORT, 'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK, 'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT, @@ -2059,6 +2373,8 @@ async def get_app_config(request: Request): 'enable_folders': app.state.config.ENABLE_FOLDERS, 'folder_max_file_count': app.state.config.FOLDER_MAX_FILE_COUNT, 'enable_channels': app.state.config.ENABLE_CHANNELS, + 'enable_calendar': app.state.config.ENABLE_CALENDAR, + 'enable_automations': app.state.config.ENABLE_AUTOMATIONS, 'enable_notes': app.state.config.ENABLE_NOTES, 'enable_web_search': app.state.config.ENABLE_WEB_SEARCH, 'enable_code_execution': app.state.config.ENABLE_CODE_EXECUTION, @@ -2131,6 +2447,7 @@ async def get_app_config(request: Request): 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, 'response_watermark': app.state.config.RESPONSE_WATERMARK, + 'iframe_csp': IFRAME_CSP, }, 'license_metadata': app.state.LICENSE_METADATA, **( @@ -2237,7 +2554,7 @@ async def get_current_usage(user=Depends(get_verified_user)): return { 'model_ids': get_models_in_use(), - 'user_count': Users.get_active_user_count(), + 'user_count': await Users.get_active_user_count(), } except HTTPException: raise @@ -2258,11 +2575,9 @@ async def get_current_usage(user=Depends(get_verified_user)): server_id = tool_server_connection.get('info', {}).get('id') auth_type = tool_server_connection.get('auth_type', 'none') - if server_id and auth_type == 'oauth_2.1': - oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '') - + if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): try: - oauth_client_info = decrypt_data(oauth_client_info) + oauth_client_info = resolve_oauth_client_info(tool_server_connection) app.state.oauth_client_manager.add_client( f'mcp:{server_id}', OAuthClientInformationFull(**oauth_client_info), @@ -2318,17 +2633,40 @@ async def register_client(request, client_id: str) -> bool: return False server_url = connection.get('url') + auth_type = connection.get('auth_type', 'none') oauth_server_key = (connection.get('config') or {}).get('oauth_server_key') try: - oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, - client_id, - server_url, - oauth_server_key, - ) + if auth_type == 'oauth_2.1_static': + # Static credentials: rebuild from admin-provided credentials + fresh metadata + info = connection.get('info', {}) + oauth_client_id = info.get('oauth_client_id') or '' + oauth_client_secret = info.get('oauth_client_secret') or '' + if not oauth_client_id or not oauth_client_secret: + # Fall back to blob for backward compatibility + existing_client_info = info.get('oauth_client_info', '') + if not existing_client_info: + log.error(f'No stored OAuth client info for static client {client_id}') + return False + existing_data = decrypt_data(existing_client_info) + oauth_client_id = oauth_client_id or existing_data.get('client_id', '') + oauth_client_secret = oauth_client_secret or existing_data.get('client_secret', '') + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + client_id, + server_url, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( + request, + client_id, + server_url, + oauth_server_key, + ) except Exception as e: - log.error(f'Dynamic client re-registration failed for {client_id}: {e}') + log.error(f'OAuth client re-registration failed for {client_id}: {e}') return False try: @@ -2428,15 +2766,36 @@ async def oauth_login_callback( provider: str, request: Request, response: Response, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): return await oauth_manager.handle_callback(request, provider, response, db=db) +############################ +# OIDC Back-Channel Logout +############################ + + +@app.post('/oauth/backchannel-logout') +async def oauth_backchannel_logout( + request: Request, + db: AsyncSession = Depends(get_async_session), +): + if not ENABLE_OAUTH_BACKCHANNEL_LOGOUT: + raise HTTPException(status_code=404) + return await oauth_manager.handle_backchannel_logout(request, db=db) + + @app.get('/manifest.json') async def get_manifest_json(): if app.state.EXTERNAL_PWA_MANIFEST_URL: - return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json() + session = await get_session() + async with session.get( + app.state.EXTERNAL_PWA_MANIFEST_URL, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() else: return { 'name': app.state.WEBUI_NAME, @@ -2482,6 +2841,14 @@ async def get_opensearch_xml(): return Response(content=xml_content, media_type='application/xml') +def _sync_db_ping() -> None: + ScopedSession.execute(text('SELECT 1;')).all() + + +async def async_db_ping() -> None: + await asyncio.to_thread(_sync_db_ping) + + @app.get('/health') async def healthcheck(): return {'status': True} @@ -2503,7 +2870,7 @@ async def readiness_check(): # Check database connectivity try: - ScopedSession.execute(text('SELECT 1;')).all() + await async_db_ping() except Exception as e: log.warning(f'Readiness check DB ping failed: {e!r}') raise HTTPException( @@ -2530,7 +2897,7 @@ async def readiness_check(): @app.get('/health/db') async def healthcheck_with_db(): - ScopedSession.execute(text('SELECT 1;')).all() + await async_db_ping() return {'status': True} @@ -2548,7 +2915,13 @@ async def serve_cache_file( raise HTTPException(status_code=404, detail='File not found') if not os.path.isfile(file_path): raise HTTPException(status_code=404, detail='File not found') - return FileResponse(file_path) + + mime, _ = mimetypes.guess_type(file_path) + inline_safe = mime and mime.split('/', 1)[0] in {'image', 'audio', 'video'} + headers = {'X-Content-Type-Options': 'nosniff'} + if not inline_safe: + headers['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"' + return FileResponse(file_path, headers=headers) def swagger_ui_html(*args, **kwargs): diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 9ee6c2dceb8..961a92becf3 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -3,7 +3,9 @@ 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.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 # this is the Alembic Config object, which provides @@ -35,6 +37,10 @@ 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('%', '%%')) diff --git a/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py b/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py new file mode 100644 index 00000000000..858c9b1541a --- /dev/null +++ b/backend/open_webui/migrations/versions/4de81c2a3af1_add_pinned_note_table.py @@ -0,0 +1,80 @@ +"""add pinned_note table + +Revision ID: 4de81c2a3af1 +Revises: 56359461a091 +Create Date: 2026-05-09 04:29:27.651341 + +""" + +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 = '4de81c2a3af1' +down_revision: Union[str, None] = '56359461a091' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +import uuid +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() + + 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') + + +def downgrade() -> None: + with op.batch_alter_table('note', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True)) + + conn = op.get_bind() + + note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean)) + + pinned_note_table = table('pinned_note', column('note_id', sa.Text)) + + notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall() + + for note in notes: + conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True)) + + op.drop_table('pinned_note') diff --git a/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py new file mode 100644 index 00000000000..e556440f56f --- /dev/null +++ b/backend/open_webui/migrations/versions/56359461a091_add_calendar_tables.py @@ -0,0 +1,83 @@ +"""add calendar tables + +Revision ID: 56359461a091 +Revises: c1d2e3f4a5b6 +Create Date: 2026-04-19 16:20:58.162045 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '56359461a091' +down_revision: Union[str, None] = 'c1d2e3f4a5b6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + 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) + + 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) + + 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: + op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee') + op.drop_table('calendar_event_attendee') + op.drop_index('ix_calendar_event_user_date', table_name='calendar_event') + op.drop_index('ix_calendar_event_calendar', table_name='calendar_event') + op.drop_table('calendar_event') + op.drop_index('ix_calendar_user', table_name='calendar') + op.drop_table('calendar') diff --git a/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py b/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py new file mode 100644 index 00000000000..a52ade7711d --- /dev/null +++ b/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py @@ -0,0 +1,22 @@ +"""Add memory user_id index + +Revision ID: a0b1c2d3e4f5 +Revises: 4de81c2a3af1 +Create Date: 2025-09-15 03:00:00.000000 + +""" + +from alembic import op + +revision = 'a0b1c2d3e4f5' +down_revision = '4de81c2a3af1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index('ix_memory_user_id', 'memory', ['user_id']) + + +def downgrade(): + op.drop_index('ix_memory_user_id', table_name='memory') diff --git a/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py new file mode 100644 index 00000000000..20a3152cfe5 --- /dev/null +++ b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py @@ -0,0 +1,28 @@ +"""Add tasks and summary columns to chat table + +Revision ID: a3dd5bedd151 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-29 22:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a3dd5bedd151' +down_revision: Union[str, None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + 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: + op.drop_column('chat', 'summary') + op.drop_column('chat', 'tasks') diff --git a/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py new file mode 100644 index 00000000000..fb254432f69 --- /dev/null +++ b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py @@ -0,0 +1,27 @@ +"""add last_read_at to chat + +Revision ID: b7c8d9e0f1a2 +Revises: d4e5f6a7b8c9 +Create Date: 2026-04-01 04:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b7c8d9e0f1a2' +down_revision = 'd4e5f6a7b8c9' +branch_labels = None +depends_on = None + + +def upgrade(): + 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(): + op.drop_column('chat', 'last_read_at') diff --git a/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py new file mode 100644 index 00000000000..2451f50ae2f --- /dev/null +++ b/backend/open_webui/migrations/versions/c1d2e3f4a5b6_add_shared_chat_table.py @@ -0,0 +1,164 @@ +"""Add shared_chat table and migrate existing shares + +Revision ID: c1d2e3f4a5b6 +Revises: e1f2a3b4c5d6 +Create Date: 2026-04-16 23:00:00.000000 + +""" + +import time +import uuid + +from alembic import op +import sqlalchemy as sa + +revision = 'c1d2e3f4a5b6' +down_revision = 'e1f2a3b4c5d6' +branch_labels = None +depends_on = None + +# Lightweight table references for data migration (no ORM models needed) +chat_t = sa.table( + 'chat', + sa.column('id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('share_id', sa.Text), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), + sa.column('archived', sa.Boolean), + sa.column('meta', sa.JSON), +) + +shared_chat_t = sa.table( + 'shared_chat', + sa.column('id', sa.Text), + sa.column('chat_id', sa.Text), + sa.column('user_id', sa.Text), + sa.column('title', sa.Text), + sa.column('chat', sa.JSON), + sa.column('created_at', sa.BigInteger), + sa.column('updated_at', sa.BigInteger), +) + +chat_message_t = sa.table( + 'chat_message', + sa.column('chat_id', sa.Text), +) + +access_grant_t = sa.table( + 'access_grant', + sa.column('id', sa.Text), + sa.column('resource_type', sa.Text), + sa.column('resource_id', sa.Text), + sa.column('principal_type', sa.Text), + sa.column('principal_id', sa.Text), + sa.column('permission', sa.Text), + sa.column('created_at', sa.BigInteger), +) + + +def upgrade(): + conn = op.get_bind() + + # 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( + sa.select( + chat_t.c.id, + chat_t.c.user_id, + chat_t.c.title, + chat_t.c.chat, + chat_t.c.created_at, + chat_t.c.updated_at, + ).where(chat_t.c.user_id.like('shared-%')) + ).fetchall() + + for row in shared_rows: + share_token = row.id + original_chat_id = row.user_id.replace('shared-', '', 1) + + # Verify original chat still exists + original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone() + + if not original: + continue + + # 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, + ) + ) + + # 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( + chat_message_t.delete().where( + chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%'))) + ) + ) + conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%'))) + + +def downgrade(): + conn = op.get_bind() + + shared_rows = conn.execute( + sa.select( + shared_chat_t.c.id, + shared_chat_t.c.chat_id, + shared_chat_t.c.user_id, + shared_chat_t.c.title, + shared_chat_t.c.chat, + shared_chat_t.c.created_at, + shared_chat_t.c.updated_at, + ) + ).fetchall() + + for row in shared_rows: + conn.execute( + chat_t.insert().values( + id=row.id, + user_id=f'shared-{row.chat_id}', + title=row.title, + chat=row.chat, + created_at=row.created_at, + updated_at=row.updated_at, + archived=False, + meta={}, + ) + ) + + conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat')) + op.drop_table('shared_chat') diff --git a/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py b/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py new file mode 100644 index 00000000000..fc90dc417f2 --- /dev/null +++ b/backend/open_webui/migrations/versions/d4e5f6a7b8c9_add_automation_tables.py @@ -0,0 +1,55 @@ +"""add automation tables + +Revision ID: d4e5f6a7b8c9 +Revises: f1e2d3c4b5a6 +Create Date: 2026-03-30 +""" + +from typing import Union + +from alembic import op +import sqlalchemy as sa + +revision: str = 'd4e5f6a7b8c9' +down_revision: Union[str, None] = 'a3dd5bedd151' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'automation', + 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('data', sa.JSON(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, default=True), + sa.Column('last_run_at', sa.BigInteger(), nullable=True), + sa.Column('next_run_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + ) + op.create_index('ix_automation_next_run', 'automation', ['next_run_at']) + + op.create_table( + 'automation_run', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('automation_id', sa.Text(), nullable=False), + sa.Column('chat_id', sa.Text(), nullable=True), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + ) + op.create_index( + 'ix_automation_run_automation_id', + 'automation_run', + ['automation_id'], + ) + + +def downgrade(): + op.drop_index('ix_automation_run_automation_id') + op.drop_table('automation_run') + op.drop_index('ix_automation_next_run') + op.drop_table('automation') diff --git a/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py new file mode 100644 index 00000000000..0d80558746e --- /dev/null +++ b/backend/open_webui/migrations/versions/e1f2a3b4c5d6_add_is_pinned_to_note.py @@ -0,0 +1,23 @@ +"""Add is_pinned to note table + +Revision ID: e1f2a3b4c5d6 +Revises: b7c8d9e0f1a2 +Create Date: 2026-04-14 22:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = 'e1f2a3b4c5d6' +down_revision = 'b7c8d9e0f1a2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('note', 'is_pinned') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index ee7f950ff52..f031495912d 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -3,8 +3,9 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db_context +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_ @@ -192,6 +193,16 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool: return False +def has_public_write_access_grant(access_grants: Optional[list]) -> bool: + """ + Returns True when a direct grant list includes wildcard public-write. + """ + for grant in normalize_access_grants(access_grants): + if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write': + return True + return False + + def has_user_access_grant(access_grants: Optional[list]) -> bool: """ Returns True when a direct grant list includes any non-wildcard user grant. @@ -271,29 +282,28 @@ def grants_to_access_control(grants: list) -> Optional[dict]: class AccessGrantsTable: - def grant_access( + async def grant_access( self, resource_type: str, resource_id: str, principal_type: str, principal_id: str, permission: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[AccessGrantModel]: """Add a single access grant. Idempotent (ignores duplicates).""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Check for existing grant - existing = ( - db.query(AccessGrant) - .filter_by( + result = await db.execute( + select(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, principal_type=principal_type, principal_id=principal_id, permission=permission, ) - .first() ) + existing = result.scalars().first() if existing: return AccessGrantModel.model_validate(existing) @@ -307,71 +317,69 @@ def grant_access( created_at=int(time.time()), ) db.add(grant) - db.commit() - db.refresh(grant) + await db.commit() + await db.refresh(grant) return AccessGrantModel.model_validate(grant) - def revoke_access( + async def revoke_access( self, resource_type: str, resource_id: str, principal_type: str, principal_id: str, permission: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: """Remove a single access grant.""" - with get_db_context(db) as db: - deleted = ( - db.query(AccessGrant) - .filter_by( + async with get_async_db_context(db) as db: + result = await db.execute( + delete(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, principal_type=principal_type, principal_id=principal_id, permission=permission, ) - .delete() ) - db.commit() - return deleted > 0 + await db.commit() + return result.rowcount > 0 - def revoke_all_access( + async def revoke_all_access( self, resource_type: str, resource_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> int: """Remove all access grants for a resource.""" - with get_db_context(db) as db: - deleted = ( - db.query(AccessGrant) - .filter_by( + async with get_async_db_context(db) as db: + result = await db.execute( + delete(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, ) - .delete() ) - db.commit() - return deleted + await db.commit() + return result.rowcount - def set_access_control( + async def set_access_control( self, resource_type: str, resource_id: str, access_control: Optional[dict], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[AccessGrantModel]: """ Replace all grants for a resource from an access_control JSON dict. This is the primary bridge for backward compat with the frontend. """ - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Delete all existing grants for this resource - db.query(AccessGrant).filter_by( - resource_type=resource_type, - resource_id=resource_id, - ).delete() + await db.execute( + delete(AccessGrant).filter_by( + resource_type=resource_type, + resource_id=resource_id, + ) + ) # Convert JSON to grant dicts grant_dicts = access_control_to_grants(resource_type, resource_id, access_control) @@ -387,25 +395,27 @@ def set_access_control( db.add(grant) results.append(grant) - db.commit() + await db.commit() return [AccessGrantModel.model_validate(g) for g in results] - def set_access_grants( + async def set_access_grants( self, resource_type: str, resource_id: str, access_grants: Optional[list], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[AccessGrantModel]: """ Replace all grants for a resource from a direct access_grants list. """ - with get_db_context(db) as db: - db.query(AccessGrant).filter_by( - resource_type=resource_type, - resource_id=resource_id, - ).delete() + async with get_async_db_context(db) as db: + await db.execute( + delete(AccessGrant).filter_by( + resource_type=resource_type, + resource_id=resource_id, + ) + ) normalized_grants = normalize_access_grants(access_grants) @@ -423,80 +433,77 @@ def set_access_grants( db.add(grant) results.append(grant) - db.commit() + await db.commit() return [AccessGrantModel.model_validate(g) for g in results] - def get_access_control( + async def get_access_control( self, resource_type: str, resource_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[dict]: """ Reconstruct the old-style access_control JSON dict from grants. For backward compat with the frontend. """ - with get_db_context(db) as db: - grants = ( - db.query(AccessGrant) - .filter_by( + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, ) - .all() ) + grants = result.scalars().all() grant_models = [AccessGrantModel.model_validate(g) for g in grants] return grants_to_access_control(grant_models) - def get_grants_by_resource( + async def get_grants_by_resource( self, resource_type: str, resource_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[AccessGrantModel]: """Get all grants for a specific resource.""" - with get_db_context(db) as db: - grants = ( - db.query(AccessGrant) - .filter_by( + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, ) - .all() ) + grants = result.scalars().all() return [AccessGrantModel.model_validate(g) for g in grants] - def get_grants_by_resources( + async def get_grants_by_resources( self, resource_type: str, resource_ids: list[str], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, list[AccessGrantModel]]: """Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}.""" if not resource_ids: return {} - with get_db_context(db) as db: - grants = ( - db.query(AccessGrant) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant).filter( AccessGrant.resource_type == resource_type, AccessGrant.resource_id.in_(resource_ids), ) - .all() ) - result: dict[str, list[AccessGrantModel]] = {rid: [] for rid in resource_ids} + grants = result.scalars().all() + result_dict: dict[str, list[AccessGrantModel]] = {rid: [] for rid in resource_ids} for g in grants: - result[g.resource_id].append(AccessGrantModel.model_validate(g)) - return result + result_dict[g.resource_id].append(AccessGrantModel.model_validate(g)) + return result_dict - def has_access( + async def has_access( self, user_id: str, resource_type: str, resource_id: str, permission: str = 'read', user_group_ids: Optional[set[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: """ Check if a user has the specified permission on a resource. @@ -506,7 +513,7 @@ def has_access( - There's a grant for the specific user with the requested permission - There's a grant for any of the user's groups with the requested permission """ - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Build conditions for matching grants conditions = [ # Public access @@ -525,7 +532,7 @@ def has_access( if user_group_ids is None: from open_webui.models.groups import Groups - user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} if user_group_ids: @@ -536,26 +543,27 @@ def has_access( ) ) - exists = ( - db.query(AccessGrant) + result = await db.execute( + select(AccessGrant) .filter( AccessGrant.resource_type == resource_type, AccessGrant.resource_id == resource_id, AccessGrant.permission == permission, or_(*conditions), ) - .first() + .limit(1) ) - return exists is not None + grant = result.scalars().first() + return grant is not None - def get_accessible_resource_ids( + async def get_accessible_resource_ids( self, user_id: str, resource_type: str, resource_ids: list[str], permission: str = 'read', user_group_ids: Optional[set[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> set[str]: """ Batch check: return the subset of resource_ids that the user can access. @@ -565,7 +573,7 @@ def get_accessible_resource_ids( if not resource_ids: return set() - with get_db_context(db) as db: + async with get_async_db_context(db) as db: conditions = [ and_( AccessGrant.principal_type == 'user', @@ -580,7 +588,7 @@ def get_accessible_resource_ids( if user_group_ids is None: from open_webui.models.groups import Groups - user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} if user_group_ids: @@ -591,8 +599,8 @@ def get_accessible_resource_ids( ) ) - rows = ( - db.query(AccessGrant.resource_id) + result = await db.execute( + select(AccessGrant.resource_id) .filter( AccessGrant.resource_type == resource_type, AccessGrant.resource_id.in_(resource_ids), @@ -600,16 +608,16 @@ def get_accessible_resource_ids( or_(*conditions), ) .distinct() - .all() ) + rows = result.all() return {row[0] for row in rows} - def get_users_with_access( + async def get_users_with_access( self, resource_type: str, resource_id: str, permission: str = 'read', - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list: """ Get all users who have the specified permission on a resource. @@ -618,21 +626,20 @@ def get_users_with_access( from open_webui.models.users import Users, UserModel from open_webui.models.groups import Groups - with get_db_context(db) as db: - grants = ( - db.query(AccessGrant) - .filter_by( + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant).filter_by( resource_type=resource_type, resource_id=resource_id, permission=permission, ) - .all() ) + grants = result.scalars().all() # Check for public access for grant in grants: if grant.principal_type == 'user' and grant.principal_id == '*': - result = Users.get_users(filter={'roles': ['!pending']}, db=db) + result = await Users.get_users(filter={'roles': ['!pending']}, db=db) return result.get('users', []) user_ids_with_access = set() @@ -641,14 +648,14 @@ def get_users_with_access( if grant.principal_type == 'user': user_ids_with_access.add(grant.principal_id) elif grant.principal_type == 'group': - group_user_ids = Groups.get_group_user_ids_by_id(grant.principal_id, db=db) + group_user_ids = await Groups.get_group_user_ids_by_id(grant.principal_id, db=db) if group_user_ids: user_ids_with_access.update(group_user_ids) if not user_ids_with_access: return [] - return Users.get_users_by_user_ids(list(user_ids_with_access), db=db) + return await Users.get_users_by_user_ids(list(user_ids_with_access), db=db) def has_permission_filter( self, @@ -663,6 +670,10 @@ def has_permission_filter( Apply access control filtering to a SQLAlchemy query by JOINing with access_grant. This replaces the old JSON-column-based filtering with a proper relational JOIN. + + Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself, + so it remains synchronous. The caller is responsible for executing the query + asynchronously with `await db.execute(...)`. """ group_ids = filter.get('group_ids', []) user_id = filter.get('user_id') @@ -708,7 +719,7 @@ def has_permission_filter( # LEFT JOIN access_grant and filter # We use a subquery approach to avoid duplicates from multiple matching grants - from sqlalchemy import exists as sa_exists, select + from sqlalchemy import exists as sa_exists grant_exists = ( select(AccessGrant.id) @@ -766,11 +777,15 @@ def _has_read_only_permission_filter( """ Filter for items where user has read BUT NOT write access. Public items are NOT considered read_only. + + Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself, + so it remains synchronous. The caller is responsible for executing the query + asynchronously with `await db.execute(...)`. """ group_ids = filter.get('group_ids', []) user_id = filter.get('user_id') - from sqlalchemy import exists as sa_exists, select + from sqlalchemy import exists as sa_exists # Has read grant (not public) read_grant_exists = ( diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 1a1b164c12c..2c8c6ba99fe 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -2,8 +2,9 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users from open_webui.utils.validate import validate_profile_image_url from pydantic import BaseModel, field_validator @@ -88,7 +89,7 @@ class AddUserForm(SignupForm): class AuthsTable: - def insert_new_auth( + async def insert_new_auth( self, email: str, password: str, @@ -96,9 +97,9 @@ def insert_new_auth( profile_image_url: str = '/user.png', role: str = 'pending', oauth: Optional[dict] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[UserModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: log.info('insert_new_auth') id = str(uuid.uuid4()) @@ -107,28 +108,29 @@ def insert_new_auth( result = Auth(**auth.model_dump()) db.add(result) - user = Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db) + user = await Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result and user: return user else: return None - def authenticate_user( - self, email: str, verify_password: callable, db: Optional[Session] = None + async def authenticate_user( + self, email: str, verify_password: callable, db: Optional[AsyncSession] = None ) -> Optional[UserModel]: log.info(f'authenticate_user: {email}') - user = Users.get_user_by_email(email, db=db) + user = await Users.get_user_by_email(email, db=db) if not user: return None try: - with get_db_context(db) as db: - auth = db.query(Auth).filter_by(id=user.id, active=True).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Auth).filter_by(id=user.id, active=True)) + auth = result.scalars().first() if auth: if verify_password(auth.password): return user @@ -139,66 +141,66 @@ def authenticate_user( except Exception: return None - def authenticate_user_by_api_key(self, api_key: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def authenticate_user_by_api_key( + self, api_key: str, db: Optional[AsyncSession] = None + ) -> Optional[UserModel]: log.info(f'authenticate_user_by_api_key') # if no api_key, return None if not api_key: return None try: - user = Users.get_user_by_api_key(api_key, db=db) + user = await Users.get_user_by_api_key(api_key, db=db) return user if user else None except Exception: return False - def authenticate_user_by_email(self, email: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def authenticate_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]: log.info(f'authenticate_user_by_email: {email}') try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Single JOIN query instead of two separate queries - result = ( - db.query(Auth, User) - .join(User, Auth.id == User.id) - .filter(Auth.email == email, Auth.active == True) - .first() + result = await db.execute( + select(Auth, User).join(User, Auth.id == User.id).filter(Auth.email == email, Auth.active == True) ) - if result: - _, user = result + row = result.first() + if row: + _, user = row return UserModel.model_validate(user) return None except Exception: return None - def update_user_password_by_id(self, id: str, new_password: str, db: Optional[Session] = None) -> bool: + async def update_user_password_by_id(self, id: str, new_password: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - result = db.query(Auth).filter_by(id=id).update({'password': new_password}) - db.commit() - return True if result == 1 else False + async with get_async_db_context(db) as db: + result = await db.execute(update(Auth).filter_by(id=id).values(password=new_password)) + await db.commit() + return True if result.rowcount == 1 else False except Exception: return False - def update_email_by_id(self, id: str, email: str, db: Optional[Session] = None) -> bool: + async def update_email_by_id(self, id: str, email: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - result = db.query(Auth).filter_by(id=id).update({'email': email}) - db.commit() - if result == 1: - Users.update_user_by_id(id, {'email': email}, db=db) + async with get_async_db_context(db) as db: + result = await db.execute(update(Auth).filter_by(id=id).values(email=email)) + await db.commit() + if result.rowcount == 1: + await Users.update_user_by_id(id, {'email': email}, db=db) return True return False except Exception: return False - def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_auth_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Delete User - result = Users.delete_user_by_id(id, db=db) + result = await Users.delete_user_by_id(id, db=db) if result: - db.query(Auth).filter_by(id=id).delete() - db.commit() + await db.execute(delete(Auth).filter_by(id=id)) + await db.commit() return True else: diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py new file mode 100644 index 00000000000..05f449ad13c --- /dev/null +++ b/backend/open_webui/models/automations.py @@ -0,0 +1,421 @@ +import time +import logging +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String, delete, update +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.internal.db import Base, get_async_db_context + +log = logging.getLogger(__name__) + + +#################### +# Automation DB Schema +#################### + + +class Automation(Base): + __tablename__ = 'automation' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + name = Column(Text, nullable=False) + data = Column(JSON, nullable=False) # {prompt, model_id, rrule} + meta = Column(JSON, nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + last_run_at = Column(BigInteger, nullable=True) + next_run_at = Column(BigInteger, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_automation_next_run', 'next_run_at'),) + + +class AutomationRun(Base): + __tablename__ = 'automation_run' + + id = Column(Text, primary_key=True) + automation_id = Column(Text, nullable=False) + chat_id = Column(Text, nullable=True) + status = Column(Text, nullable=False) # success | error + error = Column(Text, nullable=True) + created_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + Index('ix_automation_run_automation_id', 'automation_id'), + Index('ix_automation_run_aid_created', 'automation_id', 'created_at'), + ) + + +#################### +# Pydantic Models +#################### + + +class AutomationTerminalConfig(BaseModel): + server_id: str + cwd: Optional[str] = None + + +class AutomationData(BaseModel): + prompt: str + model_id: str + rrule: str + terminal: Optional[AutomationTerminalConfig] = None + + +class AutomationModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + name: str + data: dict + meta: Optional[dict] = None + is_active: bool + last_run_at: Optional[int] = None + next_run_at: Optional[int] = None + + created_at: int + updated_at: int + + +class AutomationRunModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + automation_id: str + chat_id: Optional[str] = None + status: str + error: Optional[str] = None + created_at: int + + +class AutomationForm(BaseModel): + name: str + data: AutomationData + meta: Optional[dict] = None + is_active: Optional[bool] = True + + +class AutomationResponse(AutomationModel): + last_run: Optional[AutomationRunModel] = None + next_runs: Optional[list[int]] = None + + +class AutomationListResponse(BaseModel): + items: list[AutomationModel] + total: int + + +#################### +# AutomationTable +#################### + + +class AutomationTable: + async def insert( + self, + user_id: str, + form: AutomationForm, + next_run_at: int, + db: Optional[AsyncSession] = None, + ) -> AutomationModel: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + row = Automation( + id=str(uuid4()), + user_id=user_id, + name=form.name, + data=form.data.model_dump(), + meta=form.meta, + is_active=form.is_active, + next_run_at=next_run_at, + created_at=now, + updated_at=now, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return AutomationModel.model_validate(row) + + async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int: + async with get_async_db_context(db) as db: + result = await db.execute(select(func.count()).select_from(Automation).filter_by(user_id=user_id)) + return result.scalar() + + async def get_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationModel]: + async with get_async_db_context(db) as db: + row = await db.get(Automation, id) + return AutomationModel.model_validate(row) if row else None + + async def get_active_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[AutomationModel]: + """Get active automations for a user (for calendar RRULE expansion).""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Automation).filter_by(user_id=user_id, is_active=True).order_by(Automation.created_at.desc()) + ) + return [AutomationModel.model_validate(r) for r in result.scalars().all()] + + async def search_automations( + self, + user_id: str, + query: Optional[str] = None, + status: Optional[str] = None, + skip: int = 0, + limit: int = 30, + db: Optional[AsyncSession] = None, + ) -> 'AutomationListResponse': + async with get_async_db_context(db) as db: + stmt = select(Automation).filter_by(user_id=user_id) + + if query: + search = f'%{query}%' + # Search in name and prompt inside JSON data + stmt = stmt.filter( + or_( + Automation.name.ilike(search), + cast(Automation.data, String).ilike(search), + ) + ) + + if status == 'active': + stmt = stmt.filter(Automation.is_active == True) + elif status == 'paused': + stmt = stmt.filter(Automation.is_active == False) + + stmt = stmt.order_by(Automation.created_at.desc()) + + # Get total count + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + rows = result.scalars().all() + return AutomationListResponse( + items=[AutomationModel.model_validate(r) for r in rows], + total=total, + ) + + async def update_by_id( + self, + id: str, + form: AutomationForm, + next_run_at: int, + db: Optional[AsyncSession] = None, + ) -> Optional[AutomationModel]: + async with get_async_db_context(db) as db: + row = await db.get(Automation, id) + if not row: + return None + row.name = form.name + row.data = form.data.model_dump() + row.meta = form.meta + if form.is_active is not None: + row.is_active = form.is_active + row.next_run_at = next_run_at + row.updated_at = int(time.time_ns()) + await db.commit() + await db.refresh(row) + return AutomationModel.model_validate(row) + + async def toggle( + self, + id: str, + next_run_at: Optional[int], + db: Optional[AsyncSession] = None, + ) -> Optional[AutomationModel]: + async with get_async_db_context(db) as db: + row = await db.get(Automation, id) + if not row: + return None + row.is_active = not row.is_active + row.next_run_at = next_run_at if row.is_active else None + row.updated_at = int(time.time_ns()) + await db.commit() + await db.refresh(row) + return AutomationModel.model_validate(row) + + async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + row = await db.get(Automation, id) + if not row: + return False + await db.delete(row) + await db.commit() + return True + + async def claim_due(self, now_ns: int, limit: int = 10, db: Optional[AsyncSession] = None) -> list[AutomationModel]: + """ + Atomically claim due automations for execution. + + Advances next_run_at immediately so the row can never be + double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED + for zero-contention distributed work claiming. + """ + async with get_async_db_context(db) as db: + stmt = ( + select(Automation) + .where( + Automation.is_active == True, + Automation.next_run_at <= now_ns, + ) + .order_by(Automation.next_run_at) + .limit(limit) + ) + + if db.bind.dialect.name == 'postgresql': + stmt = stmt.with_for_update(skip_locked=True) + + result = await db.execute(stmt) + rows = result.scalars().all() + + from open_webui.utils.automations import next_run_ns + + # Batch-fetch user timezones so rescheduling respects each + # user's local timezone instead of falling back to server time. + user_ids = list({row.user_id for row in rows}) + timezone_by_user_id: dict[str, Optional[str]] = {} + if user_ids: + from open_webui.models.users import User + + tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids))) + timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()} + + for row in rows: + row.last_run_at = now_ns + row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id)) + + await db.commit() + + return [AutomationModel.model_validate(r) for r in rows] + + +#################### +# AutomationRunTable +#################### + + +class AutomationRunTable: + async def insert( + self, + automation_id: str, + status: str, + chat_id: Optional[str] = None, + error: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> AutomationRunModel: + async with get_async_db_context(db) as db: + row = AutomationRun( + id=str(uuid4()), + automation_id=automation_id, + chat_id=chat_id, + status=status, + error=error, + created_at=int(time.time_ns()), + ) + db.add(row) + await db.commit() + await db.refresh(row) + return AutomationRunModel.model_validate(row) + + async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(AutomationRun) + .filter_by(automation_id=automation_id) + .order_by(AutomationRun.created_at.desc()) + .limit(1) + ) + row = result.scalars().first() + return AutomationRunModel.model_validate(row) if row else None + + async def get_latest_batch( + self, automation_ids: list[str], db: Optional[AsyncSession] = None + ) -> dict[str, AutomationRunModel]: + """Fetch the latest run for each automation in a single query.""" + if not automation_ids: + return {} + async with get_async_db_context(db) as db: + # Subquery: max created_at per automation_id + subq = ( + select( + AutomationRun.automation_id, + func.max(AutomationRun.created_at).label('max_created'), + ) + .filter(AutomationRun.automation_id.in_(automation_ids)) + .group_by(AutomationRun.automation_id) + .subquery() + ) + result = await db.execute( + select(AutomationRun).join( + subq, + (AutomationRun.automation_id == subq.c.automation_id) + & (AutomationRun.created_at == subq.c.max_created), + ) + ) + rows = result.scalars().all() + return {row.automation_id: AutomationRunModel.model_validate(row) for row in rows} + + async def get_by_automation( + self, + automation_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[AsyncSession] = None, + ) -> list[AutomationRunModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(AutomationRun) + .filter_by(automation_id=automation_id) + .order_by(AutomationRun.created_at.desc()) + .offset(skip) + .limit(limit) + ) + rows = result.scalars().all() + return [AutomationRunModel.model_validate(r) for r in rows] + + async def delete_by_automation(self, automation_id: str, db: Optional[AsyncSession] = None) -> int: + async with get_async_db_context(db) as db: + result = await db.execute(delete(AutomationRun).filter_by(automation_id=automation_id)) + await db.commit() + return result.rowcount + + async def get_runs_by_user_range( + self, + user_id: str, + start_ns: int, + end_ns: int, + limit: int = 500, + db: Optional[AsyncSession] = None, + ) -> list[tuple['AutomationRunModel', 'AutomationModel']]: + """Get runs within a date range for a user, joined with parent automation.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(AutomationRun, Automation) + .join(Automation, Automation.id == AutomationRun.automation_id) + .filter( + Automation.user_id == user_id, + AutomationRun.created_at >= start_ns, + AutomationRun.created_at < end_ns, + ) + .order_by(AutomationRun.created_at.desc()) + .limit(limit) + ) + return [ + (AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto)) + for run, auto in result.all() + ] + + +Automations = AutomationTable() +AutomationRuns = AutomationRunTable() diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py new file mode 100644 index 00000000000..48b28d8e9ae --- /dev/null +++ b/backend/open_webui/models/calendar.py @@ -0,0 +1,824 @@ +import time +import logging +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import ( + Column, + Text, + JSON, + Boolean, + BigInteger, + Index, + UniqueConstraint, + select, + or_, + exists, + func, + delete, + update, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.internal.db import Base, get_async_db_context +from open_webui.models.access_grants import AccessGrantModel, AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import User, UserModel, UserResponse + +log = logging.getLogger(__name__) + + +#################### +# Calendar DB Schema +#################### + + +class Calendar(Base): + __tablename__ = 'calendar' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + name = Column(Text, nullable=False) + color = Column(Text, nullable=True) + is_default = Column(Boolean, nullable=False, default=False) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = (Index('ix_calendar_user', 'user_id'),) + + +class CalendarEvent(Base): + __tablename__ = 'calendar_event' + + id = Column(Text, primary_key=True) + calendar_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + title = Column(Text, nullable=False) + description = Column(Text, nullable=True) + start_at = Column(BigInteger, nullable=False) + end_at = Column(BigInteger, nullable=True) + all_day = Column(Boolean, nullable=False, default=False) + rrule = Column(Text, nullable=True) + color = Column(Text, nullable=True) + location = Column(Text, nullable=True) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + is_cancelled = Column(Boolean, nullable=False, default=False) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'), + Index('ix_calendar_event_user_date', 'user_id', 'start_at'), + ) + + +class CalendarEventAttendee(Base): + __tablename__ = 'calendar_event_attendee' + + id = Column(Text, primary_key=True) + event_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + status = Column(Text, nullable=False, default='pending') + meta = Column(JSON, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'), + Index('ix_calendar_event_attendee_user', 'user_id', 'status'), + ) + + +#################### +# Pydantic Models +#################### + + +class CalendarModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + name: str + color: Optional[str] = None + is_default: bool = False + is_system: bool = False + + data: Optional[dict] = None + meta: Optional[dict] = None + + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventModel(BaseModel): + model_config = ConfigDict(from_attributes=True, extra='allow') + + id: str + calendar_id: str + user_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: bool = False + + attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list) + + created_at: int + updated_at: int + + +class CalendarEventAttendeeModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + event_id: str + user_id: str + status: str = 'pending' + meta: Optional[dict] = None + + created_at: int + updated_at: int + + +#################### +# Forms +#################### + + +class CalendarForm(BaseModel): + name: str + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarUpdateForm(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + access_grants: Optional[list[dict]] = None + + +class CalendarEventForm(BaseModel): + calendar_id: str + title: str + description: Optional[str] = None + start_at: int + end_at: Optional[int] = None + all_day: bool = False + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + attendees: Optional[list[dict]] = None + + +class CalendarEventUpdateForm(BaseModel): + calendar_id: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + start_at: Optional[int] = None + end_at: Optional[int] = None + all_day: Optional[bool] = None + rrule: Optional[str] = None + color: Optional[str] = None + location: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None + is_cancelled: Optional[bool] = None + attendees: Optional[list[dict]] = None + + +class RSVPForm(BaseModel): + status: str # 'accepted' | 'declined' | 'tentative' | 'pending' + + +#################### +# Response Models +#################### + + +class CalendarEventUserResponse(CalendarEventModel): + user: Optional[UserResponse] = None + + +class CalendarEventListResponse(BaseModel): + items: list[CalendarEventUserResponse] + total: int + + +#################### +# Table Operations +#################### + + +class CalendarTable: + async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db) + + async def _to_calendar_model( + self, + cal: Calendar, + access_grants: Optional[list[AccessGrantModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarModel: + cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'}) + cal_data['access_grants'] = ( + access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db) + ) + return CalendarModel.model_validate(cal_data) + + async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: + """Return user's calendars, creating 'Personal' default if none exist.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc()) + ) + calendars = result.scalars().all() + + if calendars: + return [CalendarModel.model_validate(c) for c in calendars] + + now = int(time.time_ns()) + cal = Calendar( + id=str(uuid4()), + user_id=user_id, + name='Personal', + color='#3b82f6', + is_default=True, + created_at=now, + updated_at=now, + ) + db.add(cal) + await db.commit() + return [CalendarModel.model_validate(cal)] + + async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]: + """Owned + shared calendars.""" + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + stmt = select(Calendar) + stmt = AccessGrants.has_permission_filter( + db=db, + query=stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + stmt = stmt.order_by(Calendar.created_at.asc()) + + result = await db.execute(stmt) + calendars = result.scalars().all() + + if not calendars: + return await self.get_or_create_defaults(user_id, db=db) + + cal_ids = [c.id for c in calendars] + grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db) + + return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars] + + async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + return await self._to_calendar_model(cal, db=db) if cal else None + + async def insert_new_calendar( + self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + cal = Calendar( + id=str(uuid4()), + user_id=user_id, + name=form_data.name, + color=form_data.color, + is_default=False, + data=form_data.data, + meta=form_data.meta, + created_at=now, + updated_at=now, + ) + db.add(cal) + await db.commit() + if form_data.access_grants is not None: + await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db) + return await self._to_calendar_model(cal, db=db) + + async def update_calendar_by_id( + self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal: + return None + + update_data = form_data.model_dump(exclude_unset=True) + if 'name' in update_data: + cal.name = update_data['name'] + if 'color' in update_data: + cal.color = update_data['color'] + if 'data' in update_data: + cal.data = {**(cal.data or {}), **update_data['data']} + if 'meta' in update_data: + cal.meta = {**(cal.meta or {}), **update_data['meta']} + if 'access_grants' in update_data: + await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db) + + cal.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_calendar_model(cal, db=db) + + async def set_default_calendar( + self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarModel]: + """Set a calendar as the user's default, clearing all others.""" + async with get_async_db_context(db) as db: + # Clear all defaults for this user + await db.execute( + update(Calendar) + .where(Calendar.user_id == user_id, Calendar.is_default == True) + .values(is_default=False) + ) + # Set the new default + result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id)) + cal = result.scalars().first() + if not cal: + return None + cal.is_default = True + cal.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_calendar_model(cal, db=db) + + async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete a non-default calendar. Cascades to events, attendees, and grants.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Calendar).filter(Calendar.id == id)) + cal = result.scalars().first() + if not cal or cal.is_default: + return False + + # Delete attendees for all events in this calendar + event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id)) + event_ids = [r[0] for r in event_ids_result.all()] + if event_ids: + await db.execute( + delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) + ) + + # Delete events + await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id)) + + # Delete calendar + await db.execute(delete(Calendar).filter(Calendar.id == id)) + await db.commit() + + # Revoke access grants in a separate transaction to avoid + # write-lock contention on SQLite when session sharing is off. + await AccessGrants.revoke_all_access('calendar', id) + return True + except Exception as e: + log.exception(f'Failed to delete calendar {id}: {e}') + return False + + +class CalendarEventTable: + async def _get_attendees( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) + rows = result.scalars().all() + return [CalendarEventAttendeeModel.model_validate(r) for r in rows] + + async def _to_event_model( + self, + event: CalendarEvent, + attendees: Optional[list[CalendarEventAttendeeModel]] = None, + db: Optional[AsyncSession] = None, + ) -> CalendarEventModel: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = ( + attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db) + ) + return CalendarEventModel.model_validate(event_data) + + async def insert_new_event( + self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + now = int(time.time_ns()) + event = CalendarEvent( + id=str(uuid4()), + calendar_id=form_data.calendar_id, + user_id=user_id, + title=form_data.title, + description=form_data.description, + start_at=form_data.start_at, + end_at=form_data.end_at, + all_day=form_data.all_day, + rrule=form_data.rrule, + color=form_data.color, + location=form_data.location, + data=form_data.data, + meta=form_data.meta, + is_cancelled=False, + created_at=now, + updated_at=now, + ) + db.add(event) + await db.commit() + + # Add attendees + if form_data.attendees: + await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db) + + return await self._to_event_model(event, db=db) + + async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + return await self._to_event_model(event, db=db) if event else None + + async def get_events_by_range( + self, + user_id: str, + start: int, + end: int, + calendar_ids: Optional[list[str]] = None, + db: Optional[AsyncSession] = None, + ) -> list[CalendarEventUserResponse]: + """Fetch events visible to user within a date range. + + Visible events = events in owned/shared calendars + events user attends. + Recurring events are fetched if they have any rrule (expansion in Python). + """ + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get calendar IDs accessible to user + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + + if calendar_ids: + # Filter to requested calendars only + accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids] + + # Also get event IDs where user is an attendee + attendee_event_ids_result = await db.execute( + select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) + ) + attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()] + + # Build conditions for accessible events + conditions = [] + if accessible_cal_ids: + conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids)) + if attendee_event_ids: + conditions.append(CalendarEvent.id.in_(attendee_event_ids)) + + if not conditions: + return [] + + # Build event query + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + or_(*conditions), + or_( + # Non-recurring: overlaps the range + ( + CalendarEvent.rrule.is_(None) + & (CalendarEvent.start_at < end) + & or_( + CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start), + CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start), + ) + ), + # Recurring: fetch all (expansion in Python) + CalendarEvent.rrule.isnot(None), + ), + ) + .order_by(CalendarEvent.start_at.asc()) + ) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return [] + + # Batch-load attendees for all events in one query (avoid N+1) + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a)) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return events + + async def search_events( + self, + user_id: str, + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + db: Optional[AsyncSession] = None, + ) -> CalendarEventListResponse: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [g.id for g in user_groups] + + # Get accessible calendar IDs + cal_stmt = select(Calendar.id) + cal_stmt = AccessGrants.has_permission_filter( + db=db, + query=cal_stmt, + DocumentModel=Calendar, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='calendar', + permission='read', + ) + cal_result = await db.execute(cal_stmt) + accessible_cal_ids = [r[0] for r in cal_result.all()] + if not accessible_cal_ids: + return CalendarEventListResponse(items=[], total=0) + + stmt = ( + select(CalendarEvent, User) + .outerjoin(User, User.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + CalendarEvent.calendar_id.in_(accessible_cal_ids), + ) + ) + + if query: + search = f'%{query}%' + stmt = stmt.filter( + or_( + CalendarEvent.title.ilike(search), + CalendarEvent.description.ilike(search), + CalendarEvent.location.ilike(search), + ) + ) + + stmt = stmt.order_by(CalendarEvent.start_at.desc()) + + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + items = result.all() + + if not items: + return CalendarEventListResponse(items=[], total=total) + + # Batch-load attendees + event_ids = [event.id for event, _user in items] + att_result = await db.execute( + select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids)) + ) + att_rows = att_result.scalars().all() + att_map: dict[str, list[CalendarEventAttendeeModel]] = {} + for a in att_rows: + att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a)) + + events = [] + for event, user in items: + event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'}) + event_data['attendees'] = att_map.get(event.id, []) + events.append( + CalendarEventUserResponse( + **event_data, + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + ) + ) + + return CalendarEventListResponse(items=events, total=total) + + async def update_event_by_id( + self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id)) + event = result.scalars().first() + if not event: + return None + + update_data = form_data.model_dump(exclude_unset=True) + for field in [ + 'calendar_id', + 'title', + 'description', + 'start_at', + 'end_at', + 'all_day', + 'rrule', + 'color', + 'location', + 'is_cancelled', + ]: + if field in update_data: + setattr(event, field, update_data[field]) + + if 'data' in update_data and update_data['data'] is not None: + event.data = {**(event.data or {}), **update_data['data']} + if 'meta' in update_data and update_data['meta'] is not None: + event.meta = {**(event.meta or {}), **update_data['meta']} + + if 'attendees' in update_data and update_data['attendees'] is not None: + await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db) + + event.updated_at = int(time.time_ns()) + await db.commit() + return await self._to_event_model(event, db=db) + + async def get_upcoming_events( + self, + now_ns: int, + default_lookahead_ns: int, + db: Optional[AsyncSession] = None, + ) -> list[tuple[CalendarEventModel, Optional[str]]]: + """Events starting between now and now + lookahead, for alert processing. + + Per-event lookahead is read from meta.alert_minutes (falls back to + default_lookahead_ns). Returns (event, user_timezone) pairs. + """ + from open_webui.models.users import User as UserRow + + # Use the maximum possible lookahead (60 min) to cast a wide net; + # per-event filtering happens in Python after fetching. + max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000) + upper = now_ns + max_lookahead_ns + + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEvent, UserRow.timezone) + .outerjoin(UserRow, UserRow.id == CalendarEvent.user_id) + .filter( + CalendarEvent.is_cancelled == False, + CalendarEvent.start_at >= now_ns, + CalendarEvent.start_at <= upper, + ) + ) + rows = result.all() + + events = [] + for event, tz in rows: + model = CalendarEventModel.model_validate(event) + # Determine per-event alert window + alert_minutes = None + if model.meta and 'alert_minutes' in model.meta: + alert_minutes = model.meta['alert_minutes'] + + if alert_minutes is not None: + if alert_minutes < 0: + # alert_minutes < 0 means "no alert" + continue + event_lookahead_ns = alert_minutes * 60 * 1_000_000_000 + else: + event_lookahead_ns = default_lookahead_ns + + if model.start_at <= now_ns + event_lookahead_ns: + events.append((model, tz)) + + return events + + async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id)) + await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id)) + await db.commit() + return True + except Exception: + return False + + +class CalendarEventAttendeeTable: + async def set_attendees( + self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + """Replace all attendees for an event. + + Each dict in attendees: {user_id: str, status?: str, meta?: dict} + """ + async with get_async_db_context(db) as db: + # Remove existing + await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) + + now = int(time.time_ns()) + models = [] + for att in attendees: + row = CalendarEventAttendee( + id=str(uuid4()), + event_id=event_id, + user_id=att['user_id'], + status=att.get('status', 'pending'), + meta=att.get('meta'), + created_at=now, + updated_at=now, + ) + db.add(row) + models.append(CalendarEventAttendeeModel.model_validate(row)) + + await db.commit() + return models + + async def update_rsvp( + self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None + ) -> Optional[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee).filter( + CalendarEventAttendee.event_id == event_id, + CalendarEventAttendee.user_id == user_id, + ) + ) + att = result.scalars().first() + if not att: + return None + + att.status = status + att.updated_at = int(time.time_ns()) + await db.commit() + return CalendarEventAttendeeModel.model_validate(att) + + async def get_attendees_by_event( + self, event_id: str, db: Optional[AsyncSession] = None + ) -> list[CalendarEventAttendeeModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) + return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()] + + async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]: + """Return event IDs where user is an attendee.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id) + ) + return [r[0] for r in result.all()] + + +Calendars = CalendarTable() +CalendarEvents = CalendarEventTable() +CalendarEventAttendees = CalendarEventAttendeeTable() diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 4d773491d5a..adeaeaf9da4 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -4,15 +4,18 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.utils.validate import validate_profile_image_url + +from sqlalchemy import select, delete, update, func, case, or_, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.groups import Groups from open_webui.models.access_grants import ( AccessGrantModel, AccessGrants, ) -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy.dialects.postgresql import JSONB @@ -25,11 +28,7 @@ Text, JSON, UniqueConstraint, - case, - cast, ) -from sqlalchemy import or_, func, select, and_, text -from sqlalchemy.sql import exists #################### # Channel DB Schema @@ -247,24 +246,31 @@ class ChannelWebhookForm(BaseModel): name: str profile_image_url: Optional[str] = None + @field_validator('profile_image_url', mode='before') + @classmethod + def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return validate_profile_image_url(v) + class ChannelTable: - def _get_access_grants(self, channel_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('channel', channel_id, db=db) + async def _get_access_grants(self, channel_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('channel', channel_id, db=db) - def _to_channel_model( + async def _to_channel_model( self, channel: Channel, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> ChannelModel: channel_data = ChannelModel.model_validate(channel).model_dump(exclude={'access_grants'}) channel_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(channel_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(channel_data['id'], db=db) ) return ChannelModel.model_validate(channel_data) - def _collect_unique_user_ids( + async def _collect_unique_user_ids( self, invited_by: str, user_ids: Optional[list[str]] = None, @@ -281,7 +287,8 @@ def _collect_unique_user_ids( users.add(invited_by) for group_id in group_ids or []: - users.update(Groups.get_group_user_ids_by_id(group_id)) + group_user_ids = await Groups.get_group_user_ids_by_id(group_id) + users.update(group_user_ids) return users @@ -321,10 +328,20 @@ def _create_membership_models( return memberships - def insert_new_channel( - self, form_data: CreateChannelForm, user_id: str, db: Optional[Session] = None + def _has_permission(self, db, query, filter: dict, permission: str = 'read'): + return AccessGrants.has_permission_filter( + db=db, + query=query, + DocumentModel=Channel, + filter=filter, + resource_type='channel', + permission=permission, + ) + + async def insert_new_channel( + self, form_data: CreateChannelForm, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChannelModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: channel = ChannelModel( **{ **form_data.model_dump(exclude={'access_grants'}), @@ -340,7 +357,7 @@ def insert_new_channel( new_channel = Channel(**channel.model_dump(exclude={'access_grants'})) if form_data.type in ['group', 'dm']: - users = self._collect_unique_user_ids( + users = await self._collect_unique_user_ids( invited_by=user_id, user_ids=form_data.user_ids, group_ids=form_data.group_ids, @@ -353,17 +370,18 @@ def insert_new_channel( db.add_all(memberships) db.add(new_channel) - db.commit() - AccessGrants.set_access_grants('channel', new_channel.id, form_data.access_grants, db=db) - return self._to_channel_model(new_channel, db=db) - - def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: - with get_db_context(db) as db: - channels = db.query(Channel).all() + await db.commit() + await AccessGrants.set_access_grants('channel', new_channel.id, form_data.access_grants, db=db) + return await self._to_channel_model(new_channel, db=db) + + async def get_channels(self, db: Optional[AsyncSession] = None) -> list[ChannelModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Channel)) + channels = result.scalars().all() channel_ids = [channel.id for channel in channels] - grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) return [ - self._to_channel_model( + await self._to_channel_model( channel, access_grants=grants_map.get(channel.id, []), db=db, @@ -371,22 +389,12 @@ def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: for channel in channels ] - def _has_permission(self, db, query, filter: dict, permission: str = 'read'): - return AccessGrants.has_permission_filter( - db=db, - query=query, - DocumentModel=Channel, - filter=filter, - resource_type='channel', - permission=permission, - ) - - def get_channels_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChannelModel]: - with get_db_context(db) as db: - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] + async def get_channels_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[ChannelModel]: + async with get_async_db_context(db) as db: + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)] - membership_channels = ( - db.query(Channel) + result = await db.execute( + select(Channel) .join(ChannelMember, Channel.id == ChannelMember.channel_id) .filter( Channel.deleted_at.is_(None), @@ -395,10 +403,10 @@ def get_channels_by_user_id(self, user_id: str, db: Optional[Session] = None) -> ChannelMember.user_id == user_id, ChannelMember.is_active.is_(True), ) - .all() ) + membership_channels = result.scalars().all() - query = db.query(Channel).filter( + stmt = select(Channel).filter( Channel.deleted_at.is_(None), Channel.archived_at.is_(None), or_( @@ -407,17 +415,22 @@ def get_channels_by_user_id(self, user_id: str, db: Optional[Session] = None) -> and_(Channel.type != 'group', Channel.type != 'dm'), ), ) - query = self._has_permission(db, query, {'user_id': user_id, 'group_ids': user_group_ids}) + stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}) - standard_channels = query.all() + result = await db.execute(stmt) + standard_channels = result.scalars().all() - all_channels = membership_channels + standard_channels + all_channels = list(membership_channels) + list(standard_channels) channel_ids = [c.id for c in all_channels] - grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) - return [self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in all_channels] + grants_map = await AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) + return [ + await self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in all_channels + ] - def get_dm_channel_by_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> Optional[ChannelModel]: - with get_db_context(db) as db: + async def get_dm_channel_by_user_ids( + self, user_ids: list[str], db: Optional[AsyncSession] = None + ) -> Optional[ChannelModel]: + async with get_async_db_context(db) as db: # Ensure uniqueness in case a list with duplicates is passed unique_user_ids = list(set(user_ids)) @@ -429,7 +442,7 @@ def get_dm_channel_by_user_ids(self, user_ids: list[str], db: Optional[Session] ) subquery = ( - db.query(ChannelMember.channel_id) + select(ChannelMember.channel_id) .group_by(ChannelMember.channel_id) # 1. Channel must have exactly len(user_ids) members .having(func.count(ChannelMember.user_id) == len(unique_user_ids)) @@ -438,33 +451,32 @@ def get_dm_channel_by_user_ids(self, user_ids: list[str], db: Optional[Session] .subquery() ) - channel = ( - db.query(Channel) + result = await db.execute( + select(Channel) .filter( - Channel.id.in_(subquery), + Channel.id.in_(select(subquery.c.channel_id)), Channel.type == 'dm', ) - .first() + .limit(1) ) + channel = result.scalars().first() - return self._to_channel_model(channel, db=db) if channel else None + return await self._to_channel_model(channel, db=db) if channel else None - def add_members_to_channel( + async def add_members_to_channel( self, channel_id: str, invited_by: str, user_ids: Optional[list[str]] = None, group_ids: Optional[list[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChannelMemberModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # 1. Collect all user_ids including groups + inviter - requested_users = self._collect_unique_user_ids(invited_by, user_ids, group_ids) + requested_users = await self._collect_unique_user_ids(invited_by, user_ids, group_ids) - existing_users = { - row.user_id - for row in db.query(ChannelMember.user_id).filter(ChannelMember.channel_id == channel_id).all() - } + result = await db.execute(select(ChannelMember.user_id).filter(ChannelMember.channel_id == channel_id)) + existing_users = {row[0] for row in result.all()} new_user_ids = requested_users - existing_users if not new_user_ids: @@ -473,58 +485,56 @@ def add_members_to_channel( new_memberships = self._create_membership_models(channel_id, invited_by, new_user_ids) db.add_all(new_memberships) - db.commit() + await db.commit() return [ChannelMemberModel.model_validate(membership) for membership in new_memberships] - def remove_members_from_channel( + async def remove_members_from_channel( self, channel_id: str, user_ids: list[str], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> int: - with get_db_context(db) as db: - result = ( - db.query(ChannelMember) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + delete(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id.in_(user_ids), ) - .delete(synchronize_session=False) ) - db.commit() - return result # number of rows deleted - - def is_user_channel_manager(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - # Check if the user is the creator of the channel - # or has a 'manager' role in ChannelMember - channel = db.query(Channel).filter(Channel.id == channel_id).first() + await db.commit() + return result.rowcount # number of rows deleted + + async def is_user_channel_manager(self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(Channel).filter(Channel.id == channel_id)) + channel = result.scalars().first() if channel and channel.user_id == user_id: return True - membership = ( - db.query(ChannelMember) - .filter( + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, + ChannelMember.is_active.is_(True), ChannelMember.role == 'manager', ) - .first() ) + membership = result.scalars().first() return membership is not None - def join_channel(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> Optional[ChannelMemberModel]: - with get_db_context(db) as db: + async def join_channel( + self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[ChannelMemberModel]: + async with get_async_db_context(db) as db: # Check if the membership already exists - existing_membership = ( - db.query(ChannelMember) - .filter( + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + existing_membership = result.scalars().first() if existing_membership: return ChannelMemberModel.model_validate(existing_membership) @@ -548,19 +558,18 @@ def join_channel(self, channel_id: str, user_id: str, db: Optional[Session] = No new_membership = ChannelMember(**channel_member.model_dump()) db.add(new_membership) - db.commit() + await db.commit() return channel_member - def leave_channel(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) - .filter( + async def leave_channel(self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + membership = result.scalars().first() if not membership: return False @@ -569,125 +578,131 @@ def leave_channel(self, channel_id: str, user_id: str, db: Optional[Session] = N membership.left_at = int(time.time_ns()) membership.updated_at = int(time.time_ns()) - db.commit() + await db.commit() return True - def get_member_by_channel_and_user_id( - self, channel_id: str, user_id: str, db: Optional[Session] = None + async def get_member_by_channel_and_user_id( + self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChannelMemberModel]: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + membership = result.scalars().first() return ChannelMemberModel.model_validate(membership) if membership else None - def get_members_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> list[ChannelMemberModel]: - with get_db_context(db) as db: - memberships = db.query(ChannelMember).filter(ChannelMember.channel_id == channel_id).all() + async def get_members_by_channel_id( + self, channel_id: str, db: Optional[AsyncSession] = None + ) -> list[ChannelMemberModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelMember).filter(ChannelMember.channel_id == channel_id)) + memberships = result.scalars().all() return [ChannelMemberModel.model_validate(membership) for membership in memberships] - def pin_channel( + async def pin_channel( self, channel_id: str, user_id: str, is_pinned: bool, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + membership = result.scalars().first() if not membership: return False membership.is_channel_pinned = is_pinned membership.updated_at = int(time.time_ns()) - db.commit() + await db.commit() return True - def update_member_last_read_at(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) - .filter( + async def update_member_last_read_at( + self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + membership = result.scalars().first() if not membership: return False membership.last_read_at = int(time.time_ns()) membership.updated_at = int(time.time_ns()) - db.commit() + await db.commit() return True - def update_member_active_status( + async def update_member_active_status( self, channel_id: str, user_id: str, is_active: bool, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember).filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, ) - .first() ) + membership = result.scalars().first() if not membership: return False membership.is_active = is_active membership.updated_at = int(time.time_ns()) - db.commit() + await db.commit() return True - def is_user_channel_member(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - membership = ( - db.query(ChannelMember) + async def is_user_channel_member(self, channel_id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelMember) .filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, + ChannelMember.is_active.is_(True), ) - .first() + .limit(1) ) + membership = result.scalars().first() return membership is not None - def get_channel_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChannelModel]: + async def get_channel_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChannelModel]: try: - with get_db_context(db) as db: - channel = db.query(Channel).filter(Channel.id == id).first() - return self._to_channel_model(channel, db=db) if channel else None + async with get_async_db_context(db) as db: + result = await db.execute(select(Channel).filter(Channel.id == id)) + channel = result.scalars().first() + return await self._to_channel_model(channel, db=db) if channel else None except Exception: return None - def get_channels_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[ChannelModel]: - with get_db_context(db) as db: - channel_files = db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() + async def get_channels_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChannelModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelFile).filter(ChannelFile.file_id == file_id)) + channel_files = result.scalars().all() channel_ids = [cf.channel_id for cf in channel_files] - channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all() - grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) + result = await db.execute(select(Channel).filter(Channel.id.in_(channel_ids))) + channels = result.scalars().all() + grants_map = await AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) return [ - self._to_channel_model( + await self._to_channel_model( channel, access_grants=grants_map.get(channel.id, []), db=db, @@ -695,123 +710,127 @@ def get_channels_by_file_id(self, file_id: str, db: Optional[Session] = None) -> for channel in channels ] - def get_channels_by_file_id_and_user_id( - self, file_id: str, user_id: str, db: Optional[Session] = None + async def get_channels_by_file_id_and_user_id( + self, file_id: str, user_id: str, db: Optional[AsyncSession] = None ) -> list[ChannelModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # 1. Determine which channels have this file - channel_file_rows = db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() + result = await db.execute(select(ChannelFile).filter(ChannelFile.file_id == file_id)) + channel_file_rows = result.scalars().all() channel_ids = [row.channel_id for row in channel_file_rows] if not channel_ids: return [] # 2. Load all channel rows that still exist - channels = ( - db.query(Channel) - .filter( + result = await db.execute( + select(Channel).filter( Channel.id.in_(channel_ids), Channel.deleted_at.is_(None), Channel.archived_at.is_(None), ) - .all() ) + channels = result.scalars().all() if not channels: return [] # Preload user's group membership - user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id, db=db)] + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, db=db)] allowed_channels = [] for channel in channels: # --- Case A: group or dm => user must be an active member --- if channel.type in ['group', 'dm']: - membership = ( - db.query(ChannelMember) + result = await db.execute( + select(ChannelMember) .filter( ChannelMember.channel_id == channel.id, ChannelMember.user_id == user_id, ChannelMember.is_active.is_(True), ) - .first() + .limit(1) ) + membership = result.scalars().first() if membership: - allowed_channels.append(self._to_channel_model(channel, db=db)) + allowed_channels.append(await self._to_channel_model(channel, db=db)) continue # --- Case B: standard channel => rely on ACL permissions --- - query = db.query(Channel).filter(Channel.id == channel.id) + stmt = select(Channel).filter(Channel.id == channel.id) - query = self._has_permission( + stmt = self._has_permission( db, - query, + stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission='read', ) - allowed = query.first() + result = await db.execute(stmt) + allowed = result.scalars().first() if allowed: - allowed_channels.append(self._to_channel_model(allowed, db=db)) + allowed_channels.append(await self._to_channel_model(allowed, db=db)) return allowed_channels - def get_channel_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None + async def get_channel_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChannelModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Fetch the channel - channel: Channel = ( - db.query(Channel) - .filter( + result = await db.execute( + select(Channel).filter( Channel.id == id, Channel.deleted_at.is_(None), Channel.archived_at.is_(None), ) - .first() ) + channel = result.scalars().first() if not channel: return None # If the channel is a group or dm, read access requires membership (active) if channel.type in ['group', 'dm']: - membership = ( - db.query(ChannelMember) + result = await db.execute( + select(ChannelMember) .filter( ChannelMember.channel_id == id, ChannelMember.user_id == user_id, ChannelMember.is_active.is_(True), ) - .first() + .limit(1) ) + membership = result.scalars().first() if membership: - return self._to_channel_model(channel, db=db) + return await self._to_channel_model(channel, db=db) else: return None # For channels that are NOT group/dm, fall back to ACL-based read access - query = db.query(Channel).filter(Channel.id == id) + stmt = select(Channel).filter(Channel.id == id) # Determine user groups - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)] # Apply ACL rules - query = self._has_permission( + stmt = self._has_permission( db, - query, + stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission='read', ) - channel_allowed = query.first() - return self._to_channel_model(channel_allowed, db=db) if channel_allowed else None + result = await db.execute(stmt) + channel_allowed = result.scalars().first() + return await self._to_channel_model(channel_allowed, db=db) if channel_allowed else None - def update_channel_by_id( - self, id: str, form_data: ChannelForm, db: Optional[Session] = None + async def update_channel_by_id( + self, id: str, form_data: ChannelForm, db: Optional[AsyncSession] = None ) -> Optional[ChannelModel]: - with get_db_context(db) as db: - channel = db.query(Channel).filter(Channel.id == id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Channel).filter(Channel.id == id)) + channel = result.scalars().first() if not channel: return None @@ -823,16 +842,16 @@ def update_channel_by_id( channel.meta = form_data.meta if form_data.access_grants is not None: - AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db) channel.updated_at = int(time.time_ns()) - db.commit() - return self._to_channel_model(channel, db=db) if channel else None + await db.commit() + return await self._to_channel_model(channel, db=db) if channel else None - def add_file_to_channel_by_id( - self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None + async def add_file_to_channel_by_id( + self, channel_id: str, file_id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChannelFileModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: channel_file = ChannelFileModel( **{ 'id': str(uuid.uuid4()), @@ -847,8 +866,8 @@ def add_file_to_channel_by_id( try: result = ChannelFile(**channel_file.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return ChannelFileModel.model_validate(result) else: @@ -856,55 +875,58 @@ def add_file_to_channel_by_id( except Exception: return None - def set_file_message_id_in_channel_by_id( + async def set_file_message_id_in_channel_by_id( self, channel_id: str, file_id: str, message_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: try: - with get_db_context(db) as db: - channel_file = db.query(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id)) + channel_file = result.scalars().first() if not channel_file: return False channel_file.message_id = message_id channel_file.updated_at = int(time.time()) - db.commit() + await db.commit() return True except Exception: return False - def remove_file_from_channel_by_id(self, channel_id: str, file_id: str, db: Optional[Session] = None) -> bool: + async def remove_file_from_channel_by_id( + self, channel_id: str, file_id: str, db: Optional[AsyncSession] = None + ) -> bool: try: - with get_db_context(db) as db: - db.query(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id)) + await db.commit() return True except Exception: return False - def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('channel', id, db=db) - db.query(Channel).filter(Channel.id == id).delete() - db.commit() + async def delete_channel_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('channel', id, db=db) + await db.execute(delete(Channel).filter(Channel.id == id)) + await db.commit() return True #################### # Webhook Methods #################### - def insert_webhook( + async def insert_webhook( self, channel_id: str, user_id: str, form_data: ChannelWebhookForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[ChannelWebhookModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: webhook = ChannelWebhookModel( id=str(uuid.uuid4()), channel_id=channel_id, @@ -917,63 +939,70 @@ def insert_webhook( updated_at=int(time.time_ns()), ) db.add(ChannelWebhook(**webhook.model_dump())) - db.commit() + await db.commit() return webhook - def get_webhooks_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> list[ChannelWebhookModel]: - with get_db_context(db) as db: - webhooks = db.query(ChannelWebhook).filter(ChannelWebhook.channel_id == channel_id).all() + async def get_webhooks_by_channel_id( + self, channel_id: str, db: Optional[AsyncSession] = None + ) -> list[ChannelWebhookModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelWebhook).filter(ChannelWebhook.channel_id == channel_id)) + webhooks = result.scalars().all() return [ChannelWebhookModel.model_validate(w) for w in webhooks] - def get_webhook_by_id(self, webhook_id: str, db: Optional[Session] = None) -> Optional[ChannelWebhookModel]: - with get_db_context(db) as db: - webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + async def get_webhook_by_id( + self, webhook_id: str, db: Optional[AsyncSession] = None + ) -> Optional[ChannelWebhookModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelWebhook).filter(ChannelWebhook.id == webhook_id)) + webhook = result.scalars().first() return ChannelWebhookModel.model_validate(webhook) if webhook else None - def get_webhook_by_id_and_token( - self, webhook_id: str, token: str, db: Optional[Session] = None + async def get_webhook_by_id_and_token( + self, webhook_id: str, token: str, db: Optional[AsyncSession] = None ) -> Optional[ChannelWebhookModel]: - with get_db_context(db) as db: - webhook = ( - db.query(ChannelWebhook) - .filter( + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChannelWebhook).filter( ChannelWebhook.id == webhook_id, ChannelWebhook.token == token, ) - .first() ) + webhook = result.scalars().first() return ChannelWebhookModel.model_validate(webhook) if webhook else None - def update_webhook_by_id( + async def update_webhook_by_id( self, webhook_id: str, form_data: ChannelWebhookForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[ChannelWebhookModel]: - with get_db_context(db) as db: - webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelWebhook).filter(ChannelWebhook.id == webhook_id)) + webhook = result.scalars().first() if not webhook: return None webhook.name = form_data.name webhook.profile_image_url = form_data.profile_image_url webhook.updated_at = int(time.time_ns()) - db.commit() + await db.commit() return ChannelWebhookModel.model_validate(webhook) - def update_webhook_last_used_at(self, webhook_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + async def update_webhook_last_used_at(self, webhook_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(ChannelWebhook).filter(ChannelWebhook.id == webhook_id)) + webhook = result.scalars().first() if not webhook: return False webhook.last_used_at = int(time.time_ns()) - db.commit() + await db.commit() return True - def delete_webhook_by_id(self, webhook_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - result = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).delete() - db.commit() - return result > 0 + async def delete_webhook_by_id(self, webhook_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(delete(ChannelWebhook).filter(ChannelWebhook.id == webhook_id)) + await db.commit() + return result.rowcount > 0 Channels = ChannelTable() diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 97490c16028..a7d875c9dc4 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -3,8 +3,10 @@ import uuid from typing import Any, Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db_context +from sqlalchemy import select, delete, func, cast, Integer +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context +from open_webui.utils.response import normalize_usage from pydantic import BaseModel, ConfigDict from sqlalchemy import ( @@ -15,7 +17,6 @@ Text, JSON, Index, - func, ) #################### @@ -41,6 +42,31 @@ def _normalize_timestamp(timestamp: int) -> float: return timestamp +def get_usage(data: dict) -> Optional[dict]: + """Extract and normalize usage from message data.""" + usage = data.get('usage') or (data.get('info') or {}).get('usage') + return normalize_usage(usage) if usage else None + + +def _token_columns(dialect: str): + """Return (input_tokens, output_tokens) SQL column expressions. + + Falls back to OpenAI-style keys (prompt_tokens / completion_tokens) + when the normalized keys are absent. + """ + if dialect == 'sqlite': + extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer) + elif dialect == 'postgresql': + extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer) + else: + raise NotImplementedError(f'Unsupported dialect: {dialect}') + + return ( + func.coalesce(extract('input_tokens'), extract('prompt_tokens')), + func.coalesce(extract('output_tokens'), extract('completion_tokens')), + ) + + #################### # ChatMessage DB Schema #################### @@ -122,23 +148,23 @@ class ChatMessageModel(BaseModel): class ChatMessageTable: - def upsert_message( + async def upsert_message( self, message_id: str, chat_id: str, user_id: str, data: dict, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[ChatMessageModel]: """Insert or update a chat message.""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: now = int(time.time()) timestamp = data.get('timestamp', now) # Use composite ID: {chat_id}-{message_id} composite_id = f'{chat_id}-{message_id}' - existing = db.get(ChatMessage, composite_id) + existing = await db.get(ChatMessage, composite_id) if existing: # Update existing if 'role' in data: @@ -163,24 +189,21 @@ def upsert_message( existing.status_history = data.get('status_history') or data.get('statusHistory') if 'error' in data: existing.error = data.get('error') - # Extract usage - check direct field first, then info.usage - usage = data.get('usage') - if not usage: - info = data.get('info', {}) - usage = info.get('usage') if info else None + # Extract and normalize usage + usage = get_usage(data) if usage: - existing.usage = usage + # Deep-merge: preserve existing keys not present in new data + # This prevents background tasks (follow-ups, title, tags) + # from accidentally clearing the primary response's token counts + existing.usage = {**(existing.usage or {}), **usage} existing.updated_at = now - db.commit() - db.refresh(existing) + await db.commit() + await db.refresh(existing) return ChatMessageModel.model_validate(existing) else: # Insert new - # Extract usage - check direct field first, then info.usage - usage = data.get('usage') - if not usage: - info = data.get('info', {}) - usage = info.get('usage') if info else None + # Extract and normalize usage + usage = get_usage(data) message = ChatMessage( id=composite_id, chat_id=chat_id, @@ -201,155 +224,220 @@ def upsert_message( updated_at=now, ) db.add(message) - db.commit() - db.refresh(message) + await db.commit() + await db.refresh(message) return ChatMessageModel.model_validate(message) - def get_message_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatMessageModel]: - with get_db_context(db) as db: - message = db.get(ChatMessage, id) + async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]: + async with get_async_db_context(db) as db: + message = await db.get(ChatMessage, id) return ChatMessageModel.model_validate(message) if message else None - def get_messages_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> list[ChatMessageModel]: - with get_db_context(db) as db: - messages = db.query(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc()).all() + async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc()) + ) + messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] - def get_messages_by_user_id( + # DB column names that differ from the JSON message keys. + DB_TO_JSON_KEY_MAP = { + 'parent_id': 'parentId', + 'model_id': 'model', + 'status_history': 'statusHistory', + 'created_at': 'timestamp', + } + # DB-internal columns excluded from the reconstructed message dict. + EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'}) + + async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]: + """Build a {message_id: message_dict} map from chat_message rows. + + Returns the same shape as chat.history.messages so callers + (get_message_list, middleware) work unchanged. Returns None if + no rows exist for the chat (caller should fall back to the + embedded JSON blob for legacy chats). + """ + async with get_async_db_context(db) as db: + result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id)) + rows = result.scalars().all() + + if not rows: + return None + + # Strip the composite-id prefix ("{chat_id}-") to recover the + # original message_id used as map key. + prefix = f'{chat_id}-' + prefix_len = len(prefix) + col_keys = [c.key for c in ChatMessage.__table__.columns] + + messages_map: dict[str, dict] = {} + for row in rows: + msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id + + msg: dict = {'id': msg_id} + for key in col_keys: + if key in self.EXCLUDED_COLUMNS: + continue + val = getattr(row, key) + if val is None: + continue + json_key = self.DB_TO_JSON_KEY_MAP.get(key, key) + msg[json_key] = val + + # Ensure content always has a value + msg.setdefault('content', '') + + # Mirror usage into info.usage for callers that read it there + if 'usage' in msg: + msg['info'] = {'usage': msg['usage']} + + messages_map[msg_id] = msg + + # Reconstruct childrenIds from parentId links so that the map + # is fully navigable (callers like the frontend rely on this). + for msg_id, msg in messages_map.items(): + parent_id = msg.get('parentId') + if parent_id and parent_id in messages_map: + parent = messages_map[parent_id] + children = parent.get('childrenIds') + if children is None: + parent['childrenIds'] = [msg_id] + elif msg_id not in children: + children.append(msg_id) + + # Ensure every message has a childrenIds list (leaf nodes get []) + for msg in messages_map.values(): + if 'childrenIds' not in msg: + msg['childrenIds'] = [] + + return messages_map + + async def get_messages_by_user_id( self, user_id: str, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatMessageModel]: - with get_db_context(db) as db: - messages = ( - db.query(ChatMessage) + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChatMessage) .filter_by(user_id=user_id) .order_by(ChatMessage.created_at.desc()) .offset(skip) .limit(limit) - .all() ) + messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] - def get_messages_by_model_id( + async def get_messages_by_model_id( self, model_id: str, start_date: Optional[int] = None, end_date: Optional[int] = None, skip: int = 0, limit: int = 100, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatMessageModel]: - with get_db_context(db) as db: - query = db.query(ChatMessage).filter_by(model_id=model_id) + async with get_async_db_context(db) as db: + stmt = select(ChatMessage).filter_by(model_id=model_id) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) - messages = query.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit).all() + stmt = stmt.filter(ChatMessage.created_at <= end_date) + stmt = stmt.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] - def get_chat_ids_by_model_id( + async def get_chat_ids_by_model_id( self, model_id: str, start_date: Optional[int] = None, end_date: Optional[int] = None, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[str]: """Get distinct chat_ids that used a specific model.""" - with get_db_context(db) as db: - query = db.query( + async with get_async_db_context(db) as db: + stmt = select( ChatMessage.chat_id, func.max(ChatMessage.created_at).label('last_message_at'), ).filter(ChatMessage.model_id == model_id) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) # Group by chat_id and order by most recent message in each chat # Secondary sort on chat_id ensures deterministic pagination - # (prevents duplicates across pages when timestamps tie) - chat_ids = ( - query.group_by(ChatMessage.chat_id) + stmt = ( + stmt.group_by(ChatMessage.chat_id) .order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id) .offset(skip) .limit(limit) - .all() ) + result = await db.execute(stmt) + chat_ids = result.all() return [chat_id for chat_id, _ in chat_ids] - def delete_messages_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - db.query(ChatMessage).filter_by(chat_id=chat_id).delete() - db.commit() + async def delete_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + await db.execute(delete(ChatMessage).filter_by(chat_id=chat_id)) + await db.commit() return True # Analytics methods - def get_message_count_by_model( + async def get_message_count_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, int]: - with get_db_context(db) as db: - from sqlalchemy import func + async with get_async_db_context(db) as db: from open_webui.models.groups import GroupMember - query = db.query(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter( + stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like('shared-%'), ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.group_by(ChatMessage.model_id).all() - return {row.model_id: row.count for row in results} + stmt = stmt.group_by(ChatMessage.model_id) + result = await db.execute(stmt) + return {row.model_id: row.count for row in result.all()} - def get_token_usage_by_model( + async def get_token_usage_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, dict]: """Aggregate token usage by model using database-level aggregation.""" - with get_db_context(db) as db: - from sqlalchemy import func, cast, Integer + async with get_async_db_context(db) as db: from open_webui.models.groups import GroupMember - dialect = db.bind.dialect.name + # We need the dialect to determine JSON extraction syntax + # For async sessions, access via get_bind() + bind = await db.connection() + dialect = bind.dialect.name - if dialect == 'sqlite': - input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) - output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) - elif dialect == 'postgresql': - # Use json_extract_path_text for PostgreSQL JSON columns - input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), - Integer, - ) - output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), - Integer, - ) - else: - raise NotImplementedError(f'Unsupported dialect: {dialect}') + input_tokens, output_tokens = _token_columns(dialect) - query = db.query( + stmt = select( ChatMessage.model_id, func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), @@ -358,18 +446,18 @@ def get_token_usage_by_model( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ChatMessage.usage.isnot(None), - ~ChatMessage.user_id.like('shared-%'), ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.group_by(ChatMessage.model_id).all() + stmt = stmt.group_by(ChatMessage.model_id) + result = await db.execute(stmt) return { row.model_id: { @@ -378,40 +466,26 @@ def get_token_usage_by_model( 'total_tokens': row.input_tokens + row.output_tokens, 'message_count': row.message_count, } - for row in results + for row in result.all() } - def get_token_usage_by_user( + async def get_token_usage_by_user( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, dict]: """Aggregate token usage by user using database-level aggregation.""" - with get_db_context(db) as db: - from sqlalchemy import func, cast, Integer + async with get_async_db_context(db) as db: from open_webui.models.groups import GroupMember - dialect = db.bind.dialect.name + bind = await db.connection() + dialect = bind.dialect.name - if dialect == 'sqlite': - input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) - output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) - elif dialect == 'postgresql': - # Use json_extract_path_text for PostgreSQL JSON columns - input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), - Integer, - ) - output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), - Integer, - ) - else: - raise NotImplementedError(f'Unsupported dialect: {dialect}') + input_tokens, output_tokens = _token_columns(dialect) - query = db.query( + stmt = select( ChatMessage.user_id, func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), @@ -420,18 +494,18 @@ def get_token_usage_by_user( ChatMessage.role == 'assistant', ChatMessage.user_id.isnot(None), ChatMessage.usage.isnot(None), - ~ChatMessage.user_id.like('shared-%'), ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.group_by(ChatMessage.user_id).all() + stmt = stmt.group_by(ChatMessage.user_id) + result = await db.execute(stmt) return { row.user_id: { @@ -440,88 +514,88 @@ def get_token_usage_by_user( 'total_tokens': row.input_tokens + row.output_tokens, 'message_count': row.message_count, } - for row in results + for row in result.all() } - def get_message_count_by_user( + async def get_message_count_by_user( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, int]: - with get_db_context(db) as db: - from sqlalchemy import func + async with get_async_db_context(db) as db: from open_webui.models.groups import GroupMember - query = db.query(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter( - ~ChatMessage.user_id.like('shared-%') + stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter( + ChatMessage.role == 'assistant', ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.group_by(ChatMessage.user_id).all() - return {row.user_id: row.count for row in results} + stmt = stmt.group_by(ChatMessage.user_id) + result = await db.execute(stmt) + return {row.user_id: row.count for row in result.all()} - def get_message_count_by_chat( + async def get_message_count_by_chat( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, int]: - with get_db_context(db) as db: - from sqlalchemy import func + async with get_async_db_context(db) as db: from open_webui.models.groups import GroupMember - query = db.query(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter( - ~ChatMessage.user_id.like('shared-%') + stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter( + ChatMessage.role == 'assistant', ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.group_by(ChatMessage.chat_id).all() - return {row.chat_id: row.count for row in results} + stmt = stmt.group_by(ChatMessage.chat_id) + result = await db.execute(stmt) + return {row.chat_id: row.count for row in result.all()} - def get_daily_message_counts_by_model( + async def get_daily_message_counts_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, dict[str, int]]: """Get message counts grouped by day and model.""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: from datetime import datetime, timedelta from open_webui.models.groups import GroupMember - query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like('shared-%'), ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() - query = query.filter(ChatMessage.user_id.in_(group_users)) + group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() + stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - results = query.all() + result = await db.execute(stmt) + results = result.all() # Group by date -> model -> count daily_counts: dict[str, dict[str, int]] = {} @@ -543,28 +617,28 @@ def get_daily_message_counts_by_model( return daily_counts - def get_hourly_message_counts_by_model( + async def get_hourly_message_counts_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict[str, dict[str, int]]: """Get message counts grouped by hour and model.""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: from datetime import datetime, timedelta - query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( + stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like('shared-%'), ) if start_date: - query = query.filter(ChatMessage.created_at >= start_date) + stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: - query = query.filter(ChatMessage.created_at <= end_date) + stmt = stmt.filter(ChatMessage.created_at <= end_date) - results = query.all() + result = await db.execute(stmt) + results = result.all() # Group by hour -> model -> count hourly_counts: dict[str, dict[str, int]] = {} diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 9fe923f0046..957492d8173 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -4,11 +4,15 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update, func, or_, and_, text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import exists +from sqlalchemy.sql.expression import bindparam +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders from open_webui.models.chat_messages import ChatMessage, ChatMessages +from open_webui.models.automations import AutomationRun from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db from pydantic import BaseModel, ConfigDict @@ -23,12 +27,11 @@ Index, UniqueConstraint, ) -from sqlalchemy import or_, func, select, and_, text -from sqlalchemy.sql import exists -from sqlalchemy.sql.expression import bindparam #################### # Chat DB Schema +# Let no word spoken in this house be lost, and when the +# record is read again, let it still serve the one who spoke. #################### log = logging.getLogger(__name__) @@ -52,17 +55,17 @@ class Chat(Base): meta = Column(JSON, server_default='{}') folder_id = Column(Text, nullable=True) + tasks = Column(JSON, nullable=True) + summary = Column(Text, nullable=True) + + last_read_at = Column(BigInteger, nullable=True) + __table_args__ = ( # Performance indexes for common queries - # WHERE folder_id = ... Index('folder_id_idx', 'folder_id'), - # WHERE user_id = ... AND pinned = ... Index('user_id_pinned_idx', 'user_id', 'pinned'), - # WHERE user_id = ... AND archived = ... Index('user_id_archived_idx', 'user_id', 'archived'), - # WHERE user_id = ... ORDER BY updated_at DESC Index('updated_at_user_id_idx', 'updated_at', 'user_id'), - # WHERE folder_id = ... AND user_id = ... Index('folder_id_user_id_idx', 'folder_id', 'user_id'), ) @@ -85,6 +88,11 @@ class ChatModel(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + + last_read_at: Optional[int] = None + class ChatFile(Base): __tablename__ = 'chat_file' @@ -159,12 +167,16 @@ class ChatResponse(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + class ChatTitleIdResponse(BaseModel): id: str title: str updated_at: int created_at: int + last_read_at: Optional[int] = None class SharedChatResponse(BaseModel): @@ -280,9 +292,10 @@ def _sanitize_chat_row(self, chat_item): return changed - def insert_new_chat(self, user_id: str, form_data: ChatForm, db: Optional[Session] = None) -> Optional[ChatModel]: - with get_db_context(db) as db: - id = str(uuid.uuid4()) + async def insert_new_chat( + self, id: str, user_id: str, form_data: ChatForm, db: Optional[AsyncSession] = None + ) -> Optional[ChatModel]: + async with get_async_db_context(db) as db: chat = ChatModel( **{ 'id': id, @@ -299,8 +312,8 @@ def insert_new_chat(self, user_id: str, form_data: ChatForm, db: Optional[Sessio chat_item = Chat(**chat.model_dump()) db.add(chat_item) - db.commit() - db.refresh(chat_item) + await db.commit() + await db.refresh(chat_item) # Dual-write initial messages to chat_message table try: @@ -308,7 +321,7 @@ def insert_new_chat(self, user_id: str, form_data: ChatForm, db: Optional[Sessio messages = history.get('messages', {}) for message_id, message in messages.items(): if isinstance(message, dict) and message.get('role'): - ChatMessages.upsert_message( + await ChatMessages.upsert_message( message_id=message_id, chat_id=id, user_id=user_id, @@ -336,13 +349,13 @@ def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportFor ) return chat - def import_chats( + async def import_chats( self, user_id: str, chat_import_forms: list[ChatImportForm], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: chats = [] for form_data in chat_import_forms: @@ -350,55 +363,72 @@ def import_chats( chats.append(Chat(**chat.model_dump())) db.add_all(chats) - db.commit() + await db.commit() # Dual-write messages to chat_message table - try: - for form_data, chat_obj in zip(chat_import_forms, chats): - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) - for message_id, message in messages.items(): - if isinstance(message, dict) and message.get('role'): - ChatMessages.upsert_message( + for form_data, chat_obj in zip(chat_import_forms, chats): + history = form_data.chat.get('history', {}) + messages = history.get('messages', {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get('role'): + try: + await ChatMessages.upsert_message( message_id=message_id, chat_id=chat_obj.id, user_id=user_id, data=message, ) - except Exception as e: - log.warning(f'Failed to write imported messages to chat_message table: {e}') + except Exception as e: + log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}') return [ChatModel.model_validate(chat) for chat in chats] - def update_chat_by_id(self, id: str, chat: dict, db: Optional[Session] = None) -> Optional[ChatModel]: + async def update_chat_by_id(self, id: str, chat: dict, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat_item = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat_item = await db.get(Chat, id) chat_item.chat = self._clean_null_bytes(chat) chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' chat_item.updated_at = int(time.time()) - db.commit() - db.refresh(chat_item) + await db.commit() return ChatModel.model_validate(chat_item) except Exception: return None - def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]: - chat = self.get_chat_by_id(id) - if chat is None: - return None - - chat = chat.chat - chat['title'] = title + async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) + if chat and chat.user_id == user_id: + chat.last_read_at = int(time.time()) + await db.commit() + return True + return False + except Exception: + return False - return self.update_chat_by_id(id, chat) + async def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]: + try: + async with get_async_db_context() as db: + chat_item = await db.get(Chat, id) + if chat_item is None: + return None + clean_title = self._clean_null_bytes(title) + chat_item.title = clean_title + chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title} + chat_item.updated_at = int(time.time()) + await db.commit() + await db.refresh(chat_item) + return ChatModel.model_validate(chat_item) + except Exception: + return None - def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[ChatModel]: - with get_db_context() as db: - chat = db.get(Chat, id) + async def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[ChatModel]: + async with get_async_db_context() as db: + chat = await db.get(Chat, id) if chat is None: return None @@ -408,44 +438,120 @@ def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[Cha # Single meta update chat.meta = {**chat.meta, 'tags': new_tag_ids} - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) # Batch-create any missing tag rows - Tags.ensure_tags_exist(new_tags, user.id, db=db) + await Tags.ensure_tags_exist(new_tags, user.id, db=db) # Clean up orphaned old tags in one query removed = set(old_tags) - set(new_tag_ids) if removed: - self.delete_orphan_tags_for_user(list(removed), user.id, db=db) + await self.delete_orphan_tags_for_user(list(removed), user.id, db=db) return ChatModel.model_validate(chat) - def get_chat_title_by_id(self, id: str) -> Optional[str]: - with get_db_context() as db: - result = db.query(Chat.title).filter_by(id=id).first() - if result is None: + async def get_chat_title_by_id(self, id: str) -> Optional[str]: + async with get_async_db_context() as db: + result = await db.execute(select(Chat.title).filter_by(id=id)) + row = result.first() + if row is None: return None - return result[0] or 'New Chat' + return row[0] or 'New Chat' + + @staticmethod + def get_unresolved_parent_ids(messages_map: dict) -> set[str]: + """Return parent IDs referenced by messages but absent from the map. + + An empty set means the message graph is fully connected. + """ + return { + msg['parentId'] + for msg in messages_map.values() + if msg.get('parentId') and msg['parentId'] not in messages_map + } + + async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: + """Write messages to the ``chat_message`` table so future lookups + use the fast path. Errors are logged but never raised. + """ + for message_id, message in messages.items(): + if not isinstance(message, dict) or not message.get('role'): + continue + try: + await ChatMessages.upsert_message( + message_id=message_id, + chat_id=chat_id, + user_id=user_id, + data=message, + ) + except Exception as e: + log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e) + + async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: + """Message map for walking history (see ``get_message_list``). + + Prefer ``chat_message`` rows to avoid loading the large embedded + history; fall back to the legacy JSON when no rows exist. + When rows exist but the parent-link graph has gaps (e.g. migration + failures), missing messages are merged from the legacy history + and backfilled so future requests self-heal. + """ + # Fast path: build from normalized chat_message rows. + messages_map = await ChatMessages.get_messages_map_by_chat_id(id) + + if messages_map is not None: + unresolved_ids = self.get_unresolved_parent_ids(messages_map) + if not unresolved_ids: + return messages_map + + # Graph has gaps — enrich from the legacy embedded history. + log.info( + 'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history', + id, + len(unresolved_ids), + ) + chat = await self.get_chat_by_id(id) + if chat: + history_messages = chat.chat.get('history', {}).get('messages', {}) or {} + missing_messages = { + message_id: history_messages[message_id] + for message_id in unresolved_ids + if message_id in history_messages + } - def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: - chat = self.get_chat_by_id(id) + if missing_messages: + messages_map.update(missing_messages) + + # Backfill so future requests use the fast path. + await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages) + + return messages_map + + # No rows — fall back to the legacy embedded history. + chat = await self.get_chat_by_id(id) if chat is None: return None - return chat.chat.get('history', {}).get('messages', {}) or {} + history_messages = chat.chat.get('history', {}).get('messages', {}) or {} - def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]: - chat = self.get_chat_by_id(id) + # Backfill so future requests use the fast path. + if history_messages: + await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages) + + return history_messages + + async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]: + chat = await self.get_chat_by_id(id) if chat is None: return None return chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) - def upsert_message_to_chat_by_id_and_message_id( + async def upsert_message_to_chat_by_id_and_message_id( self, id: str, message_id: str, message: dict ) -> Optional[ChatModel]: - chat = self.get_chat_by_id(id) + chat = await self.get_chat_by_id(id) if chat is None: return None @@ -471,7 +577,7 @@ def upsert_message_to_chat_by_id_and_message_id( # Dual-write to chat_message table try: - ChatMessages.upsert_message( + await ChatMessages.upsert_message( message_id=message_id, chat_id=id, user_id=user_id, @@ -480,12 +586,12 @@ def upsert_message_to_chat_by_id_and_message_id( except Exception as e: log.warning(f'Failed to write to chat_message table: {e}') - return self.update_chat_by_id(id, chat) + return await self.update_chat_by_id(id, chat) - def add_message_status_to_chat_by_id_and_message_id( + async def add_message_status_to_chat_by_id_and_message_id( self, id: str, message_id: str, status: dict ) -> Optional[ChatModel]: - chat = self.get_chat_by_id(id) + chat = await self.get_chat_by_id(id) if chat is None: return None @@ -498,11 +604,11 @@ def add_message_status_to_chat_by_id_and_message_id( history['messages'][message_id]['statusHistory'] = status_history chat['history'] = history - return self.update_chat_by_id(id, chat) + return await self.update_chat_by_id(id, chat) - def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]: - with get_db_context() as db: - chat = self.get_chat_by_id(id, db=db) + async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]: + async with get_async_db_context() as db: + chat = await self.get_chat_by_id(id, db=db) if chat is None: return None @@ -517,151 +623,133 @@ def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files history['messages'][message_id]['files'] = message_files chat['history'] = history - self.update_chat_by_id(id, chat, db=db) + await self.update_chat_by_id(id, chat, db=db) return message_files - def insert_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: - with get_db_context(db) as db: - # Get the existing chat to share - chat = db.get(Chat, chat_id) - # Check if chat exists + async def insert_shared_chat_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> Optional[ChatModel]: + """Create a shared snapshot for a chat. Returns the original chat with share_id set.""" + from open_webui.models.shared_chats import SharedChats + + async with get_async_db_context(db) as db: + chat = await db.get(Chat, chat_id) if not chat: return None - # Check if the chat is already shared + + # If already shared, just update the existing snapshot if chat.share_id: - return self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db) - # Create a new chat with the same data, but with a new ID - shared_chat = ChatModel( - **{ - 'id': str(uuid.uuid4()), - 'user_id': f'shared-{chat_id}', - 'title': chat.title, - 'chat': chat.chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - 'created_at': chat.created_at, - 'updated_at': int(time.time()), - } - ) - shared_result = Chat(**shared_chat.model_dump()) - db.add(shared_result) - db.commit() - db.refresh(shared_result) + return await self.update_shared_chat_by_chat_id(chat_id, db=db) - # Update the original chat with the share_id - result = db.query(Chat).filter_by(id=chat_id).update({'share_id': shared_chat.id}) - db.commit() - return shared_chat if (shared_result and result) else None + shared = await SharedChats.create(chat_id, chat.user_id, db=db) + if not shared: + return None + + # Set share_id on the original chat + chat.share_id = shared.id + await db.commit() + await db.refresh(chat) + return ChatModel.model_validate(chat) + + async def update_shared_chat_by_chat_id( + self, chat_id: str, db: Optional[AsyncSession] = None + ) -> Optional[ChatModel]: + """Re-snapshot the shared chat with current chat data.""" + from open_webui.models.shared_chats import SharedChats - def update_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.get(Chat, chat_id) - shared_chat = db.query(Chat).filter_by(user_id=f'shared-{chat_id}').first() - - if shared_chat is None: - return self.insert_shared_chat_by_chat_id(chat_id, db=db) - - shared_chat.title = chat.title - shared_chat.chat = chat.chat - shared_chat.meta = chat.meta - shared_chat.pinned = chat.pinned - shared_chat.folder_id = chat.folder_id - shared_chat.updated_at = int(time.time()) - db.commit() - db.refresh(shared_chat) - - return ChatModel.model_validate(shared_chat) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, chat_id) + if not chat or not chat.share_id: + return await self.insert_shared_chat_by_chat_id(chat_id, db=db) + + await SharedChats.update(chat.share_id, db=db) + return ChatModel.model_validate(chat) except Exception: return None - def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> bool: - try: - with get_db_context(db) as db: - # Use subquery to delete chat_messages for shared chats - shared_chat_id_subquery = db.query(Chat.id).filter_by(user_id=f'shared-{chat_id}').scalar_subquery() - db.query(ChatMessage).filter(ChatMessage.chat_id.in_(shared_chat_id_subquery)).delete( - synchronize_session=False - ) - db.query(Chat).filter_by(user_id=f'shared-{chat_id}').delete() - db.commit() + async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete shared snapshot for a chat.""" + from open_webui.models.shared_chats import SharedChats - return True + try: + return await SharedChats.delete_by_chat_id(chat_id, db=db) except Exception: return False - def unarchive_all_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: + async def unarchive_all_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id).update({'archived': False}) - db.commit() + async with get_async_db_context(db) as db: + await db.execute(update(Chat).filter_by(user_id=user_id).values(archived=False)) + await db.commit() return True except Exception: return False - def update_chat_share_id_by_id( - self, id: str, share_id: Optional[str], db: Optional[Session] = None + async def update_chat_share_id_by_id( + self, id: str, share_id: Optional[str], db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) chat.share_id = share_id - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None - def toggle_chat_pinned_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: + async def toggle_chat_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) chat.pinned = not chat.pinned chat.updated_at = int(time.time()) - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None - def toggle_chat_archive_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: + async def toggle_chat_archive_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) chat.archived = not chat.archived chat.folder_id = None chat.updated_at = int(time.time()) - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None - def archive_all_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: + async def archive_all_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id).update({'archived': True}) - db.commit() + async with get_async_db_context(db) as db: + await db.execute(update(Chat).filter_by(user_id=user_id).values(archived=True)) + await db.commit() return True except Exception: return False - def get_archived_chat_list_by_user_id( + async def get_archived_chat_list_by_user_id( self, user_id: str, filter: Optional[dict] = None, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatTitleIdResponse]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id, archived=True) + async with get_async_db_context(db) as db: + stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by( + user_id=user_id, archived=True + ) if filter: query_key = filter.get('query') if query_key: - query = query.filter(Chat.title.ilike(f'%{query_key}%')) + stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%')) order_by = filter.get('order_by') direction = filter.get('direction') @@ -671,22 +759,21 @@ def get_archived_chat_list_by_user_id( raise ValueError('Invalid order_by field') if direction.lower() == 'asc': - query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == 'desc': - query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) else: raise ValueError('Invalid direction for ordering') else: - query = query.order_by(Chat.updated_at.desc(), Chat.id) - - query = query.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - all_chats = query.all() + result = await db.execute(stmt) + all_chats = result.all() return [ ChatTitleIdResponse.model_validate( { @@ -699,108 +786,74 @@ def get_archived_chat_list_by_user_id( for chat in all_chats ] - def get_shared_chat_list_by_user_id( + async def get_shared_chat_list_by_user_id( self, user_id: str, filter: Optional[dict] = None, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[SharedChatResponse]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id).filter(Chat.share_id.isnot(None)) + """Delegate to SharedChats for listing shared chats by user.""" + from open_webui.models.shared_chats import SharedChats - if filter: - query_key = filter.get('query') - if query_key: - query = query.filter(Chat.title.ilike(f'%{query_key}%')) + return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db) - order_by = filter.get('order_by') - direction = filter.get('direction') - - if order_by and direction: - if not getattr(Chat, order_by, None): - raise ValueError('Invalid order_by field') - - if direction.lower() == 'asc': - query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == 'desc': - query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) - else: - raise ValueError('Invalid direction for ordering') - else: - query = query.order_by(Chat.updated_at.desc(), Chat.id) - - # Select only the columns needed for SharedChatResponse - # to avoid loading the heavy chat JSON blob - query = query.with_entities( - Chat.id, - Chat.title, - Chat.share_id, - Chat.updated_at, - Chat.created_at, - ) - - if skip: - query = query.offset(skip) - if limit: - query = query.limit(limit) - - all_chats = query.all() - return [ - SharedChatResponse.model_validate( - { - 'id': chat[0], - 'title': chat[1], - 'share_id': chat[2], - 'updated_at': chat[3], - 'created_at': chat[4], - } - ) - for chat in all_chats - ] - - def get_chat_list_by_user_id( + async def get_chat_list_by_user_id( self, user_id: str, include_archived: bool = False, filter: Optional[dict] = None, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, - ) -> list[ChatModel]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id) + db: Optional[AsyncSession] = None, + ) -> list[ChatTitleIdResponse]: + async with get_async_db_context(db) as db: + stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( + user_id=user_id + ) if not include_archived: - query = query.filter_by(archived=False) + stmt = stmt.filter_by(archived=False) if filter: query_key = filter.get('query') if query_key: - query = query.filter(Chat.title.ilike(f'%{query_key}%')) + stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%')) order_by = filter.get('order_by') direction = filter.get('direction') if order_by and direction and getattr(Chat, order_by): if direction.lower() == 'asc': - query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == 'desc': - query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) else: raise ValueError('Invalid direction for ordering') else: - query = query.order_by(Chat.updated_at.desc(), Chat.id) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + result = await db.execute(stmt) + all_chats = result.all() + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] - def get_chat_title_id_list_by_user_id( + async def get_chat_title_id_list_by_user_id( self, user_id: str, include_archived: bool = False, @@ -808,32 +861,32 @@ def get_chat_title_id_list_by_user_id( include_pinned: bool = False, skip: Optional[int] = None, limit: Optional[int] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatTitleIdResponse]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id) + async with get_async_db_context(db) as db: + stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( + user_id=user_id + ) if not include_folders: - query = query.filter_by(folder_id=None) + stmt = stmt.filter_by(folder_id=None) if not include_pinned: - query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) + stmt = stmt.filter(or_(Chat.pinned == False, Chat.pinned == None)) if not include_archived: - query = query.filter_by(archived=False) + stmt = stmt.filter_by(archived=False) - query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities( - Chat.id, Chat.title, Chat.updated_at, Chat.created_at - ) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - all_chats = query.all() + result = await db.execute(stmt) + all_chats = result.all() - # result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. return [ ChatTitleIdResponse.model_validate( { @@ -841,111 +894,118 @@ def get_chat_title_id_list_by_user_id( 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats ] - def get_chat_list_by_chat_ids( + async def get_chat_list_by_chat_ids( self, chat_ids: list[str], skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatModel]: - with get_db_context(db) as db: - all_chats = ( - db.query(Chat) - .filter(Chat.id.in_(chat_ids)) - .filter_by(archived=False) - .order_by(Chat.updated_at.desc()) - .all() + async with get_async_db_context(db) as db: + result = await db.execute( + select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc()) ) + all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chat_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: + async def get_chat_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat_item = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat_item = await db.get(Chat, id) if chat_item is None: return None if self._sanitize_chat_row(chat_item): - db.commit() - db.refresh(chat_item) + await db.commit() + await db.refresh(chat_item) return ChatModel.model_validate(chat_item) except Exception: return None - def get_chat_by_share_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: - try: - with get_db_context(db) as db: - # it is possible that the shared link was deleted. hence, - # we check if the chat is still shared by checking if a chat with the share_id exists - chat = db.query(Chat).filter_by(share_id=id).first() + async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]: + """Look up a shared chat snapshot by its share token.""" + from open_webui.models.shared_chats import SharedChats - if chat: - return self.get_chat_by_id(id, db=db) - else: - return None + try: + shared = await SharedChats.get_by_id(id, db=db) + if shared: + # Return a ChatModel-compatible view of the snapshot + return ChatModel( + id=shared.id, + user_id=shared.user_id, + title=shared.title, + chat=shared.chat, + created_at=shared.created_at, + updated_at=shared.updated_at, + share_id=shared.id, + ) + return None except Exception: return None - def get_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: + async def get_chat_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.query(Chat).filter_by(id=id, user_id=user_id).first() - return ChatModel.model_validate(chat) + async with get_async_db_context(db) as db: + result = await db.execute(select(Chat).filter_by(id=id, user_id=user_id)) + chat = result.scalars().first() + return ChatModel.model_validate(chat) if chat else None except Exception: return None - def is_chat_owner(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: + async def is_chat_owner(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: """ Lightweight ownership check — uses EXISTS subquery instead of loading the full Chat row (which includes the potentially large JSON blob). """ try: - with get_db_context(db) as db: - return db.query(exists().where(and_(Chat.id == id, Chat.user_id == user_id))).scalar() + async with get_async_db_context(db) as db: + result = await db.execute(select(exists().where(and_(Chat.id == id, Chat.user_id == user_id)))) + return result.scalar() except Exception: return False - def get_chat_folder_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[str]: + async def get_chat_folder_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[str]: """ Fetch only the folder_id column for a chat, without loading the full JSON blob. Returns None if chat doesn't exist or doesn't belong to user. """ try: - with get_db_context(db) as db: - result = db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first() - return result[0] if result else None + async with get_async_db_context(db) as db: + result = await db.execute(select(Chat.folder_id).filter_by(id=id, user_id=user_id)) + row = result.first() + return row[0] if row else None except Exception: return None - def get_chats(self, skip: int = 0, limit: int = 50, db: Optional[Session] = None) -> list[ChatModel]: - with get_db_context(db) as db: - all_chats = ( - db.query(Chat) - # .limit(limit).offset(skip) - .order_by(Chat.updated_at.desc()) - ) + async def get_chats(self, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None) -> list[ChatModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Chat).order_by(Chat.updated_at.desc())) + all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chats_by_user_id( + async def get_chats_by_user_id( self, user_id: str, filter: Optional[dict] = None, skip: Optional[int] = None, limit: Optional[int] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> ChatListResponse: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id) + async with get_async_db_context(db) as db: + stmt = select(Chat).filter_by(user_id=user_id) if filter: if filter.get('updated_at'): - query = query.filter(Chat.updated_at > filter.get('updated_at')) + stmt = stmt.filter(Chat.updated_at > filter.get('updated_at')) order_by = filter.get('order_by') direction = filter.get('direction') @@ -953,23 +1013,25 @@ def get_chats_by_user_id( if order_by and direction: if hasattr(Chat, order_by): if direction.lower() == 'asc': - query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id) elif direction.lower() == 'desc': - query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) + stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id) else: - query = query.order_by(Chat.updated_at.desc(), Chat.id) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) else: - query = query.order_by(Chat.updated_at.desc(), Chat.id) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip is not None: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit is not None: - query = query.limit(limit) + stmt = stmt.limit(limit) - all_chats = query.all() + result = await db.execute(stmt) + all_chats = result.scalars().all() return ChatListResponse( **{ @@ -978,14 +1040,16 @@ def get_chats_by_user_id( } ) - def get_pinned_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChatTitleIdResponse]: - with get_db_context(db) as db: - all_chats = ( - db.query(Chat) + async def get_pinned_chats_by_user_id( + self, user_id: str, db: Optional[AsyncSession] = None + ) -> list[ChatTitleIdResponse]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at) .filter_by(user_id=user_id, pinned=True, archived=False) .order_by(Chat.updated_at.desc()) - .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) ) + all_chats = result.all() return [ ChatTitleIdResponse.model_validate( { @@ -993,24 +1057,27 @@ def get_pinned_chats_by_user_id(self, user_id: str, db: Optional[Session] = None 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats ] - def get_archived_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChatModel]: - with get_db_context(db) as db: - all_chats = db.query(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc()) - return [ChatModel.model_validate(chat) for chat in all_chats] + async def get_archived_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc()) + ) + return [ChatModel.model_validate(chat) for chat in result.scalars().all()] - def get_chats_by_user_id_and_search_text( + async def get_chats_by_user_id_and_search_text( self, user_id: str, search_text: str, include_archived: bool = False, skip: int = 0, limit: int = 60, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ChatModel]: """ Filters chats based on a search query using Python, allowing pagination using skip and limit. @@ -1018,17 +1085,19 @@ def get_chats_by_user_id_and_search_text( search_text = sanitize_text_for_db(search_text).lower().strip() if not search_text: - return self.get_chat_list_by_user_id(user_id, include_archived, filter={}, skip=skip, limit=limit, db=db) + return await self.get_chat_list_by_user_id( + user_id, include_archived, filter={}, skip=skip, limit=limit, db=db + ) search_text_words = search_text.split(' ') - # search_text might contain 'tag:tag_name' format so we need to extract the tag_name, split the search_text and remove the tags + # search_text might contain 'tag:tag_name' format so we need to extract the tag_name tag_ids = [ word.replace('tag:', '').replace(' ', '_').lower() for word in search_text_words if word.startswith('tag:') ] - # Extract folder names - handle spaces and case insensitivity - folders = Folders.search_folders_by_names( + # Extract folder names + folders = await Folders.search_folders_by_names( user_id, [word.replace('folder:', '') for word in search_text_words if word.startswith('folder:')], ) @@ -1066,30 +1135,31 @@ def get_chats_by_user_id_and_search_text( search_text = ' '.join(search_text_words) - with get_db_context(db) as db: - query = db.query(Chat).filter(Chat.user_id == user_id) + async with get_async_db_context(db) as db: + stmt = select(Chat).filter(Chat.user_id == user_id) if is_archived is not None: - query = query.filter(Chat.archived == is_archived) + stmt = stmt.filter(Chat.archived == is_archived) elif not include_archived: - query = query.filter(Chat.archived == False) + stmt = stmt.filter(Chat.archived == False) if is_pinned is not None: - query = query.filter(Chat.pinned == is_pinned) + stmt = stmt.filter(Chat.pinned == is_pinned) if is_shared is not None: if is_shared: - query = query.filter(Chat.share_id.isnot(None)) + stmt = stmt.filter(Chat.share_id.isnot(None)) else: - query = query.filter(Chat.share_id.is_(None)) + stmt = stmt.filter(Chat.share_id.is_(None)) if folder_ids: - query = query.filter(Chat.folder_id.in_(folder_ids)) + stmt = stmt.filter(Chat.folder_id.in_(folder_ids)) - query = query.order_by(Chat.updated_at.desc(), Chat.id) + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) # Check if the database dialect is either 'sqlite' or 'postgresql' - dialect_name = db.bind.dialect.name + bind = await db.connection() + dialect_name = bind.dialect.name if dialect_name == 'sqlite': # SQLite case: using JSON1 extension for JSON searching sqlite_content_sql = ( @@ -1100,15 +1170,15 @@ def get_chats_by_user_id_and_search_text( ')' ) sqlite_content_clause = text(sqlite_content_sql) - query = query.filter( + stmt = stmt.filter( or_(Chat.title.ilike(bindparam('title_key')), sqlite_content_clause).params( title_key=f'%{search_text}%', content_key=search_text ) ) - # Check if there are any tags to filter, it should have all the tags + # Check if there are any tags to filter if 'none' in tag_ids: - query = query.filter( + stmt = stmt.filter( text(""" NOT EXISTS ( SELECT 1 @@ -1117,7 +1187,7 @@ def get_chats_by_user_id_and_search_text( """) ) elif tag_ids: - query = query.filter( + stmt = stmt.filter( and_( *[ text(f""" @@ -1133,14 +1203,11 @@ def get_chats_by_user_id_and_search_text( ) elif dialect_name == 'postgresql': - # PostgreSQL doesn't allow null bytes in text. We filter those out by checking - # the JSON representation for \u0000 before attempting text extraction - # Safety filter: JSON field must not contain \u0000 - query = query.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'")) + stmt = stmt.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'")) # Safety filter: title must not contain actual null bytes - query = query.filter(text("Chat.title::text NOT LIKE '%\\x00%'")) + stmt = stmt.filter(text("Chat.title::text NOT LIKE '%\\x00%'")) postgres_content_sql = """ EXISTS ( @@ -1153,16 +1220,15 @@ def get_chats_by_user_id_and_search_text( postgres_content_clause = text(postgres_content_sql) - query = query.filter( + stmt = stmt.filter( or_( Chat.title.ilike(bindparam('title_key')), postgres_content_clause, ) ).params(title_key=f'%{search_text}%', content_key=search_text.lower()) - # Check if there are any tags to filter, it should have all the tags if 'none' in tag_ids: - query = query.filter( + stmt = stmt.filter( text(""" NOT EXISTS ( SELECT 1 @@ -1171,7 +1237,7 @@ def get_chats_by_user_id_and_search_text( """) ) elif tag_ids: - query = query.filter( + stmt = stmt.filter( and_( *[ text(f""" @@ -1186,146 +1252,194 @@ def get_chats_by_user_id_and_search_text( ) ) else: - raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') + raise NotImplementedError(f'Unsupported dialect: {dialect_name}') # Perform pagination at the SQL level - all_chats = query.offset(skip).limit(limit).all() + stmt = stmt.offset(skip).limit(limit) + result = await db.execute(stmt) + all_chats = result.scalars().all() log.info(f'The number of chats: {len(all_chats)}') # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chats_by_folder_id_and_user_id( + async def get_chats_by_folder_id_and_user_id( self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60, - db: Optional[Session] = None, - ) -> list[ChatModel]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id) - query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) - query = query.filter_by(archived=False) - - query = query.order_by(Chat.updated_at.desc(), Chat.id) + db: Optional[AsyncSession] = None, + ) -> list[ChatTitleIdResponse]: + async with get_async_db_context(db) as db: + stmt = ( + select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at) + .filter_by(folder_id=folder_id, user_id=user_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .order_by(Chat.updated_at.desc(), Chat.id) + ) if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - all_chats = query.all() - return [ChatModel.model_validate(chat) for chat in all_chats] + result = await db.execute(stmt) + all_chats = result.all() + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] - def get_chats_by_folder_ids_and_user_id( - self, folder_ids: list[str], user_id: str, db: Optional[Session] = None + async def get_chats_by_folder_ids_and_user_id( + self, folder_ids: list[str], user_id: str, db: Optional[AsyncSession] = None ) -> list[ChatModel]: - with get_db_context(db) as db: - query = db.query(Chat).filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id) - query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) - query = query.filter_by(archived=False) - - query = query.order_by(Chat.updated_at.desc()) + async with get_async_db_context(db) as db: + stmt = ( + select(Chat) + .filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .order_by(Chat.updated_at.desc()) + ) - all_chats = query.all() + result = await db.execute(stmt) + all_chats = result.scalars().all() return [ChatModel.model_validate(chat) for chat in all_chats] - def update_chat_folder_id_by_id_and_user_id( - self, id: str, user_id: str, folder_id: str, db: Optional[Session] = None + async def update_chat_folder_id_by_id_and_user_id( + self, id: str, user_id: str, folder_id: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) chat.folder_id = folder_id chat.updated_at = int(time.time()) chat.pinned = False - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None - def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> list[TagModel]: - with get_db_context(db) as db: - chat = db.get(Chat, id) - tag_ids = chat.meta.get('tags', []) - return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db) - - def get_chat_list_by_user_id_and_tag_name( + async def get_chat_tags_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> list[TagModel]: + async with get_async_db_context(db) as db: + stmt = select(Chat.meta).where(Chat.id == id) + result = await db.execute(stmt) + meta = result.scalar_one_or_none() + tag_ids = (meta or {}).get('tags', []) + return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db) + + async def get_chat_list_by_user_id_and_tag_name( self, user_id: str, tag_name: str, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, - ) -> list[ChatModel]: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id) + db: Optional[AsyncSession] = None, + ) -> list[ChatTitleIdResponse]: + async with get_async_db_context(db) as db: + stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by( + user_id=user_id + ) tag_id = tag_name.replace(' ', '_').lower() - log.info(f'DB dialect name: {db.bind.dialect.name}') - if db.bind.dialect.name == 'sqlite': - # SQLite JSON1 querying for tags within the meta JSON field - query = query.filter( + bind = await db.connection() + dialect_name = bind.dialect.name + log.info(f'DB dialect name: {dialect_name}') + if dialect_name == 'sqlite': + stmt = stmt.filter( text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") ).params(tag_id=tag_id) - elif db.bind.dialect.name == 'postgresql': - # PostgreSQL JSON query for tags within the meta JSON field (for `json` type) - query = query.filter( + elif dialect_name == 'postgresql': + stmt = stmt.filter( text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)") ).params(tag_id=tag_id) else: - raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') + raise NotImplementedError(f'Unsupported dialect: {dialect_name}') - all_chats = query.all() - log.debug(f'all_chats: {all_chats}') - return [ChatModel.model_validate(chat) for chat in all_chats] + stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) - def add_chat_tag_by_id_and_user_id_and_tag_name( - self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None + result = await db.execute(stmt) + all_chats = result.all() + return [ + ChatTitleIdResponse.model_validate( + { + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], + 'last_read_at': chat[4], + } + ) + for chat in all_chats + ] + + async def add_chat_tag_by_id_and_user_id_and_tag_name( + self, id: str, user_id: str, tag_name: str, db: Optional[AsyncSession] = None ) -> Optional[ChatModel]: tag_id = tag_name.replace(' ', '_').lower() - Tags.ensure_tags_exist([tag_name], user_id, db=db) + await Tags.ensure_tags_exist([tag_name], user_id, db=db) try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) if tag_id not in chat.meta.get('tags', []): chat.meta = { **chat.meta, 'tags': list(set(chat.meta.get('tags', []) + [tag_id])), } - db.commit() - db.refresh(chat) + await db.commit() + await db.refresh(chat) return ChatModel.model_validate(chat) except Exception: return None - def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str, db: Optional[Session] = None) -> int: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id, archived=False) + async def count_chats_by_tag_name_and_user_id( + self, tag_name: str, user_id: str, db: Optional[AsyncSession] = None + ) -> int: + async with get_async_db_context(db) as db: + stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False) tag_id = tag_name.replace(' ', '_').lower() - if db.bind.dialect.name == 'sqlite': - query = query.filter( + bind = await db.connection() + dialect_name = bind.dialect.name + if dialect_name == 'sqlite': + stmt = stmt.filter( text("EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") ).params(tag_id=tag_id) - elif db.bind.dialect.name == 'postgresql': - query = query.filter( + elif dialect_name == 'postgresql': + stmt = stmt.filter( text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)") ).params(tag_id=tag_id) else: - raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') + raise NotImplementedError(f'Unsupported dialect: {dialect_name}') - return query.count() + result = await db.execute(stmt) + return result.scalar() - def delete_orphan_tags_for_user( + async def delete_orphan_tags_for_user( self, tag_ids: list[str], user_id: str, threshold: int = 0, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> None: """Delete tag rows from *tag_ids* that appear in at most *threshold* non-archived chats for *user_id*. One query to find orphans, one to @@ -1337,30 +1451,30 @@ def delete_orphan_tags_for_user( """ if not tag_ids: return - with get_db_context(db) as db: + async with get_async_db_context(db) as db: orphans = [] for tag_id in tag_ids: - count = self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db) + count = await self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db) if count <= threshold: orphans.append(tag_id) - Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db) + await Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db) - def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str, db: Optional[Session] = None) -> int: - with get_db_context(db) as db: - query = db.query(Chat).filter_by(user_id=user_id) - - query = query.filter_by(folder_id=folder_id) - count = query.count() + async def count_chats_by_folder_id_and_user_id( + self, folder_id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> int: + async with get_async_db_context(db) as db: + result = await db.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)) + count = result.scalar() log.info(f"Count of chats for folder '{folder_id}': {count}") return count - def delete_tag_by_id_and_user_id_and_tag_name( - self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None + async def delete_tag_by_id_and_user_id_and_tag_name( + self, id: str, user_id: str, tag_name: str, db: Optional[AsyncSession] = None ) -> bool: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) tags = chat.meta.get('tags', []) tag_id = tag_name.replace(' ', '_').lower() @@ -1369,130 +1483,143 @@ def delete_tag_by_id_and_user_id_and_tag_name( **chat.meta, 'tags': list(set(tags)), } - db.commit() + await db.commit() return True except Exception: return False - def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - chat = db.get(Chat, id) + async with get_async_db_context(db) as db: + chat = await db.get(Chat, id) chat.meta = { **chat.meta, 'tags': [], } - db.commit() + await db.commit() return True except Exception: return False - def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_chat_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(ChatMessage).filter_by(chat_id=id).delete() - db.query(Chat).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None)) + await db.execute(delete(ChatMessage).filter_by(chat_id=id)) + await db.execute(delete(Chat).filter_by(id=id)) + await db.commit() - return True and self.delete_shared_chat_by_chat_id(id, db=db) + return True and await self.delete_shared_chat_by_chat_id(id, db=db) except Exception: return False - def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(ChatMessage).filter_by(chat_id=id).delete() - db.query(Chat).filter_by(id=id, user_id=user_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None)) + await db.execute(delete(ChatMessage).filter_by(chat_id=id)) + await db.execute(delete(Chat).filter_by(id=id, user_id=user_id)) + await db.commit() - return True and self.delete_shared_chat_by_chat_id(id, db=db) + return True and await self.delete_shared_chat_by_chat_id(id, db=db) except Exception: return False - def delete_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - self.delete_shared_chats_by_user_id(user_id, db=db) - - chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id).subquery() - db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete( - synchronize_session=False + async with get_async_db_context(db) as db: + await self.delete_shared_chats_by_user_id(user_id, db=db) + + chat_id_subquery = select(Chat.id).filter_by(user_id=user_id).scalar_subquery() + await db.execute( + update(AutomationRun) + .filter(AutomationRun.chat_id.in_(select(Chat.id).filter_by(user_id=user_id))) + .values(chat_id=None) + ) + await db.execute( + delete(ChatMessage).filter(ChatMessage.chat_id.in_(select(Chat.id).filter_by(user_id=user_id))) ) - db.query(Chat).filter_by(user_id=user_id).delete() - db.commit() + await db.execute(delete(Chat).filter_by(user_id=user_id)) + await db.commit() return True except Exception: return False - def delete_chats_by_user_id_and_folder_id(self, user_id: str, folder_id: str, db: Optional[Session] = None) -> bool: + async def delete_chats_by_user_id_and_folder_id( + self, user_id: str, folder_id: str, db: Optional[AsyncSession] = None + ) -> bool: try: - with get_db_context(db) as db: - chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id, folder_id=folder_id).subquery() - db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete( - synchronize_session=False + async with get_async_db_context(db) as db: + chat_ids_stmt = select(Chat.id).filter_by(user_id=user_id, folder_id=folder_id) + await db.execute( + update(AutomationRun).filter(AutomationRun.chat_id.in_(chat_ids_stmt)).values(chat_id=None) ) - db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete() - db.commit() + await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(chat_ids_stmt))) + await db.execute(delete(Chat).filter_by(user_id=user_id, folder_id=folder_id)) + await db.commit() return True except Exception: return False - def move_chats_by_user_id_and_folder_id( + async def move_chats_by_user_id_and_folder_id( self, user_id: str, folder_id: str, new_folder_id: Optional[str], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: try: - with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update({'folder_id': new_folder_id}) - db.commit() + async with get_async_db_context(db) as db: + await db.execute( + update(Chat).filter_by(user_id=user_id, folder_id=folder_id).values(folder_id=new_folder_id) + ) + await db.commit() return True except Exception: return False - def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chat snapshots created by a user.""" + from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable + try: - with get_db_context(db) as db: - chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() - shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user] + async with get_async_db_context(db) as db: + # Delete shared_chat rows for this user's chats + await db.execute(delete(SharedChatTable).filter_by(user_id=user_id)) - # Use subquery to delete chat_messages for shared chats - shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery() - db.query(ChatMessage).filter(ChatMessage.chat_id.in_(shared_id_subq)).delete(synchronize_session=False) - db.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete() - db.commit() + # Clear share_id on all of this user's chats + await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None)) + await db.commit() return True except Exception: return False - def insert_chat_files( + async def insert_chat_files( self, chat_id: str, message_id: str, file_ids: list[str], user_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[list[ChatFileModel]]: if not file_ids: return None - chat_message_file_ids = [ - item.id for item in self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db) - ] + chat_message_file_ids = { + item.id for item in await self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db) + } # Remove duplicates and existing file_ids - file_ids = list(set([file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids])) + file_ids = list({file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids}) if not file_ids: return None try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: now = int(time.time()) chat_files = [ @@ -1511,44 +1638,63 @@ def insert_chat_files( results = [ChatFile(**chat_file.model_dump()) for chat_file in chat_files] db.add_all(results) - db.commit() + await db.commit() return chat_files except Exception: return None - def get_chat_files_by_chat_id_and_message_id( - self, chat_id: str, message_id: str, db: Optional[Session] = None + async def get_chat_files_by_chat_id_and_message_id( + self, chat_id: str, message_id: str, db: Optional[AsyncSession] = None ) -> list[ChatFileModel]: - with get_db_context(db) as db: - all_chat_files = ( - db.query(ChatFile) - .filter_by(chat_id=chat_id, message_id=message_id) - .order_by(ChatFile.created_at.asc()) - .all() + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChatFile).filter_by(chat_id=chat_id, message_id=message_id).order_by(ChatFile.created_at.asc()) ) + all_chat_files = result.scalars().all() return [ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files] - def delete_chat_file(self, chat_id: str, file_id: str, db: Optional[Session] = None) -> bool: + async def delete_chat_file(self, chat_id: str, file_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(ChatFile).filter_by(chat_id=chat_id, file_id=file_id)) + await db.commit() return True except Exception: return False - def get_shared_chats_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[ChatModel]: - with get_db_context(db) as db: - # Join Chat and ChatFile tables to get shared chats associated with the file_id - all_chats = ( - db.query(Chat) + async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]: + """Return IDs of chats that contain this file and have an active share link.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Chat.id) .join(ChatFile, Chat.id == ChatFile.chat_id) .filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None)) - .all() ) + return [row[0] for row in result.all()] - return [ChatModel.model_validate(chat) for chat in all_chats] + async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: + """Update the tasks list on a chat.""" + try: + async with get_async_db_context() as db: + chat = await db.get(Chat, id) + if chat is None: + return None + chat.tasks = tasks + await db.commit() + await db.refresh(chat) + return ChatModel.model_validate(chat) + except Exception: + return None + + async def get_chat_tasks_by_id(self, id: str) -> list[dict]: + """Read the tasks list from a chat (lightweight column query).""" + async with get_async_db_context() as db: + result = await db.execute(select(Chat.tasks).filter_by(id=id)) + row = result.first() + if row is None or row[0] is None: + return [] + return row[0] Chats = ChatTable() diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index aa6c7bdcae9..d8ae4dc9b1a 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -3,9 +3,10 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context -from open_webui.models.users import User +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context +from open_webui.models.users import User, UserModel from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, JSON, Boolean @@ -102,7 +103,8 @@ class FeedbackForm(BaseModel): data: Optional[RatingData] = None meta: Optional[dict] = None snapshot: Optional[SnapshotData] = None - model_config = ConfigDict(extra='allow') + # ignore: drop client-supplied id/user_id/version/timestamps at parse time. + model_config = ConfigDict(extra='ignore') class UserResponse(BaseModel): @@ -139,17 +141,18 @@ class ModelHistoryResponse(BaseModel): class FeedbackTable: - def insert_new_feedback( - self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None + async def insert_new_feedback( + self, user_id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None ) -> Optional[FeedbackModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: id = str(uuid.uuid4()) + # Spread form_data first so server-controlled fields win on duplicate keys. feedback = FeedbackModel( **{ + **form_data.model_dump(), 'id': id, 'user_id': user_id, 'version': 0, - **form_data.model_dump(), 'created_at': int(time.time()), 'updated_at': int(time.time()), } @@ -157,8 +160,8 @@ def insert_new_feedback( try: result = Feedback(**feedback.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return FeedbackModel.model_validate(result) else: @@ -167,92 +170,99 @@ def insert_new_feedback( log.exception(f'Error creating a new feedback: {e}') return None - def get_feedback_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FeedbackModel]: + async def get_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FeedbackModel]: try: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id)) + feedback = result.scalars().first() if not feedback: return None return FeedbackModel.model_validate(feedback) except Exception: return None - def get_feedback_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None + async def get_feedback_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[FeedbackModel]: try: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id)) + feedback = result.scalars().first() if not feedback: return None return FeedbackModel.model_validate(feedback) except Exception: return None - def get_feedbacks_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> list[FeedbackModel]: + async def get_feedbacks_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]: """Get all feedbacks for a specific chat.""" try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # meta.chat_id stores the chat reference - feedbacks = ( - db.query(Feedback) + result = await db.execute( + select(Feedback) .filter(Feedback.meta['chat_id'].as_string() == chat_id) .order_by(Feedback.created_at.desc()) - .all() ) + feedbacks = result.scalars().all() return [FeedbackModel.model_validate(fb) for fb in feedbacks] except Exception: return [] - def get_feedback_items( + async def get_feedback_items( self, filter: dict = {}, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> FeedbackListResponse: - with get_db_context(db) as db: - query = db.query(Feedback, User).join(User, Feedback.user_id == User.id) + async with get_async_db_context(db) as db: + stmt = select(Feedback, User).join(User, Feedback.user_id == User.id) if filter: + # Apply model_id filter (exact match) + model_id = filter.get('model_id') + if model_id: + stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id) + order_by = filter.get('order_by') direction = filter.get('direction') if order_by == 'username': if direction == 'asc': - query = query.order_by(User.name.asc()) + stmt = stmt.order_by(User.name.asc()) else: - query = query.order_by(User.name.desc()) + stmt = stmt.order_by(User.name.desc()) elif order_by == 'model_id': - # it's stored in feedback.data['model_id'] if direction == 'asc': - query = query.order_by(Feedback.data['model_id'].as_string().asc()) + stmt = stmt.order_by(Feedback.data['model_id'].as_string().asc()) else: - query = query.order_by(Feedback.data['model_id'].as_string().desc()) + stmt = stmt.order_by(Feedback.data['model_id'].as_string().desc()) elif order_by == 'rating': - # it's stored in feedback.data['rating'] if direction == 'asc': - query = query.order_by(Feedback.data['rating'].as_string().asc()) + stmt = stmt.order_by(Feedback.data['rating'].as_string().asc()) else: - query = query.order_by(Feedback.data['rating'].as_string().desc()) + stmt = stmt.order_by(Feedback.data['rating'].as_string().desc()) elif order_by == 'updated_at': if direction == 'asc': - query = query.order_by(Feedback.updated_at.asc()) + stmt = stmt.order_by(Feedback.updated_at.asc()) else: - query = query.order_by(Feedback.updated_at.desc()) + stmt = stmt.order_by(Feedback.updated_at.desc()) else: - query = query.order_by(Feedback.created_at.desc()) + stmt = stmt.order_by(Feedback.created_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() feedbacks = [] for feedback, user in items: @@ -262,15 +272,18 @@ def get_feedback_items( return FeedbackListResponse(items=feedbacks, total=total) - def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel]: - with get_db_context(db) as db: - return [ - FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback).order_by(Feedback.updated_at.desc()).all() - ] + async def get_all_feedbacks(self, db: Optional[AsyncSession] = None) -> list[FeedbackModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).order_by(Feedback.updated_at.desc())) + return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()] - def get_all_feedback_ids(self, db: Optional[Session] = None) -> list[FeedbackIdResponse]: - with get_db_context(db) as db: + async def get_all_feedback_ids(self, db: Optional[AsyncSession] = None) -> list[FeedbackIdResponse]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Feedback.id, Feedback.user_id, Feedback.created_at, Feedback.updated_at).order_by( + Feedback.updated_at.desc() + ) + ) return [ FeedbackIdResponse( id=row.id, @@ -278,25 +291,28 @@ def get_all_feedback_ids(self, db: Optional[Session] = None) -> list[FeedbackIdR created_at=row.created_at, updated_at=row.updated_at, ) - for row in db.query( - Feedback.id, - Feedback.user_id, - Feedback.created_at, - Feedback.updated_at, - ) - .order_by(Feedback.updated_at.desc()) - .all() + for row in result.all() ] - def get_feedbacks_for_leaderboard(self, db: Optional[Session] = None) -> list[LeaderboardFeedbackData]: + async def get_distinct_model_ids(self, db: Optional[AsyncSession] = None) -> list[str]: + """Get distinct model_ids from feedback data for filter dropdowns.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(Feedback.data['model_id'].as_string()) + .filter(Feedback.data['model_id'].as_string().isnot(None)) + .distinct() + ) + rows = result.all() + return sorted([row[0] for row in rows if row[0]]) + + async def get_feedbacks_for_leaderboard(self, db: Optional[AsyncSession] = None) -> list[LeaderboardFeedbackData]: """Fetch only id and data for leaderboard computation (excludes snapshot/meta).""" - with get_db_context(db) as db: - return [ - LeaderboardFeedbackData(id=row.id, data=row.data) for row in db.query(Feedback.id, Feedback.data).all() - ] + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback.id, Feedback.data)) + return [LeaderboardFeedbackData(id=row.id, data=row.data) for row in result.all()] - def get_model_evaluation_history( - self, model_id: str, days: int = 30, db: Optional[Session] = None + async def get_model_evaluation_history( + self, model_id: str, days: int = 30, db: Optional[AsyncSession] = None ) -> list[ModelHistoryEntry]: """ Get daily wins/losses for a specific model over the past N days. @@ -306,13 +322,16 @@ def get_model_evaluation_history( from datetime import datetime, timedelta from collections import defaultdict - with get_db_context(db) as db: + async with get_async_db_context(db) as db: if days == 0: # All time - no cutoff - rows = db.query(Feedback.created_at, Feedback.data).all() + result = await db.execute(select(Feedback.created_at, Feedback.data)) else: cutoff = int(time.time()) - (days * 86400) - rows = db.query(Feedback.created_at, Feedback.data).filter(Feedback.created_at >= cutoff).all() + result = await db.execute( + select(Feedback.created_at, Feedback.data).filter(Feedback.created_at >= cutoff) + ) + rows = result.all() daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0}) first_date = None @@ -358,25 +377,22 @@ def get_model_evaluation_history( return result - def get_feedbacks_by_type(self, type: str, db: Optional[Session] = None) -> list[FeedbackModel]: - with get_db_context(db) as db: - return [ - FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()).all() - ] + async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc())) + return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()] - def get_feedbacks_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FeedbackModel]: - with get_db_context(db) as db: - return [ - FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc()).all() - ] + async def get_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc())) + return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()] - def update_feedback_by_id( - self, id: str, form_data: FeedbackForm, db: Optional[Session] = None + async def update_feedback_by_id( + self, id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None ) -> Optional[FeedbackModel]: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id)) + feedback = result.scalars().first() if not feedback: return None @@ -389,18 +405,19 @@ def update_feedback_by_id( feedback.updated_at = int(time.time()) - db.commit() + await db.commit() return FeedbackModel.model_validate(feedback) - def update_feedback_by_id_and_user_id( + async def update_feedback_by_id_and_user_id( self, id: str, user_id: str, form_data: FeedbackForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FeedbackModel]: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id)) + feedback = result.scalars().first() if not feedback: return None @@ -413,38 +430,40 @@ def update_feedback_by_id_and_user_id( feedback.updated_at = int(time.time()) - db.commit() + await db.commit() return FeedbackModel.model_validate(feedback) - def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id).first() + async def delete_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id)) + feedback = result.scalars().first() if not feedback: return False - db.delete(feedback) - db.commit() + await db.delete(feedback) + await db.commit() return True - def delete_feedback_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() + async def delete_feedback_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id)) + feedback = result.scalars().first() if not feedback: return False - db.delete(feedback) - db.commit() + await db.delete(feedback) + await db.commit() return True - def delete_feedbacks_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - result = db.query(Feedback).filter_by(user_id=user_id).delete() - db.commit() - return result > 0 - - def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - result = db.query(Feedback).delete() - db.commit() - return result > 0 + async def delete_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(delete(Feedback).filter_by(user_id=user_id)) + await db.commit() + return result.rowcount > 0 + + async def delete_all_feedbacks(self, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(delete(Feedback)) + await db.commit() + return result.rowcount > 0 Feedbacks = FeedbackTable() diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index c02752f1301..cfdcfbc2d9d 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -2,8 +2,9 @@ import time from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.utils.misc import sanitize_metadata from pydantic import BaseModel, ConfigDict, model_validator from sqlalchemy import BigInteger, Column, String, Text, JSON @@ -12,6 +13,8 @@ #################### # Files DB Schema +# What is written here bears witness. Let the testimony +# remain as it was given, and let none tamper with it. #################### @@ -85,7 +88,7 @@ class FileModelResponse(BaseModel): filename: str data: Optional[dict] = None - meta: FileMeta + meta: Optional[FileMeta] = None created_at: int # timestamp in epoch updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files @@ -122,8 +125,10 @@ class FileUpdateForm(BaseModel): class FilesTable: - def insert_new_file(self, user_id: str, form_data: FileForm, db: Optional[Session] = None) -> Optional[FileModel]: - with get_db_context(db) as db: + async def insert_new_file( + self, user_id: str, form_data: FileForm, db: Optional[AsyncSession] = None + ) -> Optional[FileModel]: + async with get_async_db_context(db) as db: file_data = form_data.model_dump() # Sanitize meta to remove non-JSON-serializable objects @@ -143,8 +148,8 @@ def insert_new_file(self, user_id: str, form_data: FileForm, db: Optional[Sessio try: result = File(**file.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return FileModel.model_validate(result) else: @@ -153,21 +158,24 @@ def insert_new_file(self, user_id: str, form_data: FileForm, db: Optional[Sessio log.exception(f'Error inserting a new file: {e}') return None - def get_file_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FileModel]: + async def get_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FileModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: - file = db.get(File, id) - return FileModel.model_validate(file) + file = await db.get(File, id) + return FileModel.model_validate(file) if file else None except Exception: return None except Exception: return None - def get_file_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[FileModel]: - with get_db_context(db) as db: + async def get_file_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[FileModel]: + async with get_async_db_context(db) as db: try: - file = db.query(File).filter_by(id=id, user_id=user_id).first() + result = await db.execute(select(File).filter_by(id=id, user_id=user_id)) + file = result.scalars().first() if file: return FileModel.model_validate(file) else: @@ -175,10 +183,14 @@ def get_file_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session except Exception: return None - def get_file_metadata_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FileMetadataResponse]: - with get_db_context(db) as db: + async def get_file_metadata_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[FileMetadataResponse]: + async with get_async_db_context(db) as db: try: - file = db.get(File, id) + file = await db.get(File, id) + if not file: + return None return FileMetadataResponse( id=file.id, hash=file.hash, @@ -189,12 +201,13 @@ def get_file_metadata_by_id(self, id: str, db: Optional[Session] = None) -> Opti except Exception: return None - def get_files(self, db: Optional[Session] = None) -> list[FileModel]: - with get_db_context(db) as db: - return [FileModel.model_validate(file) for file in db.query(File).all()] + async def get_files(self, db: Optional[AsyncSession] = None) -> list[FileModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(File)) + return [FileModel.model_validate(file) for file in result.scalars().all()] - def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[Session] = None) -> bool: - file = self.get_file_by_id(id, db=db) + async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool: + file = await self.get_file_by_id(id, db=db) if not file: return False if file.user_id == user_id: @@ -202,51 +215,53 @@ def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[ # Implement additional access control logic here as needed return False - def get_files_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FileModel]: - with get_db_context(db) as db: - return [ - FileModel.model_validate(file) - for file in db.query(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc()).all() - ] - - def get_file_metadatas_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FileMetadataResponse]: - with get_db_context(db) as db: + async def get_files_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[FileModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc())) + return [FileModel.model_validate(file) for file in result.scalars().all()] + + async def get_file_metadatas_by_ids( + self, ids: list[str], db: Optional[AsyncSession] = None + ) -> list[FileMetadataResponse]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(File.id, File.hash, File.meta, File.created_at, File.updated_at) + .filter(File.id.in_(ids)) + .order_by(File.updated_at.desc()) + ) return [ FileMetadataResponse( - id=file.id, - hash=file.hash, - meta=file.meta, - created_at=file.created_at, - updated_at=file.updated_at, + id=row.id, + hash=row.hash, + meta=row.meta, + created_at=row.created_at, + updated_at=row.updated_at, ) - for file in db.query(File.id, File.hash, File.meta, File.created_at, File.updated_at) - .filter(File.id.in_(ids)) - .order_by(File.updated_at.desc()) - .all() + for row in result.all() ] - def get_files_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FileModel]: - with get_db_context(db) as db: - return [FileModel.model_validate(file) for file in db.query(File).filter_by(user_id=user_id).all()] + async def get_files_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(File).filter_by(user_id=user_id)) + return [FileModel.model_validate(file) for file in result.scalars().all()] - def get_file_list( + async def get_file_list( self, user_id: Optional[str] = None, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> 'FileListResponse': - with get_db_context(db) as db: - query = db.query(File) + async with get_async_db_context(db) as db: + stmt = select(File) if user_id: - query = query.filter_by(user_id=user_id) + stmt = stmt.filter_by(user_id=user_id) - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() - items = [ - FileModel.model_validate(file) - for file in query.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit).all() - ] + result = await db.execute(stmt.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit)) + items = [FileModelResponse.model_validate(file, from_attributes=True) for file in result.scalars().all()] return FileListResponse(items=items, total=total) @@ -273,13 +288,13 @@ def _glob_to_like_pattern(glob: str) -> str: pattern = pattern.replace('?', '_') return pattern - def search_files( + async def search_files( self, user_id: Optional[str] = None, filename: str = '*', skip: int = 0, limit: int = 100, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[FileModel]: """ Search files with glob pattern matching, optional user filter, and pagination. @@ -294,27 +309,26 @@ def search_files( Returns: List of matching FileModel objects, ordered by created_at descending. """ - with get_db_context(db) as db: - query = db.query(File) + async with get_async_db_context(db) as db: + stmt = select(File) if user_id: - query = query.filter_by(user_id=user_id) + stmt = stmt.filter_by(user_id=user_id) pattern = self._glob_to_like_pattern(filename) if pattern != '%': - query = query.filter(File.filename.ilike(pattern, escape='\\')) + stmt = stmt.filter(File.filename.ilike(pattern, escape='\\')) - return [ - FileModel.model_validate(file) - for file in query.order_by(File.created_at.desc(), File.id.desc()).offset(skip).limit(limit).all() - ] + result = await db.execute(stmt.order_by(File.created_at.desc(), File.id.desc()).offset(skip).limit(limit)) + return [FileModel.model_validate(file) for file in result.scalars().all()] - def update_file_by_id( - self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None + async def update_file_by_id( + self, id: str, form_data: FileUpdateForm, db: Optional[AsyncSession] = None ) -> Optional[FileModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: - file = db.query(File).filter_by(id=id).first() + result = await db.execute(select(File).filter_by(id=id)) + file = result.scalars().first() if form_data.hash is not None: file.hash = form_data.hash @@ -326,63 +340,70 @@ def update_file_by_id( file.meta = {**(file.meta if file.meta else {}), **form_data.meta} file.updated_at = int(time.time()) - db.commit() + await db.commit() return FileModel.model_validate(file) except Exception as e: log.exception(f'Error updating file completely by id: {e}') return None - def update_file_hash_by_id(self, id: str, hash: Optional[str], db: Optional[Session] = None) -> Optional[FileModel]: - with get_db_context(db) as db: + async def update_file_hash_by_id( + self, id: str, hash: Optional[str], db: Optional[AsyncSession] = None + ) -> Optional[FileModel]: + async with get_async_db_context(db) as db: try: - file = db.query(File).filter_by(id=id).first() + result = await db.execute(select(File).filter_by(id=id)) + file = result.scalars().first() file.hash = hash file.updated_at = int(time.time()) - db.commit() + await db.commit() return FileModel.model_validate(file) except Exception: return None - def update_file_data_by_id(self, id: str, data: dict, db: Optional[Session] = None) -> Optional[FileModel]: - with get_db_context(db) as db: + async def update_file_data_by_id( + self, id: str, data: dict, db: Optional[AsyncSession] = None + ) -> Optional[FileModel]: + async with get_async_db_context(db) as db: try: - file = db.query(File).filter_by(id=id).first() + result = await db.execute(select(File).filter_by(id=id)) + file = result.scalars().first() file.data = {**(file.data if file.data else {}), **data} file.updated_at = int(time.time()) - db.commit() + await db.commit() return FileModel.model_validate(file) except Exception as e: return None - def update_file_metadata_by_id(self, id: str, meta: dict, db: Optional[Session] = None) -> Optional[FileModel]: - with get_db_context(db) as db: + async def update_file_metadata_by_id( + self, id: str, meta: dict, db: Optional[AsyncSession] = None + ) -> Optional[FileModel]: + async with get_async_db_context(db) as db: try: - file = db.query(File).filter_by(id=id).first() + result = await db.execute(select(File).filter_by(id=id)) + file = result.scalars().first() file.meta = {**(file.meta if file.meta else {}), **meta} file.updated_at = int(time.time()) - db.commit() + await db.commit() return FileModel.model_validate(file) except Exception: return None - return False - - def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(File).filter_by(id=id).delete() - db.commit() + await db.execute(delete(File).filter_by(id=id)) + await db.commit() return True except Exception: return False - def delete_all_files(self, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_all_files(self, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(File).delete() - db.commit() + await db.execute(delete(File)) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 5311f922e20..c5532394825 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -6,16 +6,18 @@ from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func -from sqlalchemy.orm import Session +from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func, select, delete +from sqlalchemy.ext.asyncio import AsyncSession -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.internal.db import Base, JSONField, get_async_db_context log = logging.getLogger(__name__) #################### # Folder DB Schema +# Let every room in this house shelter someone who needs it, +# and let no chamber stand empty while there is want. #################### @@ -72,25 +74,25 @@ class FolderForm(BaseModel): data: Optional[dict] = None meta: Optional[dict] = None parent_id: Optional[str] = None - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='forbid') class FolderUpdateForm(BaseModel): name: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None - model_config = ConfigDict(extra='allow') + model_config = ConfigDict(extra='forbid') class FolderTable: - def insert_new_folder( + async def insert_new_folder( self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FolderModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: id = str(uuid.uuid4()) folder = FolderModel( **{ @@ -105,8 +107,8 @@ def insert_new_folder( try: result = Folder(**folder.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return FolderModel.model_validate(result) else: @@ -115,12 +117,13 @@ def insert_new_folder( log.exception(f'Error inserting a new folder: {e}') return None - def get_folder_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None + async def get_folder_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[FolderModel]: try: - with get_db_context(db) as db: - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return None @@ -129,48 +132,48 @@ def get_folder_by_id_and_user_id( except Exception: return None - def get_children_folders_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None + async def get_children_folders_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[list[FolderModel]]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: folders = [] - def get_children(folder): - children = self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) + async def get_children(folder): + children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for child in children: - get_children(child) + await get_children(child) folders.append(child) - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return None - get_children(folder) + await get_children(folder) return folders except Exception: return None - def get_folders_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FolderModel]: - with get_db_context(db) as db: - return [FolderModel.model_validate(folder) for folder in db.query(Folder).filter_by(user_id=user_id).all()] + async def get_folders_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FolderModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(user_id=user_id)) + return [FolderModel.model_validate(folder) for folder in result.scalars().all()] - def get_folder_by_parent_id_and_user_id_and_name( + async def get_folder_by_parent_id_and_user_id_and_name( self, parent_id: Optional[str], user_id: str, name: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FolderModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Check if folder exists - folder = ( - db.query(Folder) - .filter_by(parent_id=parent_id, user_id=user_id) - .filter(Folder.name.ilike(name)) - .first() + result = await db.execute( + select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name)) ) + folder = result.scalars().first() if not folder: return None @@ -180,25 +183,24 @@ def get_folder_by_parent_id_and_user_id_and_name( log.error(f'get_folder_by_parent_id_and_user_id_and_name: {e}') return None - def get_folders_by_parent_id_and_user_id( - self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None + async def get_folders_by_parent_id_and_user_id( + self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None ) -> list[FolderModel]: - with get_db_context(db) as db: - return [ - FolderModel.model_validate(folder) - for folder in db.query(Folder).filter_by(parent_id=parent_id, user_id=user_id).all() - ] + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id)) + return [FolderModel.model_validate(folder) for folder in result.scalars().all()] - def update_folder_parent_id_by_id_and_user_id( + async def update_folder_parent_id_by_id_and_user_id( self, id: str, user_id: str, parent_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FolderModel]: try: - with get_db_context(db) as db: - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return None @@ -206,38 +208,38 @@ def update_folder_parent_id_by_id_and_user_id( folder.parent_id = parent_id folder.updated_at = int(time.time()) - db.commit() + await db.commit() return FolderModel.model_validate(folder) except Exception as e: log.error(f'update_folder: {e}') return - def update_folder_by_id_and_user_id( + async def update_folder_by_id_and_user_id( self, id: str, user_id: str, form_data: FolderUpdateForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FolderModel]: try: - with get_db_context(db) as db: - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return None form_data = form_data.model_dump(exclude_unset=True) - existing_folder = ( - db.query(Folder) - .filter_by( + existing_result = await db.execute( + select(Folder).filter_by( name=form_data.get('name'), parent_id=folder.parent_id, user_id=user_id, ) - .first() ) + existing_folder = existing_result.scalars().first() if existing_folder and existing_folder.id != id: return None @@ -256,19 +258,20 @@ def update_folder_by_id_and_user_id( } folder.updated_at = int(time.time()) - db.commit() + await db.commit() return FolderModel.model_validate(folder) except Exception as e: log.error(f'update_folder: {e}') return - def update_folder_is_expanded_by_id_and_user_id( - self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None + async def update_folder_is_expanded_by_id_and_user_id( + self, id: str, user_id: str, is_expanded: bool, db: Optional[AsyncSession] = None ) -> Optional[FolderModel]: try: - with get_db_context(db) as db: - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return None @@ -276,37 +279,41 @@ def update_folder_is_expanded_by_id_and_user_id( folder.is_expanded = is_expanded folder.updated_at = int(time.time()) - db.commit() + await db.commit() return FolderModel.model_validate(folder) except Exception as e: log.error(f'update_folder: {e}') return - def delete_folder_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> list[str]: + async def delete_folder_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> list[str]: try: folder_ids = [] - with get_db_context(db) as db: - folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id)) + folder = result.scalars().first() if not folder: return folder_ids folder_ids.append(folder.id) # Delete all children folders - def delete_children(folder): - folder_children = self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) + async def delete_children(folder): + folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for folder_child in folder_children: - delete_children(folder_child) + await delete_children(folder_child) folder_ids.append(folder_child.id) - folder = db.query(Folder).filter_by(id=folder_child.id).first() - db.delete(folder) - db.commit() + child_result = await db.execute(select(Folder).filter_by(id=folder_child.id)) + child_folder = child_result.scalars().first() + await db.delete(child_folder) + await db.commit() - delete_children(folder) - db.delete(folder) - db.commit() + await delete_children(folder) + await db.delete(folder) + await db.commit() return folder_ids except Exception as e: log.error(f'delete_folder: {e}') @@ -317,8 +324,8 @@ def normalize_folder_name(self, name: str) -> str: name = re.sub(r'[\s_]+', ' ', name) return name.strip().lower() - def search_folders_by_names( - self, user_id: str, queries: list[str], db: Optional[Session] = None + async def search_folders_by_names( + self, user_id: str, queries: list[str], db: Optional[AsyncSession] = None ) -> list[FolderModel]: """ Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive. @@ -328,16 +335,18 @@ def search_folders_by_names( return [] results = {} - with get_db_context(db) as db: - folders = db.query(Folder).filter_by(user_id=user_id).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(user_id=user_id)) + folders = result.scalars().all() for folder in folders: if self.normalize_folder_name(folder.name) in normalized_queries: results[folder.id] = FolderModel.model_validate(folder) # get children folders - children = self.get_children_folders_by_id_and_user_id(folder.id, user_id, db=db) - for child in children: - results[child.id] = child + children = await self.get_children_folders_by_id_and_user_id(folder.id, user_id, db=db) + if children: + for child in children: + results[child.id] = child # Return the results as a list if not results: @@ -346,16 +355,17 @@ def search_folders_by_names( results = list(results.values()) return results - def search_folders_by_name_contains( - self, user_id: str, query: str, db: Optional[Session] = None + async def search_folders_by_name_contains( + self, user_id: str, query: str, db: Optional[AsyncSession] = None ) -> list[FolderModel]: """ Partial match: normalized name contains (as substring) the normalized query. """ normalized_query = self.normalize_folder_name(query) results = [] - with get_db_context(db) as db: - folders = db.query(Folder).filter_by(user_id=user_id).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(user_id=user_id)) + folders = result.scalars().all() for folder in folders: norm_name = self.normalize_folder_name(folder.name) if normalized_query in norm_name: diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 3c29b4fa93e..ddac317863d 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -2,9 +2,10 @@ import time from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context -from open_webui.models.users import Users, UserModel +from sqlalchemy import select, delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context +from open_webui.models.users import Users, UserModel, UserResponse from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index @@ -12,6 +13,8 @@ #################### # Functions DB Schema +# Each function here is a promise made. Let no promise +# go unkept, and let none be called who cannot answer. #################### @@ -75,10 +78,6 @@ class FunctionWithValvesModel(BaseModel): #################### -class FunctionUserResponse(FunctionModel): - user: Optional[UserModel] = None - - class FunctionResponse(BaseModel): id: str user_id: str @@ -90,6 +89,12 @@ class FunctionResponse(BaseModel): updated_at: int # timestamp in epoch created_at: int # timestamp in epoch + model_config = ConfigDict(from_attributes=True) + + +class FunctionUserResponse(FunctionResponse): + user: Optional[UserResponse] = None + class FunctionForm(BaseModel): id: str @@ -103,12 +108,12 @@ class FunctionValves(BaseModel): class FunctionsTable: - def insert_new_function( + async def insert_new_function( self, user_id: str, type: str, form_data: FunctionForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[FunctionModel]: function = FunctionModel( **{ @@ -121,11 +126,11 @@ def insert_new_function( ) try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: result = Function(**function.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return FunctionModel.model_validate(result) else: @@ -134,17 +139,18 @@ def insert_new_function( log.exception(f'Error creating a new function: {e}') return None - def sync_functions( + async def sync_functions( self, user_id: str, functions: list[FunctionWithValvesModel], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[FunctionWithValvesModel]: # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present. try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Get existing functions - existing_functions = db.query(Function).all() + result = await db.execute(select(Function)) + existing_functions = result.scalars().all() existing_ids = {func.id for func in existing_functions} # Prepare a set of new function IDs @@ -153,12 +159,14 @@ def sync_functions( # Update or insert functions for func in functions: if func.id in existing_ids: - db.query(Function).filter_by(id=func.id).update( - { + await db.execute( + update(Function) + .filter_by(id=func.id) + .values( **func.model_dump(), - 'user_id': user_id, - 'updated_at': int(time.time()), - } + user_id=user_id, + updated_at=int(time.time()), + ) ) else: new_func = Function( @@ -173,24 +181,25 @@ def sync_functions( # Remove functions that are no longer present for func in existing_functions: if func.id not in new_function_ids: - db.delete(func) + await db.delete(func) - db.commit() + await db.commit() - return [FunctionModel.model_validate(func) for func in db.query(Function).all()] + result = await db.execute(select(Function)) + return [FunctionModel.model_validate(func) for func in result.scalars().all()] except Exception as e: log.exception(f'Error syncing functions for user {user_id}: {e}') return [] - def get_function_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FunctionModel]: + async def get_function_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FunctionModel]: try: - with get_db_context(db) as db: - function = db.get(Function, id) - return FunctionModel.model_validate(function) + async with get_async_db_context(db) as db: + function = await db.get(Function, id) + return FunctionModel.model_validate(function) if function else None except Exception: return None - def get_functions_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FunctionModel]: + async def get_functions_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[FunctionModel]: """ Batch fetch multiple functions by their IDs in a single query. Returns functions in the same order as the input IDs (None entries filtered out). @@ -198,8 +207,9 @@ def get_functions_by_ids(self, ids: list[str], db: Optional[Session] = None) -> if not ids: return [] try: - with get_db_context(db) as db: - functions = db.query(Function).filter(Function.id.in_(ids)).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Function).filter(Function.id.in_(ids))) + functions = result.scalars().all() # Create a dict for O(1) lookup func_dict = {f.id: FunctionModel.model_validate(f) for f in functions} # Return in original order, filtering out any not found @@ -207,75 +217,80 @@ def get_functions_by_ids(self, ids: list[str], db: Optional[Session] = None) -> except Exception: return [] - def get_functions( - self, active_only=False, include_valves=False, db: Optional[Session] = None + async def get_functions( + self, active_only=False, include_valves=False, db: Optional[AsyncSession] = None ) -> list[FunctionModel | FunctionWithValvesModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: if active_only: - functions = db.query(Function).filter_by(is_active=True).all() - + result = await db.execute(select(Function).filter_by(is_active=True)) else: - functions = db.query(Function).all() + result = await db.execute(select(Function)) + + functions = result.scalars().all() if include_valves: return [FunctionWithValvesModel.model_validate(function) for function in functions] else: return [FunctionModel.model_validate(function) for function in functions] - def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]: - with get_db_context(db) as db: - functions = db.query(Function).order_by(Function.updated_at.desc()).all() + async def get_function_list(self, db: Optional[AsyncSession] = None) -> list[FunctionUserResponse]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Function).order_by(Function.updated_at.desc())) + functions = result.scalars().all() user_ids = list(set(func.user_id for func in functions)) - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} return [ FunctionUserResponse.model_validate( { - **FunctionModel.model_validate(func).model_dump(), - 'user': (users_dict.get(func.user_id).model_dump() if func.user_id in users_dict else None), + **FunctionResponse.model_validate(func).model_dump(), + 'user': ( + UserResponse( + id=users_dict[func.user_id].id, + name=users_dict[func.user_id].name, + role=users_dict[func.user_id].role, + email=users_dict[func.user_id].email, + ).model_dump() + if func.user_id in users_dict + else None + ), } ) for func in functions ] - def get_functions_by_type(self, type: str, active_only=False, db: Optional[Session] = None) -> list[FunctionModel]: - with get_db_context(db) as db: + async def get_functions_by_type( + self, type: str, active_only=False, db: Optional[AsyncSession] = None + ) -> list[FunctionModel]: + async with get_async_db_context(db) as db: if active_only: - return [ - FunctionModel.model_validate(function) - for function in db.query(Function).filter_by(type=type, is_active=True).all() - ] + result = await db.execute(select(Function).filter_by(type=type, is_active=True)) else: - return [ - FunctionModel.model_validate(function) for function in db.query(Function).filter_by(type=type).all() - ] + result = await db.execute(select(Function).filter_by(type=type)) + return [FunctionModel.model_validate(function) for function in result.scalars().all()] - def get_global_filter_functions(self, db: Optional[Session] = None) -> list[FunctionModel]: - with get_db_context(db) as db: - return [ - FunctionModel.model_validate(function) - for function in db.query(Function).filter_by(type='filter', is_active=True, is_global=True).all() - ] + async def get_global_filter_functions(self, db: Optional[AsyncSession] = None) -> list[FunctionModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True)) + return [FunctionModel.model_validate(function) for function in result.scalars().all()] - def get_global_action_functions(self, db: Optional[Session] = None) -> list[FunctionModel]: - with get_db_context(db) as db: - return [ - FunctionModel.model_validate(function) - for function in db.query(Function).filter_by(type='action', is_active=True, is_global=True).all() - ] + async def get_global_action_functions(self, db: Optional[AsyncSession] = None) -> list[FunctionModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True)) + return [FunctionModel.model_validate(function) for function in result.scalars().all()] - def get_function_valves_by_id(self, id: str, db: Optional[Session] = None) -> Optional[dict]: - with get_db_context(db) as db: + async def get_function_valves_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[dict]: + async with get_async_db_context(db) as db: try: - function = db.get(Function, id) + function = await db.get(Function, id) return function.valves if function.valves else {} except Exception as e: log.exception(f'Error getting function valves by id {id}: {e}') return None - def get_function_valves_by_ids(self, ids: list[str], db: Optional[Session] = None) -> dict[str, dict]: + async def get_function_valves_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> dict[str, dict]: """ Batch fetch valves for multiple functions in a single query. Returns a dict mapping function_id -> valves dict. @@ -284,33 +299,34 @@ def get_function_valves_by_ids(self, ids: list[str], db: Optional[Session] = Non if not ids: return {} try: - with get_db_context(db) as db: - functions = db.query(Function.id, Function.valves).filter(Function.id.in_(ids)).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids))) + functions = result.all() return {f.id: (f.valves if f.valves else {}) for f in functions} except Exception as e: log.exception(f'Error batch-fetching function valves: {e}') return {} - def update_function_valves_by_id( - self, id: str, valves: dict, db: Optional[Session] = None + async def update_function_valves_by_id( + self, id: str, valves: dict, db: Optional[AsyncSession] = None ) -> Optional[FunctionValves]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: - function = db.get(Function, id) + function = await db.get(Function, id) function.valves = valves function.updated_at = int(time.time()) - db.commit() - db.refresh(function) + await db.commit() + await db.refresh(function) return FunctionModel.model_validate(function) except Exception: return None - def update_function_metadata_by_id( - self, id: str, metadata: dict, db: Optional[Session] = None + async def update_function_metadata_by_id( + self, id: str, metadata: dict, db: Optional[AsyncSession] = None ) -> Optional[FunctionModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: - function = db.get(Function, id) + function = await db.get(Function, id) if function: if function.meta: @@ -319,8 +335,8 @@ def update_function_metadata_by_id( function.meta = metadata function.updated_at = int(time.time()) - db.commit() - db.refresh(function) + await db.commit() + await db.refresh(function) return FunctionModel.model_validate(function) else: return None @@ -328,9 +344,11 @@ def update_function_metadata_by_id( log.exception(f'Error updating function metadata by id {id}: {e}') return None - def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[dict]: + async def get_user_valves_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings @@ -344,11 +362,11 @@ def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[ log.exception(f'Error getting user values by id {id} and user id {user_id}') return None - def update_user_valves_by_id_and_user_id( - self, id: str, user_id: str, valves: dict, db: Optional[Session] = None + async def update_user_valves_by_id_and_user_id( + self, id: str, user_id: str, valves: dict, db: Optional[AsyncSession] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings @@ -360,47 +378,51 @@ def update_user_valves_by_id_and_user_id( user_settings['functions']['valves'][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) + await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) return user_settings['functions']['valves'][id] except Exception as e: log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}') return None - def update_function_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[FunctionModel]: - with get_db_context(db) as db: + async def update_function_by_id( + self, id: str, updated: dict, db: Optional[AsyncSession] = None + ) -> Optional[FunctionModel]: + async with get_async_db_context(db) as db: try: - db.query(Function).filter_by(id=id).update( - { + await db.execute( + update(Function) + .filter_by(id=id) + .values( **updated, - 'updated_at': int(time.time()), - } + updated_at=int(time.time()), + ) ) - db.commit() - function = db.get(Function, id) + await db.commit() + function = await db.get(Function, id) return FunctionModel.model_validate(function) if function else None except Exception: return None - def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]: - with get_db_context(db) as db: + async def deactivate_all_functions(self, db: Optional[AsyncSession] = None) -> Optional[bool]: + async with get_async_db_context(db) as db: try: - db.query(Function).update( - { - 'is_active': False, - 'updated_at': int(time.time()), - } + await db.execute( + update(Function).values( + is_active=False, + updated_at=int(time.time()), + ) ) - db.commit() + await db.commit() return True except Exception: return None - def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_function_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(Function).filter_by(id=id).delete() - db.commit() + await db.execute(delete(Function).filter_by(id=id)) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index d6c2fc9450c..bc199fac5bd 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -4,8 +4,9 @@ from typing import Optional import uuid -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update, func, and_, or_, cast, String +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION from open_webui.models.files import FileMetadataResponse @@ -15,21 +16,17 @@ from sqlalchemy import ( BigInteger, Column, - String, Text, JSON, - and_, - func, ForeignKey, - cast, - or_, - select, ) log = logging.getLogger(__name__) #################### # UserGroup DB Schema +# Let none who belong to this house be turned away, +# and let the covenant hold for every member. #################### @@ -141,10 +138,10 @@ def _ensure_default_share_config(self, group_data: dict) -> dict: group_data['data']['config']['share'] = DEFAULT_GROUP_SHARE_PERMISSION return group_data - def insert_new_group( - self, user_id: str, form_data: GroupForm, db: Optional[Session] = None + async def insert_new_group( + self, user_id: str, form_data: GroupForm, db: Optional[AsyncSession] = None ) -> Optional[GroupModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: group_data = self._ensure_default_share_config(form_data.model_dump(exclude_none=True)) group = GroupModel( **{ @@ -159,8 +156,8 @@ def insert_new_group( try: result = Group(**group.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return GroupModel.model_validate(result) else: @@ -169,13 +166,20 @@ def insert_new_group( except Exception: return None - def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]: - with get_db_context(db) as db: - groups = db.query(Group).order_by(Group.updated_at.desc()).all() + async def get_all_groups(self, db: Optional[AsyncSession] = None) -> list[GroupModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Group).order_by(Group.updated_at.desc())) + groups = result.scalars().all() return [GroupModel.model_validate(group) for group in groups] - def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]: - with get_db_context(db) as db: + async def get_group_by_name(self, name: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Group).filter(Group.name == name)) + group = result.scalars().first() + return GroupModel.model_validate(group) if group else None + + async def get_groups(self, filter, db: Optional[AsyncSession] = None) -> list[GroupResponse]: + async with get_async_db_context(db) as db: member_count = ( select(func.count(GroupMember.user_id)) .where(GroupMember.group_id == Group.id) @@ -183,11 +187,11 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse .scalar_subquery() .label('member_count') ) - query = db.query(Group, member_count) + stmt = select(Group, member_count) if filter: if 'query' in filter: - query = query.filter(Group.name.ilike(f'%{filter["query"]}%')) + stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%')) # When share filter is present, member check is handled in the share logic if 'share' in filter: @@ -211,20 +215,21 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse json_share_lower == 'members', Group.id.in_(member_groups_select), ) - query = query.filter(or_(anyone_can_share, members_only_and_is_member)) + stmt = stmt.filter(or_(anyone_can_share, members_only_and_is_member)) else: - query = query.filter(anyone_can_share) + stmt = stmt.filter(anyone_can_share) else: - query = query.filter(and_(Group.data.isnot(None), json_share_lower == 'false')) + stmt = stmt.filter(and_(Group.data.isnot(None), json_share_lower == 'false')) else: # Only apply member_id filter when share filter is NOT present if 'member_id' in filter: - query = query.filter( + stmt = stmt.filter( Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id'])) ) - results = query.order_by(Group.updated_at.desc()).all() + result = await db.execute(stmt.order_by(Group.updated_at.desc())) + rows = result.all() return [ GroupResponse.model_validate( @@ -233,32 +238,34 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse 'member_count': count or 0, } ) - for group, count in results + for group, count in rows ] - def search_groups( + async def search_groups( self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> GroupListResponse: - with get_db_context(db) as db: - query = db.query(Group) + async with get_async_db_context(db) as db: + stmt = select(Group) if filter: if 'query' in filter: - query = query.filter(Group.name.ilike(f'%{filter["query"]}%')) + stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%')) if 'member_id' in filter: - query = query.filter( + stmt = stmt.filter( Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id'])) ) if 'share' in filter: share_value = filter['share'] - query = query.filter(Group.data.op('->>')('share') == str(share_value)) + stmt = stmt.filter(Group.data.op('->>')('share') == str(share_value)) - total = query.count() + # Get total count + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() member_count = ( select(func.count(GroupMember.user_id)) @@ -267,7 +274,14 @@ def search_groups( .scalar_subquery() .label('member_count') ) - results = query.add_columns(member_count).order_by(Group.updated_at.desc()).offset(skip).limit(limit).all() + result = await db.execute( + select(Group, member_count) + .where(Group.id.in_(select(stmt.subquery().c.id))) + .order_by(Group.updated_at.desc()) + .offset(skip) + .limit(limit) + ) + rows = result.all() return { 'items': [ @@ -277,65 +291,69 @@ def search_groups( 'member_count': count or 0, } ) - for group, count in results + for group, count in rows ], 'total': total, } - def get_groups_by_member_id(self, user_id: str, db: Optional[Session] = None) -> list[GroupModel]: - with get_db_context(db) as db: - return [ - GroupModel.model_validate(group) - for group in db.query(Group) + async def get_groups_by_member_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[GroupModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Group) .join(GroupMember, GroupMember.group_id == Group.id) .filter(GroupMember.user_id == user_id) .order_by(Group.updated_at.desc()) - .all() - ] + ) + return [GroupModel.model_validate(group) for group in result.scalars().all()] - def get_groups_by_member_ids( - self, user_ids: list[str], db: Optional[Session] = None + async def get_groups_by_member_ids( + self, user_ids: list[str], db: Optional[AsyncSession] = None ) -> dict[str, list[GroupModel]]: """Fetch groups for multiple users in a single query to avoid N+1.""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Query GroupMember joined with Group, filtering by user_ids - results = ( - db.query(GroupMember.user_id, Group) + result = await db.execute( + select(GroupMember.user_id, Group) .join(Group, Group.id == GroupMember.group_id) .filter(GroupMember.user_id.in_(user_ids)) .order_by(Group.updated_at.desc()) - .all() ) + rows = result.all() # Group groups by user_id user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids} - for user_id, group in results: + for user_id, group in rows: user_groups[user_id].append(GroupModel.model_validate(group)) return user_groups - def get_group_by_id(self, id: str, db: Optional[Session] = None) -> Optional[GroupModel]: + async def get_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]: try: - with get_db_context(db) as db: - group = db.query(Group).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Group).filter_by(id=id)) + group = result.scalars().first() return GroupModel.model_validate(group) if group else None except Exception: return None - def get_group_user_ids_by_id(self, id: str, db: Optional[Session] = None) -> list[str]: - with get_db_context(db) as db: - members = db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all() + async def get_group_user_ids_by_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]: + async with get_async_db_context(db) as db: + result = await db.execute(select(GroupMember.user_id).filter(GroupMember.group_id == id)) + members = result.all() if not members: return [] return [m[0] for m in members] - def get_group_user_ids_by_ids(self, group_ids: list[str], db: Optional[Session] = None) -> dict[str, list[str]]: - with get_db_context(db) as db: - members = ( - db.query(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids)).all() + async def get_group_user_ids_by_ids( + self, group_ids: list[str], db: Optional[AsyncSession] = None + ) -> dict[str, list[str]]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids)) ) + members = result.all() group_user_ids: dict[str, list[str]] = {group_id: [] for group_id in group_ids} @@ -344,10 +362,12 @@ def get_group_user_ids_by_ids(self, group_ids: list[str], db: Optional[Session] return group_user_ids - def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str], db: Optional[Session] = None) -> None: - with get_db_context(db) as db: + async def set_group_user_ids_by_id( + self, group_id: str, user_ids: list[str], db: Optional[AsyncSession] = None + ) -> None: + async with get_async_db_context(db) as db: # Delete existing members - db.query(GroupMember).filter(GroupMember.group_id == group_id).delete() + await db.execute(delete(GroupMember).filter(GroupMember.group_id == group_id)) # Insert new members now = int(time.time()) @@ -363,101 +383,104 @@ def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str], db: Optio ] db.add_all(new_members) - db.commit() + await db.commit() - def get_group_member_count_by_id(self, id: str, db: Optional[Session] = None) -> int: - with get_db_context(db) as db: - count = db.query(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id).scalar() + async def get_group_member_count_by_id(self, id: str, db: Optional[AsyncSession] = None) -> int: + async with get_async_db_context(db) as db: + result = await db.execute(select(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id)) + count = result.scalar() return count if count else 0 - def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[Session] = None) -> dict[str, int]: + async def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> dict[str, int]: if not ids: return {} - with get_db_context(db) as db: - rows = ( - db.query(GroupMember.group_id, func.count(GroupMember.user_id)) + async with get_async_db_context(db) as db: + result = await db.execute( + select(GroupMember.group_id, func.count(GroupMember.user_id)) .filter(GroupMember.group_id.in_(ids)) .group_by(GroupMember.group_id) - .all() ) + rows = result.all() return {group_id: count for group_id, count in rows} - def update_group_by_id( + async def update_group_by_id( self, id: str, form_data: GroupUpdateForm, overwrite: bool = False, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[GroupModel]: try: - with get_db_context(db) as db: - db.query(Group).filter_by(id=id).update( - { + async with get_async_db_context(db) as db: + await db.execute( + update(Group) + .filter_by(id=id) + .values( **form_data.model_dump(exclude_none=True), - 'updated_at': int(time.time()), - } + updated_at=int(time.time()), + ) ) - db.commit() - return self.get_group_by_id(id=id, db=db) + await db.commit() + return await self.get_group_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(Group).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(Group).filter_by(id=id)) + await db.commit() return True except Exception: return False - def delete_all_groups(self, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_all_groups(self, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(Group).delete() - db.commit() + await db.execute(delete(Group)) + await db.commit() return True except Exception: return False - def remove_user_from_all_groups(self, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def remove_user_from_all_groups(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: # Find all groups the user belongs to - groups = ( - db.query(Group) + result = await db.execute( + select(Group) .join(GroupMember, GroupMember.group_id == Group.id) .filter(GroupMember.user_id == user_id) - .all() ) + groups = result.scalars().all() # Remove the user from each group for group in groups: - db.query(GroupMember).filter( - GroupMember.group_id == group.id, GroupMember.user_id == user_id - ).delete() + await db.execute( + delete(GroupMember).filter(GroupMember.group_id == group.id, GroupMember.user_id == user_id) + ) - db.query(Group).filter_by(id=group.id).update({'updated_at': int(time.time())}) + await db.execute(update(Group).filter_by(id=group.id).values(updated_at=int(time.time()))) - db.commit() + await db.commit() return True except Exception: - db.rollback() + await db.rollback() return False - def create_groups_by_group_names( - self, user_id: str, group_names: list[str], db: Optional[Session] = None + async def create_groups_by_group_names( + self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None ) -> list[GroupModel]: # check for existing groups - existing_groups = self.get_all_groups(db=db) + existing_groups = await self.get_all_groups(db=db) existing_group_names = {group.name for group in existing_groups} new_groups = [] - with get_db_context(db) as db: + async with get_async_db_context(db) as db: for group_name in group_names: if group_name not in existing_group_names: new_group = GroupModel( @@ -476,31 +499,33 @@ def create_groups_by_group_names( try: result = Group(**new_group.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) new_groups.append(GroupModel.model_validate(result)) except Exception as e: log.exception(e) continue return new_groups - def sync_groups_by_group_names(self, user_id: str, group_names: list[str], db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def sync_groups_by_group_names( + self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None + ) -> bool: + async with get_async_db_context(db) as db: try: now = int(time.time()) # 1. Groups that SHOULD contain the user - target_groups = db.query(Group).filter(Group.name.in_(group_names)).all() + result = await db.execute(select(Group).filter(Group.name.in_(group_names))) + target_groups = result.scalars().all() target_group_ids = {g.id for g in target_groups} # 2. Groups the user is CURRENTLY in - existing_group_ids = { - g.id - for g in db.query(Group) + result = await db.execute( + select(Group) .join(GroupMember, GroupMember.group_id == Group.id) .filter(GroupMember.user_id == user_id) - .all() - } + ) + existing_group_ids = {g.id for g in result.scalars().all()} # 3. Determine adds + removals groups_to_add = target_group_ids - existing_group_ids @@ -508,15 +533,15 @@ def sync_groups_by_group_names(self, user_id: str, group_names: list[str], db: O # 4. Remove in one bulk delete if groups_to_remove: - db.query(GroupMember).filter( - GroupMember.user_id == user_id, - GroupMember.group_id.in_(groups_to_remove), - ).delete(synchronize_session=False) - - db.query(Group).filter(Group.id.in_(groups_to_remove)).update( - {'updated_at': now}, synchronize_session=False + await db.execute( + delete(GroupMember).filter( + GroupMember.user_id == user_id, + GroupMember.group_id.in_(groups_to_remove), + ) ) + await db.execute(update(Group).filter(Group.id.in_(groups_to_remove)).values(updated_at=now)) + # 5. Bulk insert missing memberships for group_id in groups_to_add: db.add( @@ -530,27 +555,26 @@ def sync_groups_by_group_names(self, user_id: str, group_names: list[str], db: O ) if groups_to_add: - db.query(Group).filter(Group.id.in_(groups_to_add)).update( - {'updated_at': now}, synchronize_session=False - ) + await db.execute(update(Group).filter(Group.id.in_(groups_to_add)).values(updated_at=now)) - db.commit() + await db.commit() return True except Exception as e: log.exception(e) - db.rollback() + await db.rollback() return False - def add_users_to_group( + async def add_users_to_group( self, id: str, user_ids: Optional[list[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[GroupModel]: try: - with get_db_context(db) as db: - group = db.query(Group).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Group).filter_by(id=id)) + group = result.scalars().first() if not group: return None @@ -567,15 +591,14 @@ def add_users_to_group( updated_at=now, ) ) - db.flush() # Detect unique constraint violation early + await db.flush() # Detect unique constraint violation early except Exception: - db.rollback() # Clear failed INSERT - db.begin() # Start a new transaction + await db.rollback() # Clear failed INSERT continue # Duplicate → ignore group.updated_at = now - db.commit() - db.refresh(group) + await db.commit() + await db.refresh(group) return GroupModel.model_validate(group) @@ -583,15 +606,16 @@ def add_users_to_group( log.exception(e) return None - def remove_users_from_group( + async def remove_users_from_group( self, id: str, user_ids: Optional[list[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[GroupModel]: try: - with get_db_context(db) as db: - group = db.query(Group).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Group).filter_by(id=id)) + group = result.scalars().first() if not group: return None @@ -599,15 +623,15 @@ def remove_users_from_group( return GroupModel.model_validate(group) # Remove users from group_member in batch - db.query(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)).delete( - synchronize_session=False + await db.execute( + delete(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)) ) # Update group timestamp group.updated_at = int(time.time()) - db.commit() - db.refresh(group) + await db.commit() + await db.refresh(group) return GroupModel.model_validate(group) except Exception as e: diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 0495abfb39e..e08e626981e 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -4,8 +4,9 @@ from typing import Optional import uuid -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update, or_, func, cast +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.files import ( File, @@ -27,13 +28,14 @@ Text, JSON, UniqueConstraint, - or_, ) log = logging.getLogger(__name__) #################### # Knowledge DB Schema +# Let what was gathered here outlast the one who gathered it, +# and still teach when the builder is gone. #################### @@ -132,25 +134,25 @@ class KnowledgeFileListResponse(BaseModel): class KnowledgeTable: - def _get_access_grants(self, knowledge_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('knowledge', knowledge_id, db=db) + async def _get_access_grants(self, knowledge_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('knowledge', knowledge_id, db=db) - def _to_knowledge_model( + async def _to_knowledge_model( self, knowledge: Knowledge, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> KnowledgeModel: knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'}) knowledge_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(knowledge_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(knowledge_data['id'], db=db) ) return KnowledgeModel.model_validate(knowledge_data) - def insert_new_knowledge( - self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None + async def insert_new_knowledge( + self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None ) -> Optional[KnowledgeModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: knowledge = KnowledgeModel( **{ **form_data.model_dump(exclude={'access_grants'}), @@ -165,27 +167,28 @@ def insert_new_knowledge( try: result = Knowledge(**knowledge.model_dump(exclude={'access_grants'})) db.add(result) - db.commit() - db.refresh(result) - AccessGrants.set_access_grants('knowledge', result.id, form_data.access_grants, db=db) + await db.commit() + await db.refresh(result) + await AccessGrants.set_access_grants('knowledge', result.id, form_data.access_grants, db=db) if result: - return self._to_knowledge_model(result, db=db) + return await self._to_knowledge_model(result, db=db) else: return None except Exception: return None - def get_knowledge_bases( - self, skip: int = 0, limit: int = 30, db: Optional[Session] = None + async def get_knowledge_bases( + self, skip: int = 0, limit: int = 30, db: Optional[AsyncSession] = None ) -> list[KnowledgeUserModel]: - with get_db_context(db) as db: - all_knowledge = db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Knowledge).order_by(Knowledge.updated_at.desc())) + all_knowledge = result.scalars().all() user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) knowledge_ids = [knowledge.id for knowledge in all_knowledge] - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) knowledge_bases = [] for knowledge in all_knowledge: @@ -193,10 +196,12 @@ def get_knowledge_bases( knowledge_bases.append( KnowledgeUserModel.model_validate( { - **self._to_knowledge_model( - knowledge, - access_grants=grants_map.get(knowledge.id, []), - db=db, + **( + await self._to_knowledge_model( + knowledge, + access_grants=grants_map.get(knowledge.id, []), + db=db, + ) ).model_dump(), 'user': user.model_dump() if user else None, } @@ -204,22 +209,22 @@ def get_knowledge_bases( ) return knowledge_bases - def search_knowledge_bases( + async def search_knowledge_bases( self, user_id: str, filter: dict, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> KnowledgeListResponse: try: - with get_db_context(db) as db: - query = db.query(Knowledge, User).outerjoin(User, User.id == Knowledge.user_id) + async with get_async_db_context(db) as db: + stmt = select(Knowledge, User).outerjoin(User, User.id == Knowledge.user_id) if filter: query_key = filter.get('query') if query_key: - query = query.filter( + stmt = stmt.filter( or_( Knowledge.name.ilike(f'%{query_key}%'), Knowledge.description.ilike(f'%{query_key}%'), @@ -231,41 +236,45 @@ def search_knowledge_bases( view_option = filter.get('view_option') if view_option == 'created': - query = query.filter(Knowledge.user_id == user_id) + stmt = stmt.filter(Knowledge.user_id == user_id) elif view_option == 'shared': - query = query.filter(Knowledge.user_id != user_id) + stmt = stmt.filter(Knowledge.user_id != user_id) - query = AccessGrants.has_permission_filter( + stmt = AccessGrants.has_permission_filter( db=db, - query=query, + query=stmt, DocumentModel=Knowledge, filter=filter, resource_type='knowledge', permission='read', ) - query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc()) + stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc()) - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() knowledge_ids = [kb.id for kb, _ in items] - grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) knowledge_bases = [] for knowledge_base, user in items: knowledge_bases.append( KnowledgeUserModel.model_validate( { - **self._to_knowledge_model( - knowledge_base, - access_grants=grants_map.get(knowledge_base.id, []), - db=db, + **( + await self._to_knowledge_model( + knowledge_base, + access_grants=grants_map.get(knowledge_base.id, []), + db=db, + ) ).model_dump(), 'user': (UserModel.model_validate(user).model_dump() if user else None), } @@ -277,52 +286,58 @@ def search_knowledge_bases( print(e) return KnowledgeListResponse(items=[], total=0) - def search_knowledge_files( - self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None + async def search_knowledge_files( + self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[AsyncSession] = None ) -> KnowledgeFileListResponse: """ Scalable version: search files across all knowledge bases the user has READ access to, without loading all KBs or using large IN() lists. """ try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Base query: join Knowledge → KnowledgeFile → File - query = ( - db.query(File, User, Knowledge) + stmt = ( + select(File, User, Knowledge) .join(KnowledgeFile, File.id == KnowledgeFile.file_id) .join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id) .outerjoin(User, User.id == KnowledgeFile.user_id) ) # Apply access-control directly to the joined query - # This makes the database handle filtering, even with 10k+ KBs - query = AccessGrants.has_permission_filter( + stmt = AccessGrants.has_permission_filter( db=db, - query=query, + query=stmt, DocumentModel=Knowledge, filter=filter, resource_type='knowledge', permission='read', ) - # Apply filename search + # Apply filename / content search if filter: q = filter.get('query') if q: - query = query.filter(File.filename.ilike(f'%{q}%')) + stmt = stmt.filter( + or_( + File.filename.ilike(f'%{q}%'), + cast(File.data['content'], Text).ilike(f'%{q}%'), + ) + ) # Order by file changes - query = query.order_by(File.updated_at.desc(), File.id.asc()) + stmt = stmt.order_by(File.updated_at.desc(), File.id.asc()) # Count before pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - rows = query.all() + result = await db.execute(stmt) + rows = result.all() items = [] for file, user, knowledge in rows: @@ -330,7 +345,7 @@ def search_knowledge_files( FileUserResponse( **FileModel.model_validate(file).model_dump(), user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), - collection=self._to_knowledge_model(knowledge, db=db).model_dump(), + collection=(await self._to_knowledge_model(knowledge, db=db)).model_dump(), ) ) @@ -340,14 +355,15 @@ def search_knowledge_files( print('search_knowledge_files error:', e) return KnowledgeFileListResponse(items=[], total=0) - def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[Session] = None) -> bool: - knowledge = self.get_knowledge_by_id(id, db=db) + async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool: + knowledge = await self.get_knowledge_by_id(id, db=db) if not knowledge: return False if knowledge.user_id == user_id: return True - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - return AccessGrants.has_access( + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + return await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge.id, @@ -356,45 +372,50 @@ def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[ db=db, ) - def get_knowledge_bases_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[Session] = None + async def get_knowledge_bases_by_user_id( + self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None ) -> list[KnowledgeUserModel]: - knowledge_bases = self.get_knowledge_bases(db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - return [ - knowledge_base - for knowledge_base in knowledge_bases - if knowledge_base.user_id == user_id - or AccessGrants.has_access( + knowledge_bases = await self.get_knowledge_bases(db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + result = [] + for knowledge_base in knowledge_bases: + if knowledge_base.user_id == user_id: + result.append(knowledge_base) + elif await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge_base.id, permission=permission, user_group_ids=user_group_ids, db=db, - ) - ] + ): + result.append(knowledge_base) + return result - def get_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> Optional[KnowledgeModel]: + async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: - with get_db_context(db) as db: - knowledge = db.query(Knowledge).filter_by(id=id).first() - return self._to_knowledge_model(knowledge, db=db) if knowledge else None + async with get_async_db_context(db) as db: + result = await db.execute(select(Knowledge).filter_by(id=id)) + knowledge = result.scalars().first() + return await self._to_knowledge_model(knowledge, db=db) if knowledge else None except Exception: return None - def get_knowledge_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None + async def get_knowledge_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[KnowledgeModel]: - knowledge = self.get_knowledge_by_id(id, db=db) + knowledge = await self.get_knowledge_by_id(id, db=db) if not knowledge: return None if knowledge.user_id == user_id: return knowledge - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - if AccessGrants.has_access( + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + if await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge.id, @@ -405,19 +426,19 @@ def get_knowledge_by_id_and_user_id( return knowledge return None - def get_knowledges_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[KnowledgeModel]: + async def get_knowledges_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[KnowledgeModel]: try: - with get_db_context(db) as db: - knowledges = ( - db.query(Knowledge) + async with get_async_db_context(db) as db: + result = await db.execute( + select(Knowledge) .join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id) .filter(KnowledgeFile.file_id == file_id) - .all() ) + knowledges = result.scalars().all() knowledge_ids = [k.id for k in knowledges] - grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) return [ - self._to_knowledge_model( + await self._to_knowledge_model( knowledge, access_grants=grants_map.get(knowledge.id, []), db=db, @@ -427,19 +448,19 @@ def get_knowledges_by_file_id(self, file_id: str, db: Optional[Session] = None) except Exception: return [] - def search_files_by_id( + async def search_files_by_id( self, knowledge_id: str, user_id: str, filter: dict, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> KnowledgeFileListResponse: try: - with get_db_context(db) as db: - query = ( - db.query(File, User) + async with get_async_db_context(db) as db: + stmt = ( + select(File, User) .join(KnowledgeFile, File.id == KnowledgeFile.file_id) .outerjoin(User, User.id == KnowledgeFile.user_id) .filter(KnowledgeFile.knowledge_id == knowledge_id) @@ -451,13 +472,18 @@ def search_files_by_id( if filter: query_key = filter.get('query') if query_key: - query = query.filter(or_(File.filename.ilike(f'%{query_key}%'))) + stmt = stmt.filter( + or_( + File.filename.ilike(f'%{query_key}%'), + cast(File.data['content'], Text).ilike(f'%{query_key}%'), + ) + ) view_option = filter.get('view_option') if view_option == 'created': - query = query.filter(KnowledgeFile.user_id == user_id) + stmt = stmt.filter(KnowledgeFile.user_id == user_id) elif view_option == 'shared': - query = query.filter(KnowledgeFile.user_id != user_id) + stmt = stmt.filter(KnowledgeFile.user_id != user_id) order_by = filter.get('order_by') direction = filter.get('direction') @@ -471,17 +497,19 @@ def search_files_by_id( primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc() # Apply sort with secondary key for deterministic pagination - query = query.order_by(primary_sort, File.id.asc()) + stmt = stmt.order_by(primary_sort, File.id.asc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() files = [] for file, user in items: @@ -497,35 +525,36 @@ def search_files_by_id( print(e) return KnowledgeFileListResponse(items=[], total=0) - def get_files_by_id(self, knowledge_id: str, db: Optional[Session] = None) -> list[FileModel]: + async def get_files_by_id(self, knowledge_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]: try: - with get_db_context(db) as db: - files = ( - db.query(File) + async with get_async_db_context(db) as db: + result = await db.execute( + select(File) .join(KnowledgeFile, File.id == KnowledgeFile.file_id) .filter(KnowledgeFile.knowledge_id == knowledge_id) - .all() ) + files = result.scalars().all() return [FileModel.model_validate(file) for file in files] except Exception: return [] - def get_file_metadatas_by_id(self, knowledge_id: str, db: Optional[Session] = None) -> list[FileMetadataResponse]: + async def get_file_metadatas_by_id( + self, knowledge_id: str, db: Optional[AsyncSession] = None + ) -> list[FileMetadataResponse]: try: - with get_db_context(db) as db: - files = self.get_files_by_id(knowledge_id, db=db) - return [FileMetadataResponse(**file.model_dump()) for file in files] + files = await self.get_files_by_id(knowledge_id, db=db) + return [FileMetadataResponse(**file.model_dump()) for file in files] except Exception: return [] - def add_file_to_knowledge_by_id( + async def add_file_to_knowledge_by_id( self, knowledge_id: str, file_id: str, user_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[KnowledgeFileModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: knowledge_file = KnowledgeFileModel( **{ 'id': str(uuid.uuid4()), @@ -540,8 +569,8 @@ def add_file_to_knowledge_by_id( try: result = KnowledgeFile(**knowledge_file.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return KnowledgeFileModel.model_validate(result) else: @@ -549,103 +578,107 @@ def add_file_to_knowledge_by_id( except Exception: return None - def has_file(self, knowledge_id: str, file_id: str, db: Optional[Session] = None) -> bool: + async def has_file(self, knowledge_id: str, file_id: str, db: Optional[AsyncSession] = None) -> bool: """Check whether a file belongs to a knowledge base.""" try: - with get_db_context(db) as db: - return db.query(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).first() is not None + async with get_async_db_context(db) as db: + result = await db.execute( + select(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).limit(1) + ) + return result.scalars().first() is not None except Exception: return False - def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str, db: Optional[Session] = None) -> bool: + async def remove_file_from_knowledge_by_id( + self, knowledge_id: str, file_id: str, db: Optional[AsyncSession] = None + ) -> bool: try: - with get_db_context(db) as db: - db.query(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id)) + await db.commit() return True except Exception: return False - def reset_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> Optional[KnowledgeModel]: + async def reset_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Delete all knowledge_file entries for this knowledge_id - db.query(KnowledgeFile).filter_by(knowledge_id=id).delete() - db.commit() + await db.execute(delete(KnowledgeFile).filter_by(knowledge_id=id)) + await db.commit() # Update the knowledge entry's updated_at timestamp - db.query(Knowledge).filter_by(id=id).update( - { - 'updated_at': int(time.time()), - } - ) - db.commit() + await db.execute(update(Knowledge).filter_by(id=id).values(updated_at=int(time.time()))) + await db.commit() - return self.get_knowledge_by_id(id=id, db=db) + return await self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def update_knowledge_by_id( + async def update_knowledge_by_id( self, id: str, form_data: KnowledgeForm, overwrite: bool = False, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[KnowledgeModel]: try: - with get_db_context(db) as db: - knowledge = self.get_knowledge_by_id(id=id, db=db) - db.query(Knowledge).filter_by(id=id).update( - { + async with get_async_db_context(db) as db: + await db.execute( + update(Knowledge) + .filter_by(id=id) + .values( **form_data.model_dump(exclude={'access_grants'}), - 'updated_at': int(time.time()), - } + updated_at=int(time.time()), + ) ) - db.commit() + await db.commit() if form_data.access_grants is not None: - AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) - return self.get_knowledge_by_id(id=id, db=db) + await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) + return await self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def update_knowledge_data_by_id( - self, id: str, data: dict, db: Optional[Session] = None + async def update_knowledge_data_by_id( + self, id: str, data: dict, db: Optional[AsyncSession] = None ) -> Optional[KnowledgeModel]: try: - with get_db_context(db) as db: - knowledge = self.get_knowledge_by_id(id=id, db=db) - db.query(Knowledge).filter_by(id=id).update( - { - 'data': data, - 'updated_at': int(time.time()), - } + async with get_async_db_context(db) as db: + await db.execute( + update(Knowledge) + .filter_by(id=id) + .values( + data=data, + updated_at=int(time.time()), + ) ) - db.commit() - return self.get_knowledge_by_id(id=id, db=db) + await db.commit() + return await self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('knowledge', id, db=db) - db.query(Knowledge).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('knowledge', id, db=db) + await db.execute(delete(Knowledge).filter_by(id=id)) + await db.commit() return True except Exception: return False - def delete_all_knowledge(self, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_all_knowledge(self, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()] + result = await db.execute(select(Knowledge.id)) + knowledge_ids = [row[0] for row in result.all()] for knowledge_id in knowledge_ids: - AccessGrants.revoke_all_access('knowledge', knowledge_id, db=db) - db.query(Knowledge).delete() - db.commit() + await AccessGrants.revoke_all_access('knowledge', knowledge_id, db=db) + await db.execute(delete(Knowledge)) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index 17d96adc0ea..1ec52eeb6a3 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -2,13 +2,16 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db, get_db_context +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text #################### # Memory DB Schema +# What was learned at cost should not need to be paid +# for again. Let the memory hold. #################### @@ -16,7 +19,7 @@ class Memory(Base): __tablename__ = 'memory' id = Column(String, primary_key=True, unique=True) - user_id = Column(String) + user_id = Column(String, index=True) content = Column(Text) updated_at = Column(BigInteger) created_at = Column(BigInteger) @@ -38,13 +41,13 @@ class MemoryModel(BaseModel): class MemoriesTable: - def insert_new_memory( + async def insert_new_memory( self, user_id: str, content: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[MemoryModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: id = str(uuid.uuid4()) memory = MemoryModel( @@ -58,90 +61,92 @@ def insert_new_memory( ) result = Memory(**memory.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return MemoryModel.model_validate(result) else: return None - def update_memory_by_id_and_user_id( + async def update_memory_by_id_and_user_id( self, id: str, user_id: str, content: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[MemoryModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: - memory = db.get(Memory, id) + memory = await db.get(Memory, id) if not memory or memory.user_id != user_id: return None memory.content = content memory.updated_at = int(time.time()) - db.commit() - db.refresh(memory) + await db.commit() + await db.refresh(memory) return MemoryModel.model_validate(memory) except Exception: return None - def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]: - with get_db_context(db) as db: + async def get_memories(self, db: Optional[AsyncSession] = None) -> list[MemoryModel]: + async with get_async_db_context(db) as db: try: - memories = db.query(Memory).all() + result = await db.execute(select(Memory)) + memories = result.scalars().all() return [MemoryModel.model_validate(memory) for memory in memories] except Exception: return None - def get_memories_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[MemoryModel]: - with get_db_context(db) as db: + async def get_memories_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[MemoryModel]: + async with get_async_db_context(db) as db: try: - memories = db.query(Memory).filter_by(user_id=user_id).all() + result = await db.execute(select(Memory).filter_by(user_id=user_id)) + memories = result.scalars().all() return [MemoryModel.model_validate(memory) for memory in memories] except Exception: return None - def get_memory_by_id(self, id: str, db: Optional[Session] = None) -> Optional[MemoryModel]: - with get_db_context(db) as db: + async def get_memory_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[MemoryModel]: + async with get_async_db_context(db) as db: try: - memory = db.get(Memory, id) - return MemoryModel.model_validate(memory) + memory = await db.get(Memory, id) + return MemoryModel.model_validate(memory) if memory else None except Exception: return None - def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_memory_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(Memory).filter_by(id=id).delete() - db.commit() + await db.execute(delete(Memory).filter_by(id=id)) + await db.commit() return True except Exception: return False - def delete_memories_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_memories_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - db.query(Memory).filter_by(user_id=user_id).delete() - db.commit() + await db.execute(delete(Memory).filter_by(user_id=user_id)) + await db.commit() return True except Exception: return False - def delete_memory_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: + async def delete_memory_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: try: - memory = db.get(Memory, id) + memory = await db.get(Memory, id) if not memory or memory.user_id != user_id: return None # Delete the memory - db.delete(memory) - db.commit() + await db.delete(memory) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 034eaac1604..7f33a72effa 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -3,8 +3,9 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.users import Users, User, UserNameResponse from open_webui.models.channels import Channels, ChannelMember @@ -12,7 +13,7 @@ from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON -from sqlalchemy import or_, func, select, and_, text +from sqlalchemy import or_, func, and_, text from sqlalchemy.sql import exists #################### @@ -137,15 +138,15 @@ class MessageResponse(MessageReplyToResponse): class MessageTable: - def insert_new_message( + async def insert_new_message( self, form_data: MessageForm, channel_id: str, user_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[MessageModel]: - with get_db_context(db) as db: - channel_member = Channels.join_channel(channel_id, user_id) + async with get_async_db_context(db) as db: + channel_member = await Channels.join_channel(channel_id, user_id) id = str(uuid.uuid4()) ts = int(time.time_ns()) @@ -170,38 +171,38 @@ def insert_new_message( result = Message(**message.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) return MessageModel.model_validate(result) if result else None - def get_message_by_id( + async def get_message_by_id( self, id: str, include_thread_replies: Optional[bool] = True, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[MessageResponse]: - with get_db_context(db) as db: - message = db.get(Message, id) + async with get_async_db_context(db) as db: + message = await db.get(Message, id) if not message: return None reply_to_message = ( - self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) + await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - reactions = self.get_reactions_by_message_id(id, db=db) + reactions = await self.get_reactions_by_message_id(id, db=db) thread_replies = [] if include_thread_replies: - thread_replies = self.get_thread_replies_by_message_id(id, db=db) + thread_replies = await self.get_thread_replies_by_message_id(id, db=db) # Check if message was sent by webhook (webhook info in meta takes precedence) webhook_info = message.meta.get('webhook') if message.meta else None if webhook_info and webhook_info.get('id'): # Look up webhook by ID to get current name - webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) + webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: user_info = { 'id': webhook.id, @@ -216,7 +217,7 @@ def get_message_by_id( 'role': 'webhook', } else: - user = Users.get_user_by_id(message.user_id, db=db) + user = await Users.get_user_by_id(message.user_id, db=db) user_info = user.model_dump() if user else None return MessageResponse.model_validate( @@ -230,34 +231,41 @@ def get_message_by_id( } ) - def get_thread_replies_by_message_id(self, id: str, db: Optional[Session] = None) -> list[MessageReplyToResponse]: - with get_db_context(db) as db: - all_messages = db.query(Message).filter_by(parent_id=id).order_by(Message.created_at.desc()).all() + async def _resolve_user_info(self, message: Message, db: AsyncSession) -> Optional[dict]: + """Resolve user info from message, handling webhook messages.""" + webhook_info = message.meta.get('webhook') if message.meta else None + if webhook_info and webhook_info.get('id'): + webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db) + if webhook: + return { + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', + } + else: + return { + 'id': webhook_info.get('id'), + 'name': 'Deleted Webhook', + 'role': 'webhook', + } + return None + + async def get_thread_replies_by_message_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> list[MessageReplyToResponse]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Message).filter_by(parent_id=id).order_by(Message.created_at.desc())) + all_messages = result.scalars().all() messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) + await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get('webhook') if message.meta else None - user_info = None - if webhook_info and webhook_info.get('id'): - webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) - if webhook: - user_info = { - 'id': webhook.id, - 'name': webhook.name, - 'role': 'webhook', - } - else: - user_info = { - 'id': webhook_info.get('id'), - 'name': 'Deleted Webhook', - 'role': 'webhook', - } + user_info = await self._resolve_user_info(message, db) messages.append( MessageReplyToResponse.model_validate( @@ -270,51 +278,37 @@ def get_thread_replies_by_message_id(self, id: str, db: Optional[Session] = None ) return messages - def get_reply_user_ids_by_message_id(self, id: str, db: Optional[Session] = None) -> list[str]: - with get_db_context(db) as db: - return [message.user_id for message in db.query(Message).filter_by(parent_id=id).all()] + async def get_reply_user_ids_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Message.user_id).filter_by(parent_id=id)) + return [row[0] for row in result.all()] - def get_messages_by_channel_id( + async def get_messages_by_channel_id( self, channel_id: str, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[MessageReplyToResponse]: - with get_db_context(db) as db: - all_messages = ( - db.query(Message) + async with get_async_db_context(db) as db: + result = await db.execute( + select(Message) .filter_by(channel_id=channel_id, parent_id=None) .order_by(Message.created_at.desc()) .offset(skip) .limit(limit) - .all() ) + all_messages = result.scalars().all() messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) + await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get('webhook') if message.meta else None - user_info = None - if webhook_info and webhook_info.get('id'): - webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) - if webhook: - user_info = { - 'id': webhook.id, - 'name': webhook.name, - 'role': 'webhook', - } - else: - user_info = { - 'id': webhook_info.get('id'), - 'name': 'Deleted Webhook', - 'role': 'webhook', - } + user_info = await self._resolve_user_info(message, db) messages.append( MessageReplyToResponse.model_validate( @@ -327,28 +321,28 @@ def get_messages_by_channel_id( ) return messages - def get_messages_by_parent_id( + async def get_messages_by_parent_id( self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[MessageReplyToResponse]: - with get_db_context(db) as db: - message = db.get(Message, parent_id) + async with get_async_db_context(db) as db: + message = await db.get(Message, parent_id) if not message: return [] - all_messages = ( - db.query(Message) + result = await db.execute( + select(Message) .filter_by(channel_id=channel_id, parent_id=parent_id) .order_by(Message.created_at.desc()) .offset(skip) .limit(limit) - .all() ) + all_messages = list(result.scalars().all()) # If length of all_messages is less than limit, then add the parent message if len(all_messages) < limit: @@ -357,27 +351,12 @@ def get_messages_by_parent_id( messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) + await self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get('webhook') if message.meta else None - user_info = None - if webhook_info and webhook_info.get('id'): - webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) - if webhook: - user_info = { - 'id': webhook.id, - 'name': webhook.name, - 'role': 'webhook', - } - else: - user_info = { - 'id': webhook_info.get('id'), - 'name': 'Deleted Webhook', - 'role': 'webhook', - } + user_info = await self._resolve_user_info(message, db) messages.append( MessageReplyToResponse.model_validate( @@ -390,34 +369,39 @@ def get_messages_by_parent_id( ) return messages - def get_last_message_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> Optional[MessageModel]: - with get_db_context(db) as db: - message = db.query(Message).filter_by(channel_id=channel_id).order_by(Message.created_at.desc()).first() + async def get_last_message_by_channel_id( + self, channel_id: str, db: Optional[AsyncSession] = None + ) -> Optional[MessageModel]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Message).filter_by(channel_id=channel_id).order_by(Message.created_at.desc()).limit(1) + ) + message = result.scalars().first() return MessageModel.model_validate(message) if message else None - def get_pinned_messages_by_channel_id( + async def get_pinned_messages_by_channel_id( self, channel_id: str, skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[MessageModel]: - with get_db_context(db) as db: - all_messages = ( - db.query(Message) + async with get_async_db_context(db) as db: + result = await db.execute( + select(Message) .filter_by(channel_id=channel_id, is_pinned=True) .order_by(Message.pinned_at.desc()) .offset(skip) .limit(limit) - .all() ) + all_messages = result.scalars().all() return [MessageModel.model_validate(message) for message in all_messages] - def update_message_by_id( - self, id: str, form_data: MessageForm, db: Optional[Session] = None + async def update_message_by_id( + self, id: str, form_data: MessageForm, db: Optional[AsyncSession] = None ) -> Optional[MessageModel]: - with get_db_context(db) as db: - message = db.get(Message, id) + async with get_async_db_context(db) as db: + message = await db.get(Message, id) message.content = form_data.content message.data = { **(message.data if message.data else {}), @@ -428,49 +412,51 @@ def update_message_by_id( **(form_data.meta if form_data.meta else {}), } message.updated_at = int(time.time_ns()) - db.commit() - db.refresh(message) + await db.commit() + await db.refresh(message) return MessageModel.model_validate(message) if message else None - def update_is_pinned_by_id( + async def update_is_pinned_by_id( self, id: str, is_pinned: bool, pinned_by: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[MessageModel]: - with get_db_context(db) as db: - message = db.get(Message, id) + async with get_async_db_context(db) as db: + message = await db.get(Message, id) message.is_pinned = is_pinned message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_by = pinned_by if is_pinned else None - db.commit() - db.refresh(message) + await db.commit() + await db.refresh(message) return MessageModel.model_validate(message) if message else None - def get_unread_message_count( + async def get_unread_message_count( self, channel_id: str, user_id: str, last_read_at: Optional[int] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> int: - with get_db_context(db) as db: - query = db.query(Message).filter( + async with get_async_db_context(db) as db: + stmt = select(func.count(Message.id)).filter( Message.channel_id == channel_id, Message.parent_id == None, # only count top-level messages Message.created_at > (last_read_at if last_read_at else 0), ) if user_id: - query = query.filter(Message.user_id != user_id) - return query.count() + stmt = stmt.filter(Message.user_id != user_id) + result = await db.execute(stmt) + return result.scalar() - def add_reaction_to_message( - self, id: str, user_id: str, name: str, db: Optional[Session] = None + async def add_reaction_to_message( + self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None ) -> Optional[MessageReactionModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # check for existing reaction - existing_reaction = db.query(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name).first() + result = await db.execute(select(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name)) + existing_reaction = result.scalars().first() if existing_reaction: return MessageReactionModel.model_validate(existing_reaction) @@ -484,19 +470,19 @@ def add_reaction_to_message( ) result = MessageReaction(**reaction.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) return MessageReactionModel.model_validate(result) if result else None - def get_reactions_by_message_id(self, id: str, db: Optional[Session] = None) -> list[Reactions]: - with get_db_context(db) as db: + async def get_reactions_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[Reactions]: + async with get_async_db_context(db) as db: # JOIN User so all user info is fetched in one query - results = ( - db.query(MessageReaction, User) + result = await db.execute( + select(MessageReaction, User) .join(User, MessageReaction.user_id == User.id) .filter(MessageReaction.message_id == id) - .all() ) + results = result.all() reactions = {} @@ -518,58 +504,60 @@ def get_reactions_by_message_id(self, id: str, db: Optional[Session] = None) -> return [Reactions(**reaction) for reaction in reactions.values()] - def remove_reaction_by_id_and_user_id_and_name( - self, id: str, user_id: str, name: str, db: Optional[Session] = None + async def remove_reaction_by_id_and_user_id_and_name( + self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None ) -> bool: - with get_db_context(db) as db: - db.query(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name)) + await db.commit() return True - def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - db.query(MessageReaction).filter_by(message_id=id).delete() - db.commit() + async def delete_reactions_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + await db.execute(delete(MessageReaction).filter_by(message_id=id)) + await db.commit() return True - def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - db.query(Message).filter_by(parent_id=id).delete() - db.commit() + async def delete_replies_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + await db.execute(delete(Message).filter_by(parent_id=id)) + await db.commit() return True - def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - db.query(Message).filter_by(id=id).delete() + async def delete_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + await db.execute(delete(Message).filter_by(id=id)) # Delete all reactions to this message - db.query(MessageReaction).filter_by(message_id=id).delete() + await db.execute(delete(MessageReaction).filter_by(message_id=id)) - db.commit() + await db.commit() return True - def search_messages_by_channel_ids( + async def search_messages_by_channel_ids( self, channel_ids: list[str], query: str, start_timestamp: Optional[int] = None, end_timestamp: Optional[int] = None, limit: int = 10, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[MessageModel]: """Search messages in specified channels by content.""" - with get_db_context(db) as db: - query_builder = db.query(Message).filter( + async with get_async_db_context(db) as db: + stmt = select(Message).filter( Message.channel_id.in_(channel_ids), Message.content.ilike(f'%{query}%'), ) if start_timestamp: - query_builder = query_builder.filter(Message.created_at >= start_timestamp) + stmt = stmt.filter(Message.created_at >= start_timestamp) if end_timestamp: - query_builder = query_builder.filter(Message.created_at <= end_timestamp) + stmt = stmt.filter(Message.created_at <= end_timestamp) - messages = query_builder.order_by(Message.created_at.desc()).limit(limit).all() + stmt = stmt.order_by(Message.created_at.desc()).limit(limit) + result = await db.execute(stmt) + messages = result.scalars().all() return [MessageModel.model_validate(msg) for msg in messages] diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index c48847b7029..79c13153ac8 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -1,19 +1,18 @@ +import json import logging import time from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update, or_, func, String, cast +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, Users, UserResponse from open_webui.models.access_grants import AccessGrantModel, AccessGrants -from pydantic import BaseModel, ConfigDict, Field - -from sqlalchemy import String, cast, or_, and_, func -from sqlalchemy.dialects import postgresql, sqlite +from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy import BigInteger, Column, Text, Boolean @@ -23,6 +22,8 @@ #################### # Models DB Schema +# A misconfigured model wastes the time of everyone +# who trusts it. Let what is set here be set with care. #################### @@ -45,7 +46,20 @@ class ModelMeta(BaseModel): model_config = ConfigDict(extra='allow') - pass + @model_validator(mode='before') + @classmethod + def normalize_tags(cls, data): + if isinstance(data, dict) and 'tags' in data: + raw_tags = data['tags'] + if isinstance(raw_tags, list): + normalized = [] + for tag in raw_tags: + if isinstance(tag, str): + normalized.append({'name': tag}) + elif isinstance(tag, dict) and 'name' in tag: + normalized.append(tag) + data['tags'] = normalized + return data class Model(Base): @@ -129,6 +143,8 @@ class ModelAccessListResponse(BaseModel): class ModelForm(BaseModel): + model_config = ConfigDict(extra='ignore') + id: str base_model_id: Optional[str] = None name: str @@ -139,26 +155,26 @@ class ModelForm(BaseModel): class ModelsTable: - def _get_access_grants(self, model_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('model', model_id, db=db) + async def _get_access_grants(self, model_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('model', model_id, db=db) - def _to_model_model( + async def _to_model_model( self, model: Model, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> ModelModel: model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'}) model_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(model_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db) ) return ModelModel.model_validate(model_data) - def insert_new_model( - self, form_data: ModelForm, user_id: str, db: Optional[Session] = None + async def insert_new_model( + self, form_data: ModelForm, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[ModelModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: result = Model( **{ **form_data.model_dump(exclude={'access_grants'}), @@ -168,37 +184,40 @@ def insert_new_model( } ) db.add(result) - db.commit() - db.refresh(result) - AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) + await db.commit() + await db.refresh(result) + await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) if result: - return self._to_model_model(result, db=db) + return await self._to_model_model(result, db=db) else: return None except Exception as e: log.exception(f'Failed to insert a new model: {e}') return None - def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]: - with get_db_context(db) as db: - all_models = db.query(Model).all() + async def get_all_models(self, db: Optional[AsyncSession] = None) -> list[ModelModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Model)) + all_models = result.scalars().all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) for model in all_models + await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) + for model in all_models ] - def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: - with get_db_context(db) as db: - all_models = db.query(Model).filter(Model.base_model_id != None).all() + async def get_models(self, db: Optional[AsyncSession] = None) -> list[ModelUserResponse]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Model).filter(Model.base_model_id != None)) + all_models = result.scalars().all() user_ids = list(set(model.user_id for model in all_models)) model_ids = [model.id for model in all_models] - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) models = [] for model in all_models: @@ -206,10 +225,12 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: models.append( ModelUserResponse.model_validate( { - **self._to_model_model( - model, - access_grants=grants_map.get(model.id, []), - db=db, + **( + await self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ) ).model_dump(), 'user': user.model_dump() if user else None, } @@ -217,33 +238,38 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: ) return models - def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]: - with get_db_context(db) as db: - all_models = db.query(Model).filter(Model.base_model_id == None).all() + async def get_base_models(self, db: Optional[AsyncSession] = None) -> list[ModelModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Model).filter(Model.base_model_id == None)) + all_models = result.scalars().all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) for model in all_models + await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) + for model in all_models ] - def get_models_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[Session] = None + async def get_models_by_user_id( + self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None ) -> list[ModelUserResponse]: - models = self.get_models(db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - return [ - model - for model in models - if model.user_id == user_id - or AccessGrants.has_access( + models = await self.get_models(db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + result = [] + for model in models: + if model.user_id == user_id: + result.append(model) + elif await AccessGrants.has_access( user_id=user_id, resource_type='model', resource_id=model.id, permission=permission, user_group_ids=user_group_ids, db=db, - ) - ] + ): + result.append(model) + return result def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( @@ -255,23 +281,22 @@ def _has_permission(self, db, query, filter: dict, permission: str = 'read'): permission=permission, ) - def search_models( + async def search_models( self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> ModelListResponse: - with get_db_context(db) as db: - # Join GroupMember so we can order by group_id when requested - query = db.query(Model, User).outerjoin(User, User.id == Model.user_id) - query = query.filter(Model.base_model_id != None) + async with get_async_db_context(db) as db: + stmt = select(Model, User).outerjoin(User, User.id == Model.user_id) + stmt = stmt.filter(Model.base_model_id != None) if filter: query_key = filter.get('query') if query_key: - query = query.filter( + stmt = stmt.filter( or_( Model.name.ilike(f'%{query_key}%'), Model.base_model_id.ilike(f'%{query_key}%'), @@ -283,69 +308,82 @@ def search_models( view_option = filter.get('view_option') if view_option == 'created': - query = query.filter(Model.user_id == user_id) + stmt = stmt.filter(Model.user_id == user_id) elif view_option == 'shared': - query = query.filter(Model.user_id != user_id) + stmt = stmt.filter(Model.user_id != user_id) # Apply access control filtering - query = self._has_permission( + stmt = self._has_permission( db, - query, + stmt, filter, permission='read', ) tag = filter.get('tag') if tag: - # TODO: This is a simple implementation and should be improved for performance - like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array - meta_text = func.lower(cast(Model.meta, String)) - - query = query.filter(meta_text.like(like_pattern)) + # SQLite stores JSON text via json.dumps(ensure_ascii=True), + # so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB + # stores literal Unicode. Use the right pattern for each. + if db.bind.dialect.name == 'sqlite': + if tag.isascii(): + meta_text = func.lower(cast(Model.meta, String)) + pattern = f'%{json.dumps(tag.lower())}%' + else: + meta_text = cast(Model.meta, String) + pattern = f'%{json.dumps(tag)}%' + else: + meta_text = func.lower(cast(Model.meta, String)) + pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%' + stmt = stmt.filter(meta_text.like(pattern)) order_by = filter.get('order_by') direction = filter.get('direction') if order_by == 'name': if direction == 'asc': - query = query.order_by(Model.name.asc()) + stmt = stmt.order_by(Model.name.asc()) else: - query = query.order_by(Model.name.desc()) + stmt = stmt.order_by(Model.name.desc()) elif order_by == 'created_at': if direction == 'asc': - query = query.order_by(Model.created_at.asc()) + stmt = stmt.order_by(Model.created_at.asc()) else: - query = query.order_by(Model.created_at.desc()) + stmt = stmt.order_by(Model.created_at.desc()) elif order_by == 'updated_at': if direction == 'asc': - query = query.order_by(Model.updated_at.asc()) + stmt = stmt.order_by(Model.updated_at.asc()) else: - query = query.order_by(Model.updated_at.desc()) + stmt = stmt.order_by(Model.updated_at.desc()) else: - query = query.order_by(Model.created_at.desc()) + stmt = stmt.order_by(Model.created_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() model_ids = [model.id for model, _ in items] - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) models = [] for model, user in items: models.append( ModelUserResponse( - **self._to_model_model( - model, - access_grants=grants_map.get(model.id, []), - db=db, + **( + await self._to_model_model( + model, + access_grants=grants_map.get(model.id, []), + db=db, + ) ).model_dump(), user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) @@ -353,22 +391,69 @@ def search_models( return ModelListResponse(items=models, total=total) - def get_model_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ModelModel]: + async def get_model_meta_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[tuple[dict, int]]: + """Return (meta, updated_at) for a model, skipping access grant resolution.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Model.meta, Model.updated_at).filter_by(id=id)) + return result.first() + except Exception: + return None + + async def get_all_tags( + self, + user_id: str, + is_admin: bool = False, + db: Optional[AsyncSession] = None, + ) -> set[str]: + """Extract unique tag names from model meta, querying only the meta column.""" + async with get_async_db_context(db) as db: + stmt = select(Model.meta).filter(Model.base_model_id != None) + + if not is_admin: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + filter_dict = {'user_id': user_id} + if user_group_ids: + filter_dict['group_ids'] = user_group_ids + + stmt = self._has_permission(db, stmt, filter_dict, permission='read') + + result = await db.execute(stmt) + rows = result.scalars().all() + + tags_set: set[str] = set() + for meta in rows: + if not meta: + continue + for tag in meta.get('tags', []): + try: + name = tag.get('name') if isinstance(tag, dict) else str(tag) + if name: + tags_set.add(name) + except Exception: + continue + + return tags_set + + async def get_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]: try: - with get_db_context(db) as db: - model = db.get(Model, id) - return self._to_model_model(model, db=db) if model else None + async with get_async_db_context(db) as db: + model = await db.get(Model, id) + return await self._to_model_model(model, db=db) if model else None except Exception: return None - def get_models_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[ModelModel]: + async def get_models_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[ModelModel]: try: - with get_db_context(db) as db: - models = db.query(Model).filter(Model.id.in_(ids)).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(Model).filter(Model.id.in_(ids))) + models = result.scalars().all() model_ids = [model.id for model in models] - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model( + await self._to_model_model( model, access_grants=grants_map.get(model.id, []), db=db, @@ -378,67 +463,90 @@ def get_models_by_ids(self, ids: list[str], db: Optional[Session] = None) -> lis except Exception: return [] - def toggle_model_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ModelModel]: - with get_db_context(db) as db: + async def toggle_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]: + async with get_async_db_context(db) as db: try: - model = db.query(Model).filter_by(id=id).first() + result = await db.execute(select(Model).filter_by(id=id)) + model = result.scalars().first() if not model: return None model.is_active = not model.is_active model.updated_at = int(time.time()) - db.commit() - db.refresh(model) + await db.commit() + await db.refresh(model) - return self._to_model_model(model, db=db) + return await self._to_model_model(model, db=db) except Exception: return None - def update_model_by_id(self, id: str, model: ModelForm, db: Optional[Session] = None) -> Optional[ModelModel]: + async def update_model_by_id( + self, id: str, model: ModelForm, db: Optional[AsyncSession] = None + ) -> Optional[ModelModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # update only the fields that are present in the model data = model.model_dump(exclude={'id', 'access_grants'}) - result = db.query(Model).filter_by(id=id).update(data) + data['updated_at'] = int(time.time()) + await db.execute(update(Model).filter_by(id=id).values(**data)) - db.commit() + await db.commit() if model.access_grants is not None: - AccessGrants.set_access_grants('model', id, model.access_grants, db=db) + await AccessGrants.set_access_grants('model', id, model.access_grants, db=db) - return self.get_model_by_id(id, db=db) + return await self.get_model_by_id(id, db=db) except Exception as e: log.exception(f'Failed to update the model by id {id}: {e}') return None - def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def update_model_updated_at_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]: try: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('model', id, db=db) - db.query(Model).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + result = await db.execute(select(Model).filter_by(id=id)) + model_obj = result.scalars().first() + if not model_obj: + return None + model_obj.updated_at = int(time.time()) + await db.commit() + await db.refresh(model_obj) + return await self._to_model_model(model_obj, db=db) + except Exception as e: + log.exception(f'Failed to update the model updated_at by id {id}: {e}') + return None + + async def delete_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('model', id, db=db) + await db.execute(delete(Model).filter_by(id=id)) + await db.commit() return True except Exception: return False - def delete_all_models(self, db: Optional[Session] = None) -> bool: + async def delete_all_models(self, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - model_ids = [row[0] for row in db.query(Model.id).all()] + async with get_async_db_context(db) as db: + result = await db.execute(select(Model.id)) + model_ids = [row[0] for row in result.all()] for model_id in model_ids: - AccessGrants.revoke_all_access('model', model_id, db=db) - db.query(Model).delete() - db.commit() + await AccessGrants.revoke_all_access('model', model_id, db=db) + await db.execute(delete(Model)) + await db.commit() return True except Exception: return False - def sync_models(self, user_id: str, models: list[ModelModel], db: Optional[Session] = None) -> list[ModelModel]: + async def sync_models( + self, user_id: str, models: list[ModelModel], db: Optional[AsyncSession] = None + ) -> list[ModelModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Get existing models - existing_models = db.query(Model).all() + result = await db.execute(select(Model)) + existing_models = result.scalars().all() existing_ids = {model.id for model in existing_models} # Prepare a set of new model IDs @@ -447,12 +555,14 @@ def sync_models(self, user_id: str, models: list[ModelModel], db: Optional[Sessi # Update or insert models for model in models: if model.id in existing_ids: - db.query(Model).filter_by(id=model.id).update( - { + await db.execute( + update(Model) + .filter_by(id=model.id) + .values( **model.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'updated_at': int(time.time()), - } + user_id=user_id, + updated_at=int(time.time()), + ) ) else: new_model = Model( @@ -463,21 +573,22 @@ def sync_models(self, user_id: str, models: list[ModelModel], db: Optional[Sessi } ) db.add(new_model) - AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) + await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) # Remove models that are no longer present for model in existing_models: if model.id not in new_model_ids: - AccessGrants.revoke_all_access('model', model.id, db=db) - db.delete(model) + await AccessGrants.revoke_all_access('model', model.id, db=db) + await db.delete(model) - db.commit() + await db.commit() - all_models = db.query(Model).all() + result = await db.execute(select(Model)) + all_models = result.scalars().all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model( + await self._to_model_model( model, access_grants=grants_map.get(model.id, []), db=db, diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 34749f5f6cb..f651d226cad 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -4,16 +4,16 @@ from typing import Optional from functools import lru_cache -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db, get_db_context +from sqlalchemy import Boolean, select, delete, update, or_, func, cast +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, Users, UserResponse from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Column, Text, JSON -from sqlalchemy import or_, func, cast +from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey #################### # Note DB Schema @@ -43,6 +43,7 @@ class NoteModel(BaseModel): title: str data: Optional[dict] = None meta: Optional[dict] = None + is_pinned: Optional[bool] = False access_grants: list[AccessGrantModel] = Field(default_factory=list) @@ -50,6 +51,15 @@ class NoteModel(BaseModel): updated_at: int # timestamp in epoch +class PinnedNote(Base): + __tablename__ = 'pinned_note' + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + note_id = Column(Text, ForeignKey('note.id', ondelete='CASCADE'), nullable=False) + created_at = Column(BigInteger, nullable=False) + + #################### # Forms #################### @@ -77,6 +87,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -88,18 +99,19 @@ class NoteListResponse(BaseModel): class NoteTable: - def _get_access_grants(self, note_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('note', note_id, db=db) + async def _get_access_grants(self, note_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('note', note_id, db=db) - def _to_note_model( + async def _to_note_model( self, note: Note, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> NoteModel: + # We exclude access_grants to inject them note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'}) note_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(note_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db) ) return NoteModel.model_validate(note_data) @@ -113,8 +125,10 @@ def _has_permission(self, db, query, filter: dict, permission: str = 'read'): permission=permission, ) - def insert_new_note(self, user_id: str, form_data: NoteForm, db: Optional[Session] = None) -> Optional[NoteModel]: - with get_db_context(db) as db: + async def insert_new_note( + self, user_id: str, form_data: NoteForm, db: Optional[AsyncSession] = None + ) -> Optional[NoteModel]: + async with get_async_db_context(db) as db: note = NoteModel( **{ 'id': str(uuid.uuid4()), @@ -126,56 +140,61 @@ def insert_new_note(self, user_id: str, form_data: NoteForm, db: Optional[Sessio } ) - new_note = Note(**note.model_dump(exclude={'access_grants'})) + new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'})) db.add(new_note) - db.commit() - AccessGrants.set_access_grants('note', note.id, form_data.access_grants, db=db) - return self._to_note_model(new_note, db=db) + await db.commit() + await AccessGrants.set_access_grants('note', note.id, form_data.access_grants, db=db) + return await self._to_note_model(new_note, db=db) - def get_notes(self, skip: int = 0, limit: int = 50, db: Optional[Session] = None) -> list[NoteModel]: - with get_db_context(db) as db: - query = db.query(Note).order_by(Note.updated_at.desc()) + async def get_notes(self, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None) -> list[NoteModel]: + async with get_async_db_context(db) as db: + stmt = select(Note).order_by(Note.updated_at.desc()) if skip is not None: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit is not None: - query = query.limit(limit) - notes = query.all() + stmt = stmt.limit(limit) + result = await db.execute(stmt) + notes = result.scalars().all() note_ids = [note.id for note in notes] - grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) - return [self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] - def search_notes( + async def search_notes( self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> NoteListResponse: - with get_db_context(db) as db: - query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) + async with get_async_db_context(db) as db: + stmt = select(Note, User).outerjoin(User, User.id == Note.user_id) if filter: query_key = filter.get('query') if query_key: - # Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do") - normalized_query = query_key.replace('-', '').replace(' ', '') - query = query.filter( - or_( - func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{normalized_query}%'), - func.replace( - func.replace(cast(Note.data['content']['md'], Text), '-', ''), - ' ', - '', - ).ilike(f'%{normalized_query}%'), + # Split query into individual words and normalize each + # (strip hyphens so "todo" matches "to-do"). + # All words must match somewhere in title OR content (AND semantics). + search_words = query_key.split() + normalized_words = [w.replace('-', '') for w in search_words if w.replace('-', '')] + for word in normalized_words: + stmt = stmt.filter( + or_( + func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'), + func.replace( + func.replace(cast(Note.data['content']['md'], Text), '-', ''), + ' ', + '', + ).ilike(f'%{word}%'), + ) ) - ) view_option = filter.get('view_option') if view_option == 'created': - query = query.filter(Note.user_id == user_id) + stmt = stmt.filter(Note.user_id == user_id) elif view_option == 'shared': - query = query.filter(Note.user_id != user_id) + stmt = stmt.filter(Note.user_id != user_id) # Apply access control filtering if 'permission' in filter: @@ -183,9 +202,9 @@ def search_notes( else: permission = 'write' - query = self._has_permission( + stmt = self._has_permission( db, - query, + stmt, filter, permission=permission, ) @@ -195,46 +214,50 @@ def search_notes( if order_by == 'name': if direction == 'asc': - query = query.order_by(Note.title.asc()) + stmt = stmt.order_by(Note.title.asc()) else: - query = query.order_by(Note.title.desc()) + stmt = stmt.order_by(Note.title.desc()) elif order_by == 'created_at': if direction == 'asc': - query = query.order_by(Note.created_at.asc()) + stmt = stmt.order_by(Note.created_at.asc()) else: - query = query.order_by(Note.created_at.desc()) + stmt = stmt.order_by(Note.created_at.desc()) elif order_by == 'updated_at': if direction == 'asc': - query = query.order_by(Note.updated_at.asc()) + stmt = stmt.order_by(Note.updated_at.asc()) else: - query = query.order_by(Note.updated_at.desc()) + stmt = stmt.order_by(Note.updated_at.desc()) else: - query = query.order_by(Note.updated_at.desc()) + stmt = stmt.order_by(Note.updated_at.desc()) else: - query = query.order_by(Note.updated_at.desc()) + stmt = stmt.order_by(Note.updated_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() note_ids = [note.id for note, _ in items] - grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) notes = [] for note, user in items: notes.append( NoteUserResponse( - **self._to_note_model( - note, - access_grants=grants_map.get(note.id, []), - db=db, + **( + await self._to_note_model( + note, + access_grants=grants_map.get(note.id, []), + db=db, + ) ).model_dump(), user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) @@ -242,40 +265,44 @@ def search_notes( return NoteListResponse(items=notes, total=total) - def get_notes_by_user_id( + async def get_notes_by_user_id( self, user_id: str, permission: str = 'read', skip: int = 0, limit: int = 50, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[NoteModel]: - with get_db_context(db) as db: - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] - query = db.query(Note).order_by(Note.updated_at.desc()) - query = self._has_permission(db, query, {'user_id': user_id, 'group_ids': user_group_ids}, permission) + stmt = select(Note).order_by(Note.updated_at.desc()) + stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission) if skip is not None: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit is not None: - query = query.limit(limit) + stmt = stmt.limit(limit) - notes = query.all() + result = await db.execute(stmt) + notes = result.scalars().all() note_ids = [note.id for note in notes] - grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) - return [self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] - def get_note_by_id(self, id: str, db: Optional[Session] = None) -> Optional[NoteModel]: - with get_db_context(db) as db: - note = db.query(Note).filter(Note.id == id).first() - return self._to_note_model(note, db=db) if note else None + async def get_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Note).filter(Note.id == id)) + note = result.scalars().first() + return await self._to_note_model(note, db=db) if note else None - def update_note_by_id( - self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None + async def update_note_by_id( + self, id: str, form_data: NoteUpdateForm, db: Optional[AsyncSession] = None ) -> Optional[NoteModel]: - with get_db_context(db) as db: - note = db.query(Note).filter(Note.id == id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Note).filter(Note.id == id)) + note = result.scalars().first() if not note: return None @@ -289,22 +316,79 @@ def update_note_by_id( note.meta = {**note.meta, **form_data['meta']} if 'access_grants' in form_data: - AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db) + await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db) note.updated_at = int(time.time_ns()) - db.commit() - return self._to_note_model(note, db=db) if note else None + await db.commit() + return await self._to_note_model(note, db=db) if note else None - def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def toggle_note_pinned_by_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[NoteModel]: try: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('note', id, db=db) - db.query(Note).filter(Note.id == id).delete() - db.commit() + async with get_async_db_context(db) as db: + result = await db.execute(select(Note).filter(Note.id == id)) + note = result.scalars().first() + if not note: + return None + + # Check if already pinned + pin_result = await db.execute(select(PinnedNote).filter_by(user_id=user_id, note_id=id)) + pinned_note = pin_result.scalars().first() + + if pinned_note: + await db.execute(delete(PinnedNote).filter_by(user_id=user_id, note_id=id)) + else: + new_pin = PinnedNote( + id=str(uuid.uuid4()), user_id=user_id, note_id=id, created_at=int(time.time_ns()) + ) + db.add(new_pin) + + await db.commit() + return await self._to_note_model(note, db=db) + except Exception: + return None + + async def get_pinned_notes_by_user_id( + self, + user_id: str, + permission: str = 'read', + db: Optional[AsyncSession] = None, + ) -> list[NoteModel]: + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + stmt = ( + select(Note) + .join(PinnedNote, PinnedNote.note_id == Note.id) + .filter(PinnedNote.user_id == user_id) + .order_by(PinnedNote.created_at.desc()) + ) + stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission) + + result = await db.execute(stmt) + notes = result.scalars().all() + note_ids = [note.id for note in notes] + grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] + + async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: + try: + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('note', id, db=db) + await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id)) + await db.execute(delete(Note).filter(Note.id == id)) + await db.commit() return True except Exception: return False + async def get_pinned_note_ids(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]: + async with get_async_db_context(db) as db: + result = await db.execute(select(PinnedNote.note_id).filter_by(user_id=user_id)) + return result.scalars().all() + Notes = NoteTable() diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index 868216164ac..c43567f6706 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -8,8 +8,9 @@ from cryptography.fernet import Fernet -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db, get_db_context +from sqlalchemy import select, delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY from pydantic import BaseModel, ConfigDict @@ -103,16 +104,16 @@ def _decrypt_token(self, token: str): log.error(f'Error decrypting tokens: {type(e).__name__}: {e}') raise - def create_session( + async def create_session( self, user_id: str, provider: str, token: dict, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[OAuthSessionModel]: """Create a new OAuth session""" try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: current_time = int(time.time()) id = str(uuid.uuid4()) @@ -122,98 +123,137 @@ def create_session( 'user_id': user_id, 'provider': provider, 'token': self._encrypt_token(token), - 'expires_at': token.get('expires_at'), + 'expires_at': token.get('expires_at') or int(time.time() + 3600), 'created_at': current_time, 'updated_at': current_time, } ) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: - db.expunge(result) # Detach so dict swap is never flushed - result.token = token # Return decrypted token - return OAuthSessionModel.model_validate(result) + # Make a copy of the model data before closing session + model = OAuthSessionModel( + id=result.id, + user_id=result.user_id, + provider=result.provider, + token=token, # Return decrypted token + expires_at=result.expires_at, + created_at=result.created_at, + updated_at=result.updated_at, + ) + return model else: return None except Exception as e: log.error(f'Error creating OAuth session: {e}') return None - def get_session_by_id(self, session_id: str, db: Optional[Session] = None) -> Optional[OAuthSessionModel]: + async def get_session_by_id( + self, session_id: str, db: Optional[AsyncSession] = None + ) -> Optional[OAuthSessionModel]: """Get OAuth session by ID""" try: - with get_db_context(db) as db: - session = db.query(OAuthSession).filter_by(id=session_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(OAuthSession).filter_by(id=session_id)) + session = result.scalars().first() if session: - db.expunge(session) - session.token = self._decrypt_token(session.token) - return OAuthSessionModel.model_validate(session) + return OAuthSessionModel( + id=session.id, + user_id=session.user_id, + provider=session.provider, + token=self._decrypt_token(session.token), + expires_at=session.expires_at, + created_at=session.created_at, + updated_at=session.updated_at, + ) return None except Exception as e: log.error(f'Error getting OAuth session by ID: {e}') return None - def get_session_by_id_and_user_id( - self, session_id: str, user_id: str, db: Optional[Session] = None + async def get_session_by_id_and_user_id( + self, session_id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[OAuthSessionModel]: """Get OAuth session by ID and user ID""" try: - with get_db_context(db) as db: - session = db.query(OAuthSession).filter_by(id=session_id, user_id=user_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(OAuthSession).filter_by(id=session_id, user_id=user_id)) + session = result.scalars().first() if session: - db.expunge(session) - session.token = self._decrypt_token(session.token) - return OAuthSessionModel.model_validate(session) + return OAuthSessionModel( + id=session.id, + user_id=session.user_id, + provider=session.provider, + token=self._decrypt_token(session.token), + expires_at=session.expires_at, + created_at=session.created_at, + updated_at=session.updated_at, + ) return None except Exception as e: log.error(f'Error getting OAuth session by ID: {e}') return None - def get_session_by_provider_and_user_id( - self, provider: str, user_id: str, db: Optional[Session] = None + async def get_session_by_provider_and_user_id( + self, provider: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[OAuthSessionModel]: """Get OAuth session by provider and user ID""" try: - with get_db_context(db) as db: - session = ( - db.query(OAuthSession) + async with get_async_db_context(db) as db: + result = await db.execute( + select(OAuthSession) .filter_by(provider=provider, user_id=user_id) .order_by(OAuthSession.created_at.desc()) - .first() ) + session = result.scalars().first() if session: - db.expunge(session) - session.token = self._decrypt_token(session.token) - return OAuthSessionModel.model_validate(session) + return OAuthSessionModel( + id=session.id, + user_id=session.user_id, + provider=session.provider, + token=self._decrypt_token(session.token), + expires_at=session.expires_at, + created_at=session.created_at, + updated_at=session.updated_at, + ) return None except Exception as e: log.error(f'Error getting OAuth session by provider and user ID: {e}') return None - def get_sessions_by_user_id(self, user_id: str, db: Optional[Session] = None) -> List[OAuthSessionModel]: + async def get_sessions_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> List[OAuthSessionModel]: """Get all OAuth sessions for a user""" try: - with get_db_context(db) as db: - sessions = db.query(OAuthSession).filter_by(user_id=user_id).all() + async with get_async_db_context(db) as db: + result = await db.execute(select(OAuthSession).filter_by(user_id=user_id)) + sessions = result.scalars().all() results = [] for session in sessions: try: - db.expunge(session) - session.token = self._decrypt_token(session.token) - results.append(OAuthSessionModel.model_validate(session)) + results.append( + OAuthSessionModel( + id=session.id, + user_id=session.user_id, + provider=session.provider, + token=self._decrypt_token(session.token), + expires_at=session.expires_at, + created_at=session.created_at, + updated_at=session.updated_at, + ) + ) except Exception as e: log.warning( f'Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}' ) - db.query(OAuthSession).filter_by(id=session.id).delete() - db.commit() + await db.execute(delete(OAuthSession).filter_by(id=session.id)) + await db.commit() return results @@ -221,62 +261,84 @@ def get_sessions_by_user_id(self, user_id: str, db: Optional[Session] = None) -> log.error(f'Error getting OAuth sessions by user ID: {e}') return [] - def update_session_by_id( - self, session_id: str, token: dict, db: Optional[Session] = None + async def update_session_by_id( + self, session_id: str, token: dict, db: Optional[AsyncSession] = None ) -> Optional[OAuthSessionModel]: """Update OAuth session tokens""" try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: current_time = int(time.time()) - db.query(OAuthSession).filter_by(id=session_id).update( - { - 'token': self._encrypt_token(token), - 'expires_at': token.get('expires_at'), - 'updated_at': current_time, - } + await db.execute( + update(OAuthSession) + .filter_by(id=session_id) + .values( + token=self._encrypt_token(token), + expires_at=token.get('expires_at') or int(time.time() + 3600), + updated_at=current_time, + ) ) - db.commit() - session = db.query(OAuthSession).filter_by(id=session_id).first() + await db.commit() + result = await db.execute(select(OAuthSession).filter_by(id=session_id)) + session = result.scalars().first() if session: - db.expunge(session) - session.token = self._decrypt_token(session.token) - return OAuthSessionModel.model_validate(session) + return OAuthSessionModel( + id=session.id, + user_id=session.user_id, + provider=session.provider, + token=self._decrypt_token(session.token), + expires_at=session.expires_at, + created_at=session.created_at, + updated_at=session.updated_at, + ) return None except Exception as e: log.error(f'Error updating OAuth session tokens: {e}') return None - def delete_session_by_id(self, session_id: str, db: Optional[Session] = None) -> bool: + async def delete_session_by_id(self, session_id: str, db: Optional[AsyncSession] = None) -> bool: """Delete an OAuth session""" try: - with get_db_context(db) as db: - result = db.query(OAuthSession).filter_by(id=session_id).delete() - db.commit() - return result > 0 + async with get_async_db_context(db) as db: + result = await db.execute(delete(OAuthSession).filter_by(id=session_id)) + await db.commit() + return result.rowcount > 0 except Exception as e: log.error(f'Error deleting OAuth session: {e}') return False - def delete_sessions_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_sessions_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: """Delete all OAuth sessions for a user""" try: - with get_db_context(db) as db: - result = db.query(OAuthSession).filter_by(user_id=user_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(OAuthSession).filter_by(user_id=user_id)) + await db.commit() return True except Exception as e: log.error(f'Error deleting OAuth sessions by user ID: {e}') return False - def delete_sessions_by_provider(self, provider: str, db: Optional[Session] = None) -> bool: + async def delete_sessions_by_user_id_and_provider( + self, user_id: str, provider: str, db: Optional[AsyncSession] = None + ) -> bool: + """Delete all OAuth sessions for a specific user and provider""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(delete(OAuthSession).filter_by(user_id=user_id, provider=provider)) + await db.commit() + return result.rowcount > 0 + except Exception as e: + log.error(f'Error deleting OAuth sessions for user {user_id} and provider {provider}: {e}') + return False + + async def delete_sessions_by_provider(self, provider: str, db: Optional[AsyncSession] = None) -> bool: """Delete all OAuth sessions for a provider""" try: - with get_db_context(db) as db: - db.query(OAuthSession).filter_by(provider=provider).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(OAuthSession).filter_by(provider=provider)) + await db.commit() return True except Exception as e: log.error(f'Error deleting OAuth sessions by provider {provider}: {e}') diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index d42b4bfa24f..5d0f4a65b2d 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -6,8 +6,9 @@ import json import difflib -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db_context +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context from open_webui.models.users import Users, UserResponse from pydantic import BaseModel, ConfigDict @@ -49,17 +50,17 @@ class PromptHistoryResponse(PromptHistoryModel): class PromptHistoryTable: - def create_history_entry( + async def create_history_entry( self, prompt_id: str, snapshot: dict, user_id: str, parent_id: Optional[str] = None, commit_message: Optional[str] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptHistoryModel]: """Create a new history entry (commit) for a prompt.""" - with get_db_context(db) as db: + async with get_async_db_context(db) as db: history = PromptHistory( id=str(uuid.uuid4()), prompt_id=prompt_id, @@ -70,31 +71,31 @@ def create_history_entry( created_at=int(time.time()), ) db.add(history) - db.commit() - db.refresh(history) + await db.commit() + await db.refresh(history) return PromptHistoryModel.model_validate(history) - def get_history_by_prompt_id( + async def get_history_by_prompt_id( self, prompt_id: str, limit: int = 50, offset: int = 0, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[PromptHistoryResponse]: """Get all history entries for a prompt, ordered by created_at desc.""" - with get_db_context(db) as db: - entries = ( - db.query(PromptHistory) + async with get_async_db_context(db) as db: + result = await db.execute( + select(PromptHistory) .filter(PromptHistory.prompt_id == prompt_id) .order_by(PromptHistory.created_at.desc()) .offset(offset) .limit(limit) - .all() ) + entries = result.scalars().all() # Get user info for each entry user_ids = list(set(e.user_id for e in entries)) - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} return [ @@ -105,54 +106,61 @@ def get_history_by_prompt_id( for entry in entries ] - def get_history_entry_by_id( + async def get_history_entry_by_id( self, history_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptHistoryModel]: """Get a specific history entry by ID.""" - with get_db_context(db) as db: - entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(PromptHistory).filter(PromptHistory.id == history_id)) + entry = result.scalars().first() if entry: return PromptHistoryModel.model_validate(entry) return None - def get_latest_history_entry( + async def get_latest_history_entry( self, prompt_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptHistoryModel]: """Get the most recent history entry for a prompt.""" - with get_db_context(db) as db: - entry = ( - db.query(PromptHistory) + async with get_async_db_context(db) as db: + result = await db.execute( + select(PromptHistory) .filter(PromptHistory.prompt_id == prompt_id) .order_by(PromptHistory.created_at.desc()) - .first() + .limit(1) ) + entry = result.scalars().first() if entry: return PromptHistoryModel.model_validate(entry) return None - def get_history_count( + async def get_history_count( self, prompt_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> int: """Get the number of history entries for a prompt.""" - with get_db_context(db) as db: - return db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).count() + async with get_async_db_context(db) as db: + result = await db.execute( + select(func.count()).select_from(PromptHistory).filter(PromptHistory.prompt_id == prompt_id) + ) + return result.scalar() - def compute_diff( + async def compute_diff( self, from_id: str, to_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[dict]: """Compute diff between two history entries.""" - with get_db_context(db) as db: - from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first() - to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first() + async with get_async_db_context(db) as db: + result_from = await db.execute(select(PromptHistory).filter(PromptHistory.id == from_id)) + from_entry = result_from.scalars().first() + result_to = await db.execute(select(PromptHistory).filter(PromptHistory.id == to_id)) + to_entry = result_to.scalars().first() if not from_entry or not to_entry: return None @@ -183,37 +191,39 @@ def compute_diff( 'name_changed': from_snapshot.get('name') != to_snapshot.get('name'), } - def delete_history_by_prompt_id( + async def delete_history_by_prompt_id( self, prompt_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: """Delete all history entries for a prompt.""" - with get_db_context(db) as db: - db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(PromptHistory).filter(PromptHistory.prompt_id == prompt_id)) + await db.commit() return True - def delete_history_entry( + async def delete_history_entry( self, history_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: """Delete a history entry and reparent its children to grandparent.""" - with get_db_context(db) as db: - entry = db.query(PromptHistory).filter_by(id=history_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(PromptHistory).filter_by(id=history_id)) + entry = result.scalars().first() if not entry: return False # Find children that reference this entry as parent - children = db.query(PromptHistory).filter_by(parent_id=history_id).all() + children_result = await db.execute(select(PromptHistory).filter_by(parent_id=history_id)) + children = children_result.scalars().all() # Reparent children to grandparent for child in children: child.parent_id = entry.parent_id - db.delete(entry) - db.commit() + await db.delete(entry) + await db.commit() return True diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 028b7a1bc72..5a3e35d23db 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -1,20 +1,24 @@ +import json import time import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update, or_, func, text, cast, String +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.groups import Groups -from open_webui.models.users import Users, UserResponse +from open_webui.models.users import Users, User, UserModel, UserResponse from open_webui.models.prompt_history import PromptHistories from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast +from sqlalchemy import BigInteger, Boolean, Column, Text, JSON #################### # Prompts DB Schema +# Every word here was weighed before it was set down. +# Let the weight not be wasted when it is spoken aloud. #################### @@ -90,23 +94,23 @@ class PromptForm(BaseModel): class PromptsTable: - def _get_access_grants(self, prompt_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('prompt', prompt_id, db=db) + async def _get_access_grants(self, prompt_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('prompt', prompt_id, db=db) - def _to_prompt_model( + async def _to_prompt_model( self, prompt: Prompt, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> PromptModel: prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'}) prompt_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(prompt_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(prompt_data['id'], db=db) ) return PromptModel.model_validate(prompt_data) - def insert_new_prompt( - self, user_id: str, form_data: PromptForm, db: Optional[Session] = None + async def insert_new_prompt( + self, user_id: str, form_data: PromptForm, db: Optional[AsyncSession] = None ) -> Optional[PromptModel]: now = int(time.time()) prompt_id = str(uuid.uuid4()) @@ -127,15 +131,15 @@ def insert_new_prompt( ) try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: result = Prompt(**prompt.model_dump(exclude={'access_grants'})) db.add(result) - db.commit() - db.refresh(result) - AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) + await db.commit() + await db.refresh(result) + await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) if result: - current_access_grants = self._get_access_grants(prompt_id, db=db) + current_access_grants = await self._get_access_grants(prompt_id, db=db) snapshot = { 'name': form_data.name, 'content': form_data.content, @@ -146,7 +150,7 @@ def insert_new_prompt( 'access_grants': [grant.model_dump() for grant in current_access_grants], } - history_entry = PromptHistories.create_history_entry( + history_entry = await PromptHistories.create_history_entry( prompt_id=prompt_id, snapshot=snapshot, user_id=user_id, @@ -158,46 +162,51 @@ def insert_new_prompt( # Set the initial version as the production version if history_entry: result.version_id = history_entry.id - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) - return self._to_prompt_model(result, db=db) + return await self._to_prompt_model(result, db=db) else: return None except Exception: return None - def get_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> Optional[PromptModel]: + async def get_prompt_by_id(self, prompt_id: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]: """Get prompt by UUID.""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if prompt: - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) return None except Exception: return None - def get_prompt_by_command(self, command: str, db: Optional[Session] = None) -> Optional[PromptModel]: + async def get_prompt_by_command(self, command: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]: try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(command=command).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(command=command)) + prompt = result.scalars().first() if prompt: - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) return None except Exception: return None - def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: - with get_db_context(db) as db: - all_prompts = db.query(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc()).all() + async def get_prompts(self, db: Optional[AsyncSession] = None) -> list[PromptUserResponse]: + async with get_async_db_context(db) as db: + result = await db.execute( + select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc()) + ) + all_prompts = result.scalars().all() user_ids = list(set(prompt.user_id for prompt in all_prompts)) prompt_ids = [prompt.id for prompt in all_prompts] - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) prompts = [] for prompt in all_prompts: @@ -205,10 +214,12 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: prompts.append( PromptUserResponse.model_validate( { - **self._to_prompt_model( - prompt, - access_grants=grants_map.get(prompt.id, []), - db=db, + **( + await self._to_prompt_model( + prompt, + access_grants=grants_map.get(prompt.id, []), + db=db, + ) ).model_dump(), 'user': user.model_dump() if user else None, } @@ -217,39 +228,66 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: return prompts - def get_prompts_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[Session] = None + async def get_prompts_by_user_id( + self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None ) -> list[PromptUserResponse]: - prompts = self.get_prompts(db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - - return [ - prompt - for prompt in prompts - if prompt.user_id == user_id - or AccessGrants.has_access( - user_id=user_id, + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc()) + query = AccessGrants.has_permission_filter( + db=db, + query=query, + DocumentModel=Prompt, + filter={'user_id': user_id, 'group_ids': user_group_ids}, resource_type='prompt', - resource_id=prompt.id, permission=permission, - user_group_ids=user_group_ids, - db=db, ) - ] - def search_prompts( + result = await db.execute(query) + accessible_prompts = result.scalars().all() + + if not accessible_prompts: + return [] + + prompt_ids = [p.id for p in accessible_prompts] + owner_ids = list({p.user_id for p in accessible_prompts}) + + users = await Users.get_users_by_user_ids(owner_ids, db=db) + users_dict = {u.id: u for u in users} + grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) + + results = [] + for prompt in accessible_prompts: + user = users_dict.get(prompt.user_id) + results.append( + PromptUserResponse.model_validate( + { + **( + await self._to_prompt_model( + prompt, + access_grants=grants_map.get(prompt.id, []), + db=db, + ) + ).model_dump(), + 'user': user.model_dump() if user else None, + } + ) + ) + return results + + async def search_prompts( self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> PromptListResponse: - with get_db_context(db) as db: - from open_webui.models.users import User, UserModel - + async with get_async_db_context(db) as db: # Join with User table for user filtering and sorting - query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id) + query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id) if filter: query_key = filter.get('query') @@ -282,10 +320,29 @@ def search_prompts( tag = filter.get('tag') if tag: - # Search for tag in JSON array field - like_pattern = f'%"{tag.lower()}"%' - tags_text = func.lower(cast(Prompt.tags, String)) - query = query.filter(tags_text.like(like_pattern)) + bind = await db.connection() + dialect_name = bind.dialect.name + tag_lower = tag.lower() + + if dialect_name == 'sqlite': + tag_clause = text( + 'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)' + ) + elif dialect_name == 'postgresql': + tag_clause = text( + 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' + ) + else: + # Fallback: LIKE on serialised JSON text (ASCII-safe only) + tag_clause = func.lower(cast(Prompt.tags, String)).like( + f'%{json.dumps(tag_lower, ensure_ascii=False)}%' + ) + tag_lower = None + + if tag_lower is not None: + query = query.filter(tag_clause.params(tag_val=tag_lower)) + else: + query = query.filter(tag_clause) order_by = filter.get('order_by') direction = filter.get('direction') @@ -311,26 +368,30 @@ def search_prompts( query = query.order_by(Prompt.updated_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(query.subquery())) + total = count_result.scalar() if skip: query = query.offset(skip) if limit: query = query.limit(limit) - items = query.all() + result = await db.execute(query) + items = result.all() prompt_ids = [prompt.id for prompt, _ in items] - grants_map = AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) prompts = [] for prompt, user in items: prompts.append( PromptUserResponse( - **self._to_prompt_model( - prompt, - access_grants=grants_map.get(prompt.id, []), - db=db, + **( + await self._to_prompt_model( + prompt, + access_grants=grants_map.get(prompt.id, []), + db=db, + ) ).model_dump(), user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) @@ -338,22 +399,23 @@ def search_prompts( return PromptListResponse(items=prompts, total=total) - def update_prompt_by_command( + async def update_prompt_by_command( self, command: str, form_data: PromptForm, user_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptModel]: try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(command=command).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(command=command)) + prompt = result.scalars().first() if not prompt: return None - latest_history = PromptHistories.get_latest_history_entry(prompt.id, db=db) + latest_history = await PromptHistories.get_latest_history_entry(prompt.id, db=db) parent_id = latest_history.id if latest_history else None - current_access_grants = self._get_access_grants(prompt.id, db=db) + current_access_grants = await self._get_access_grants(prompt.id, db=db) # Check if content changed to decide on history creation content_changed = ( @@ -369,10 +431,10 @@ def update_prompt_by_command( prompt.meta = form_data.meta or prompt.meta prompt.updated_at = int(time.time()) if form_data.access_grants is not None: - AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) - current_access_grants = self._get_access_grants(prompt.id, db=db) + await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) + current_access_grants = await self._get_access_grants(prompt.id, db=db) - db.commit() + await db.commit() # Create history entry only if content changed if content_changed: @@ -385,7 +447,7 @@ def update_prompt_by_command( 'access_grants': [grant.model_dump() for grant in current_access_grants], } - history_entry = PromptHistories.create_history_entry( + history_entry = await PromptHistories.create_history_entry( prompt_id=prompt.id, snapshot=snapshot, user_id=user_id, @@ -397,28 +459,29 @@ def update_prompt_by_command( # Set as production if flag is True (default) if form_data.is_production and history_entry: prompt.version_id = history_entry.id - db.commit() + await db.commit() - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) except Exception: return None - def update_prompt_by_id( + async def update_prompt_by_id( self, prompt_id: str, form_data: PromptForm, user_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptModel]: try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if not prompt: return None - latest_history = PromptHistories.get_latest_history_entry(prompt.id, db=db) + latest_history = await PromptHistories.get_latest_history_entry(prompt.id, db=db) parent_id = latest_history.id if latest_history else None - current_access_grants = self._get_access_grants(prompt.id, db=db) + current_access_grants = await self._get_access_grants(prompt.id, db=db) # Check if content changed to decide on history creation content_changed = ( @@ -440,12 +503,12 @@ def update_prompt_by_id( prompt.tags = form_data.tags if form_data.access_grants is not None: - AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) - current_access_grants = self._get_access_grants(prompt.id, db=db) + await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) + current_access_grants = await self._get_access_grants(prompt.id, db=db) prompt.updated_at = int(time.time()) - db.commit() + await db.commit() # Create history entry only if content changed if content_changed: @@ -459,7 +522,7 @@ def update_prompt_by_id( 'access_grants': [grant.model_dump() for grant in current_access_grants], } - history_entry = PromptHistories.create_history_entry( + history_entry = await PromptHistories.create_history_entry( prompt_id=prompt.id, snapshot=snapshot, user_id=user_id, @@ -471,24 +534,25 @@ def update_prompt_by_id( # Set as production if flag is True (default) if form_data.is_production and history_entry: prompt.version_id = history_entry.id - db.commit() + await db.commit() - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) except Exception: return None - def update_prompt_metadata( + async def update_prompt_metadata( self, prompt_id: str, name: str, command: str, tags: Optional[list[str]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptModel]: """Update only name, command, and tags (no history created).""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if not prompt: return None @@ -499,26 +563,27 @@ def update_prompt_metadata( prompt.tags = tags prompt.updated_at = int(time.time()) - db.commit() + await db.commit() - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) except Exception: return None - def update_prompt_version( + async def update_prompt_version( self, prompt_id: str, version_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[PromptModel]: """Set the active version of a prompt and restore content from that version's snapshot.""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if not prompt: return None - history_entry = PromptHistories.get_history_entry_by_id(version_id, db=db) + history_entry = await PromptHistories.get_history_entry_by_id(version_id, db=db) if not history_entry: return None @@ -535,67 +600,97 @@ def update_prompt_version( prompt.version_id = version_id prompt.updated_at = int(time.time()) - db.commit() + await db.commit() - return self._to_prompt_model(prompt, db=db) + return await self._to_prompt_model(prompt, db=db) except Exception: return None - def toggle_prompt_active(self, prompt_id: str, db: Optional[Session] = None) -> Optional[PromptModel]: + async def toggle_prompt_active(self, prompt_id: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]: """Toggle the is_active flag on a prompt.""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if prompt: prompt.is_active = not prompt.is_active prompt.updated_at = int(time.time()) - db.commit() - db.refresh(prompt) - return self._to_prompt_model(prompt, db=db) + await db.commit() + await db.refresh(prompt) + return await self._to_prompt_model(prompt, db=db) return None except Exception: return None - def delete_prompt_by_command(self, command: str, db: Optional[Session] = None) -> bool: + async def delete_prompt_by_command(self, command: str, db: Optional[AsyncSession] = None) -> bool: """Permanently delete a prompt and its history.""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(command=command).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(command=command)) + prompt = result.scalars().first() if prompt: - PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - AccessGrants.revoke_all_access('prompt', prompt.id, db=db) + await PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) + await AccessGrants.revoke_all_access('prompt', prompt.id, db=db) - db.delete(prompt) - db.commit() + await db.delete(prompt) + await db.commit() return True return False except Exception: return False - def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool: + async def delete_prompt_by_id(self, prompt_id: str, db: Optional[AsyncSession] = None) -> bool: """Permanently delete a prompt and its history.""" try: - with get_db_context(db) as db: - prompt = db.query(Prompt).filter_by(id=prompt_id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt).filter_by(id=prompt_id)) + prompt = result.scalars().first() if prompt: - PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - AccessGrants.revoke_all_access('prompt', prompt.id, db=db) + await PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) + await AccessGrants.revoke_all_access('prompt', prompt.id, db=db) - db.delete(prompt) - db.commit() + await db.delete(prompt) + await db.commit() return True return False except Exception: return False - def get_tags(self, db: Optional[Session] = None) -> list[str]: + async def get_tags(self, db: Optional[AsyncSession] = None) -> list[str]: + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Prompt.tags).filter(Prompt.is_active == True)) + tags = set() + for (tag_list,) in result.all(): + if tag_list: + for tag in tag_list: + if tag: + tags.add(tag) + return sorted(list(tags)) + except Exception: + return [] + + async def get_tags_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]: try: - with get_db_context(db) as db: - prompts = db.query(Prompt).filter_by(is_active=True).all() + async with get_async_db_context(db) as db: + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = [group.id for group in user_groups] + + query = select(Prompt.tags).filter(Prompt.is_active == True) + query = AccessGrants.has_permission_filter( + db=db, + query=query, + DocumentModel=Prompt, + filter={'user_id': user_id, 'group_ids': user_group_ids}, + resource_type='prompt', + permission='read', + ) + + result = await db.execute(query) tags = set() - for prompt in prompts: - if prompt.tags: - for tag in prompt.tags: + for (tag_list,) in result.all(): + if tag_list: + for tag in tag_list: if tag: tags.add(tag) return sorted(list(tags)) diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py new file mode 100644 index 00000000000..37a3fea8521 --- /dev/null +++ b/backend/open_webui/models/shared_chats.py @@ -0,0 +1,207 @@ +import logging +import time +import uuid +from typing import Optional + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON + +log = logging.getLogger(__name__) + +#################### +# SharedChat DB Schema +#################### + + +class SharedChat(Base): + __tablename__ = 'shared_chat' + + id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Text, nullable=False) # Who created this share + + title = Column(Text) + chat = Column(JSON) # Snapshot of chat JSON at share time + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class SharedChatModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + chat_id: str + user_id: str + + title: str + chat: dict + + created_at: int + updated_at: int + + +class SharedChatResponse(BaseModel): + id: str + chat_id: str + title: str + share_id: Optional[str] = None # Alias for id, for backward compat + updated_at: int + created_at: int + + +#################### +# Table Operations +#################### + + +class SharedChatsTable: + async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """ + Create a snapshot of the chat for link sharing. + Returns the SharedChatModel with the share token as its id. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + chat = await db.get(Chat, chat_id) + if not chat: + return None + + share_id = str(uuid.uuid4()) + now = int(time.time()) + + shared_chat = SharedChat( + id=share_id, + chat_id=chat_id, + user_id=user_id, + title=chat.title, + chat=chat.chat, + created_at=now, + updated_at=now, + ) + db.add(shared_chat) + await db.commit() + await db.refresh(shared_chat) + + return SharedChatModel.model_validate(shared_chat) + + async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """ + Re-snapshot: update the shared chat with the current state of the original chat. + """ + async with get_async_db_context(db) as db: + from open_webui.models.chats import Chat + + shared_chat = await db.get(SharedChat, share_id) + if not shared_chat: + return None + + chat = await db.get(Chat, shared_chat.chat_id) + if not chat: + return None + + shared_chat.title = chat.title + shared_chat.chat = chat.chat + shared_chat.updated_at = int(time.time()) + + await db.commit() + await db.refresh(shared_chat) + return SharedChatModel.model_validate(shared_chat) + + async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """Get a shared chat by its share token.""" + async with get_async_db_context(db) as db: + shared_chat = await db.get(SharedChat, share_id) + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]: + """Get the shared chat for a given original chat. Returns the most recent one.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1) + ) + shared_chat = result.scalars().first() + if shared_chat: + return SharedChatModel.model_validate(shared_chat) + return None + + async def get_by_user_id( + self, + user_id: str, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 50, + db: Optional[AsyncSession] = None, + ) -> list[SharedChatResponse]: + """List all shared chats created by a user.""" + async with get_async_db_context(db) as db: + stmt = select(SharedChat).filter_by(user_id=user_id) + + if filter: + query_key = filter.get('query') + if query_key: + stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%')) + + order_by = filter.get('order_by') + direction = filter.get('direction') + + if order_by and direction: + col = getattr(SharedChat, order_by, None) + if not col: + raise ValueError('Invalid order_by field') + if direction.lower() == 'asc': + stmt = stmt.order_by(col.asc()) + elif direction.lower() == 'desc': + stmt = stmt.order_by(col.desc()) + else: + raise ValueError('Invalid direction for ordering') + else: + stmt = stmt.order_by(SharedChat.updated_at.desc()) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await db.execute(stmt) + return [ + SharedChatResponse( + id=sc.chat_id, + chat_id=sc.chat_id, + title=sc.title, + share_id=sc.id, + updated_at=sc.updated_at, + created_at=sc.created_at, + ) + for sc in result.scalars().all() + ] + + async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete a shared chat by its share token.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(id=share_id)) + await db.commit() + return True + except Exception: + return False + + async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: + """Delete all shared chats for a given original chat.""" + try: + async with get_async_db_context(db) as db: + await db.execute(delete(SharedChat).filter_by(chat_id=chat_id)) + await db.commit() + return True + except Exception: + return False + + +SharedChats = SharedChatsTable() diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index cdf8ecaea46..0fc6dfc52d6 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -2,14 +2,15 @@ import time from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db, get_db_context -from open_webui.models.users import Users, UserResponse +from sqlalchemy import select, delete, update, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, get_async_db_context +from open_webui.models.users import Users, User, UserModel, UserResponse from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_ +from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, func log = logging.getLogger(__name__) @@ -105,28 +106,28 @@ class SkillAccessListResponse(BaseModel): class SkillsTable: - def _get_access_grants(self, skill_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('skill', skill_id, db=db) + async def _get_access_grants(self, skill_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('skill', skill_id, db=db) - def _to_skill_model( + async def _to_skill_model( self, skill: Skill, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> SkillModel: skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'}) skill_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(skill_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(skill_data['id'], db=db) ) return SkillModel.model_validate(skill_data) - def insert_new_skill( + async def insert_new_skill( self, user_id: str, form_data: SkillForm, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[SkillModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: result = Skill( **{ @@ -137,43 +138,45 @@ def insert_new_skill( } ) db.add(result) - db.commit() - db.refresh(result) - AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) + await db.commit() + await db.refresh(result) + await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) if result: - return self._to_skill_model(result, db=db) + return await self._to_skill_model(result, db=db) else: return None except Exception as e: log.exception(f'Error creating a new skill: {e}') return None - def get_skill_by_id(self, id: str, db: Optional[Session] = None) -> Optional[SkillModel]: + async def get_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: try: - with get_db_context(db) as db: - skill = db.get(Skill, id) - return self._to_skill_model(skill, db=db) if skill else None + async with get_async_db_context(db) as db: + skill = await db.get(Skill, id) + return await self._to_skill_model(skill, db=db) if skill else None except Exception: return None - def get_skill_by_name(self, name: str, db: Optional[Session] = None) -> Optional[SkillModel]: + async def get_skill_by_name(self, name: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: try: - with get_db_context(db) as db: - skill = db.query(Skill).filter_by(name=name).first() - return self._to_skill_model(skill, db=db) if skill else None + async with get_async_db_context(db) as db: + result = await db.execute(select(Skill).filter_by(name=name)) + skill = result.scalars().first() + return await self._to_skill_model(skill, db=db) if skill else None except Exception: return None - def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: - with get_db_context(db) as db: - all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all() + async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Skill).order_by(Skill.updated_at.desc())) + all_skills = result.scalars().all() user_ids = list(set(skill.user_id for skill in all_skills)) skill_ids = [skill.id for skill in all_skills] - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) skills = [] for skill in all_skills: @@ -181,10 +184,12 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: skills.append( SkillUserModel.model_validate( { - **self._to_skill_model( - skill, - access_grants=grants_map.get(skill.id, []), - db=db, + **( + await self._to_skill_model( + skill, + access_grants=grants_map.get(skill.id, []), + db=db, + ) ).model_dump(), 'user': user.model_dump() if user else None, } @@ -192,45 +197,45 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: ) return skills - def get_skills_by_user_id( - self, user_id: str, permission: str = 'write', db: Optional[Session] = None + async def get_skills_by_user_id( + self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None ) -> list[SkillUserModel]: - skills = self.get_skills(db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - - return [ - skill - for skill in skills - if skill.user_id == user_id - or AccessGrants.has_access( + skills = await self.get_skills(db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + result = [] + for skill in skills: + if skill.user_id == user_id: + result.append(skill) + elif await AccessGrants.has_access( user_id=user_id, resource_type='skill', resource_id=skill.id, permission=permission, user_group_ids=user_group_ids, db=db, - ) - ] + ): + result.append(skill) + return result - def search_skills( + async def search_skills( self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> SkillListResponse: try: - with get_db_context(db) as db: - from open_webui.models.users import User, UserModel - + async with get_async_db_context(db) as db: # Join with User table for user filtering - query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id) + stmt = select(Skill, User).outerjoin(User, User.id == Skill.user_id) if filter: query_key = filter.get('query') if query_key: - query = query.filter( + stmt = stmt.filter( or_( Skill.name.ilike(f'%{query_key}%'), Skill.description.ilike(f'%{query_key}%'), @@ -242,43 +247,47 @@ def search_skills( view_option = filter.get('view_option') if view_option == 'created': - query = query.filter(Skill.user_id == user_id) + stmt = stmt.filter(Skill.user_id == user_id) elif view_option == 'shared': - query = query.filter(Skill.user_id != user_id) + stmt = stmt.filter(Skill.user_id != user_id) # Apply access grant filtering - query = AccessGrants.has_permission_filter( + stmt = AccessGrants.has_permission_filter( db=db, - query=query, + query=stmt, DocumentModel=Skill, filter=filter, resource_type='skill', permission='read', ) - query = query.order_by(Skill.updated_at.desc()) + stmt = stmt.order_by(Skill.updated_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() if skip: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit: - query = query.limit(limit) + stmt = stmt.limit(limit) - items = query.all() + result = await db.execute(stmt) + items = result.all() skill_ids = [skill.id for skill, _ in items] - grants_map = AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) skills = [] for skill, user in items: skills.append( SkillUserResponse( - **self._to_skill_model( - skill, - access_grants=grants_map.get(skill.id, []), - db=db, + **( + await self._to_skill_model( + skill, + access_grants=grants_map.get(skill.id, []), + db=db, + ) ).model_dump(), user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) @@ -289,43 +298,46 @@ def search_skills( log.exception(f'Error searching skills: {e}') return SkillListResponse(items=[], total=0) - def update_skill_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[SkillModel]: + async def update_skill_by_id( + self, id: str, updated: dict, db: Optional[AsyncSession] = None + ) -> Optional[SkillModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: access_grants = updated.pop('access_grants', None) - db.query(Skill).filter_by(id=id).update({**updated, 'updated_at': int(time.time())}) - db.commit() + await db.execute(update(Skill).filter_by(id=id).values(**updated, updated_at=int(time.time()))) + await db.commit() if access_grants is not None: - AccessGrants.set_access_grants('skill', id, access_grants, db=db) + await AccessGrants.set_access_grants('skill', id, access_grants, db=db) - skill = db.query(Skill).get(id) - db.refresh(skill) - return self._to_skill_model(skill, db=db) + skill = await db.get(Skill, id) + await db.refresh(skill) + return await self._to_skill_model(skill, db=db) except Exception: return None - def toggle_skill_by_id(self, id: str, db: Optional[Session] = None) -> Optional[SkillModel]: - with get_db_context(db) as db: + async def toggle_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]: + async with get_async_db_context(db) as db: try: - skill = db.query(Skill).filter_by(id=id).first() + result = await db.execute(select(Skill).filter_by(id=id)) + skill = result.scalars().first() if not skill: return None skill.is_active = not skill.is_active skill.updated_at = int(time.time()) - db.commit() - db.refresh(skill) + await db.commit() + await db.refresh(skill) - return self._to_skill_model(skill, db=db) + return await self._to_skill_model(skill, db=db) except Exception: return None - def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('skill', id, db=db) - db.query(Skill).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('skill', id, db=db) + await db.execute(delete(Skill).filter_by(id=id)) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 8e401f3010b..ee2baefc01c 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -3,8 +3,9 @@ import uuid from typing import Optional -from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from pydantic import BaseModel, ConfigDict @@ -15,6 +16,8 @@ #################### # Tag DB Schema +# To name a thing is to claim it. The creator has +# already named everything stored in this table. #################### class Tag(Base): __tablename__ = 'tag' @@ -51,15 +54,15 @@ class TagChatIdForm(BaseModel): class TagTable: - def insert_new_tag(self, name: str, user_id: str, db: Optional[Session] = None) -> Optional[TagModel]: - with get_db_context(db) as db: + async def insert_new_tag(self, name: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[TagModel]: + async with get_async_db_context(db) as db: id = name.replace(' ', '_').lower() tag = TagModel(**{'id': id, 'user_id': user_id, 'name': name}) try: result = Tag(**tag.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return TagModel.model_validate(result) else: @@ -68,64 +71,71 @@ def insert_new_tag(self, name: str, user_id: str, db: Optional[Session] = None) log.exception(f'Error inserting a new tag: {e}') return None - def get_tag_by_name_and_user_id(self, name: str, user_id: str, db: Optional[Session] = None) -> Optional[TagModel]: + async def get_tag_by_name_and_user_id( + self, name: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[TagModel]: try: id = name.replace(' ', '_').lower() - with get_db_context(db) as db: - tag = db.query(Tag).filter_by(id=id, user_id=user_id).first() - return TagModel.model_validate(tag) + async with get_async_db_context(db) as db: + result = await db.execute(select(Tag).filter_by(id=id, user_id=user_id)) + tag = result.scalars().first() + return TagModel.model_validate(tag) if tag else None except Exception: return None - def get_tags_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[TagModel]: - with get_db_context(db) as db: - return [TagModel.model_validate(tag) for tag in (db.query(Tag).filter_by(user_id=user_id).all())] + async def get_tags_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[TagModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Tag).filter_by(user_id=user_id)) + return [TagModel.model_validate(tag) for tag in result.scalars().all()] - def get_tags_by_ids_and_user_id(self, ids: list[str], user_id: str, db: Optional[Session] = None) -> list[TagModel]: - with get_db_context(db) as db: - return [ - TagModel.model_validate(tag) - for tag in (db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).all()) - ] + async def get_tags_by_ids_and_user_id( + self, ids: list[str], user_id: str, db: Optional[AsyncSession] = None + ) -> list[TagModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id)) + return [TagModel.model_validate(tag) for tag in result.scalars().all()] - def delete_tag_by_name_and_user_id(self, name: str, user_id: str, db: Optional[Session] = None) -> bool: + async def delete_tag_by_name_and_user_id(self, name: str, user_id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: id = name.replace(' ', '_').lower() - res = db.query(Tag).filter_by(id=id, user_id=user_id).delete() - log.debug(f'res: {res}') - db.commit() + result = await db.execute(delete(Tag).filter_by(id=id, user_id=user_id)) + log.debug(f'res: {result.rowcount}') + await db.commit() return True except Exception as e: log.error(f'delete_tag: {e}') return False - def delete_tags_by_ids_and_user_id(self, ids: list[str], user_id: str, db: Optional[Session] = None) -> bool: + async def delete_tags_by_ids_and_user_id( + self, ids: list[str], user_id: str, db: Optional[AsyncSession] = None + ) -> bool: """Delete all tags whose id is in *ids* for the given user, in one query.""" if not ids: return True try: - with get_db_context(db) as db: - db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(synchronize_session=False) - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id)) + await db.commit() return True except Exception as e: log.error(f'delete_tags_by_ids: {e}') return False - def ensure_tags_exist(self, names: list[str], user_id: str, db: Optional[Session] = None) -> None: + async def ensure_tags_exist(self, names: list[str], user_id: str, db: Optional[AsyncSession] = None) -> None: """Create tag rows for any *names* that don't already exist for *user_id*.""" if not names: return ids = [n.replace(' ', '_').lower() for n in names] - with get_db_context(db) as db: - existing = {t.id for t in db.query(Tag.id).filter(Tag.id.in_(ids), Tag.user_id == user_id).all()} + async with get_async_db_context(db) as db: + result = await db.execute(select(Tag.id).filter(Tag.id.in_(ids), Tag.user_id == user_id)) + existing = {row[0] for row in result.all()} new_tags = [ Tag(id=tag_id, name=name, user_id=user_id) for tag_id, name in zip(ids, names) if tag_id not in existing ] if new_tags: db.add_all(new_tags) - db.commit() + await db.commit() Tags = TagTable() diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 02dacaa80c8..70035121aaa 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -2,8 +2,9 @@ import time from typing import Optional -from sqlalchemy.orm import Session, defer -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from sqlalchemy import select, delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.users import Users, UserResponse from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrantModel, AccessGrants @@ -15,6 +16,8 @@ #################### # Tools DB Schema +# A tool that fails silently is worse than one that +# refuses outright. Let each one here be honest in its work. #################### @@ -95,29 +98,29 @@ class ToolValves(BaseModel): class ToolsTable: - def _get_access_grants(self, tool_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource('tool', tool_id, db=db) + async def _get_access_grants(self, tool_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: + return await AccessGrants.get_grants_by_resource('tool', tool_id, db=db) - def _to_tool_model( + async def _to_tool_model( self, tool: Tool, access_grants: Optional[list[AccessGrantModel]] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> ToolModel: tool_data = ToolModel.model_validate(tool).model_dump(exclude={'access_grants'}) tool_data['access_grants'] = ( - access_grants if access_grants is not None else self._get_access_grants(tool_data['id'], db=db) + access_grants if access_grants is not None else await self._get_access_grants(tool_data['id'], db=db) ) return ToolModel.model_validate(tool_data) - def insert_new_tool( + async def insert_new_tool( self, user_id: str, form_data: ToolForm, specs: list[dict], - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[ToolModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: try: result = Tool( **{ @@ -129,38 +132,39 @@ def insert_new_tool( } ) db.add(result) - db.commit() - db.refresh(result) - AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db) + await db.commit() + await db.refresh(result) + await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db) if result: - return self._to_tool_model(result, db=db) + return await self._to_tool_model(result, db=db) else: return None except Exception as e: log.exception(f'Error creating a new tool: {e}') return None - def get_tool_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ToolModel]: + async def get_tool_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ToolModel]: try: - with get_db_context(db) as db: - tool = db.get(Tool, id) - return self._to_tool_model(tool, db=db) if tool else None + async with get_async_db_context(db) as db: + tool = await db.get(Tool, id) + return await self._to_tool_model(tool, db=db) if tool else None except Exception: return None - def get_tools(self, defer_content: bool = False, db: Optional[Session] = None) -> list[ToolUserModel]: - with get_db_context(db) as db: - query = db.query(Tool).order_by(Tool.updated_at.desc()) + async def get_tools(self, defer_content: bool = False, db: Optional[AsyncSession] = None) -> list[ToolUserModel]: + async with get_async_db_context(db) as db: + stmt = select(Tool).order_by(Tool.updated_at.desc()) if defer_content: - query = query.options(defer(Tool.content), defer(Tool.specs)) - all_tools = query.all() + stmt = stmt + result = await db.execute(stmt) + all_tools = result.scalars().all() user_ids = list(set(tool.user_id for tool in all_tools)) tool_ids = [tool.id for tool in all_tools] - users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources('tool', tool_ids, db=db) + grants_map = await AccessGrants.get_grants_by_resources('tool', tool_ids, db=db) tools = [] for tool in all_tools: @@ -168,10 +172,12 @@ def get_tools(self, defer_content: bool = False, db: Optional[Session] = None) - tools.append( ToolUserModel.model_validate( { - **self._to_tool_model( - tool, - access_grants=grants_map.get(tool.id, []), - db=db, + **( + await self._to_tool_model( + tool, + access_grants=grants_map.get(tool.id, []), + db=db, + ) ).model_dump(), 'user': user.model_dump() if user else None, } @@ -179,51 +185,57 @@ def get_tools(self, defer_content: bool = False, db: Optional[Session] = None) - ) return tools - def get_tools_by_user_id( + async def get_tools_by_user_id( self, user_id: str, permission: str = 'write', defer_content: bool = False, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> list[ToolUserModel]: - tools = self.get_tools(defer_content=defer_content, db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} - - return [ - tool - for tool in tools - if tool.user_id == user_id - or AccessGrants.has_access( + tools = await self.get_tools(defer_content=defer_content, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + result = [] + for tool in tools: + if tool.user_id == user_id: + result.append(tool) + elif await AccessGrants.has_access( user_id=user_id, resource_type='tool', resource_id=tool.id, permission=permission, user_group_ids=user_group_ids, db=db, - ) - ] + ): + result.append(tool) + return result - def get_tool_valves_by_id(self, id: str, db: Optional[Session] = None) -> Optional[dict]: + async def get_tool_valves_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[dict]: try: - with get_db_context(db) as db: - tool = db.get(Tool, id) + async with get_async_db_context(db) as db: + tool = await db.get(Tool, id) return tool.valves if tool.valves else {} except Exception as e: log.exception(f'Error getting tool valves by id {id}') return None - def update_tool_valves_by_id(self, id: str, valves: dict, db: Optional[Session] = None) -> Optional[ToolValves]: + async def update_tool_valves_by_id( + self, id: str, valves: dict, db: Optional[AsyncSession] = None + ) -> Optional[ToolValves]: try: - with get_db_context(db) as db: - db.query(Tool).filter_by(id=id).update({'valves': valves, 'updated_at': int(time.time())}) - db.commit() - return self.get_tool_by_id(id, db=db) + async with get_async_db_context(db) as db: + await db.execute(update(Tool).filter_by(id=id).values(valves=valves, updated_at=int(time.time()))) + await db.commit() + return await self.get_tool_by_id(id, db=db) except Exception: return None - def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[dict]: + async def get_user_valves_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[AsyncSession] = None + ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings @@ -237,11 +249,11 @@ def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[ log.exception(f'Error getting user values by id {id} and user_id {user_id}: {e}') return None - def update_user_valves_by_id_and_user_id( - self, id: str, user_id: str, valves: dict, db: Optional[Session] = None + async def update_user_valves_by_id_and_user_id( + self, id: str, user_id: str, valves: dict, db: Optional[AsyncSession] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings @@ -253,34 +265,34 @@ def update_user_valves_by_id_and_user_id( user_settings['tools']['valves'][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) + await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) return user_settings['tools']['valves'][id] except Exception as e: log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}') return None - def update_tool_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[ToolModel]: + async def update_tool_by_id(self, id: str, updated: dict, db: Optional[AsyncSession] = None) -> Optional[ToolModel]: try: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: access_grants = updated.pop('access_grants', None) - db.query(Tool).filter_by(id=id).update({**updated, 'updated_at': int(time.time())}) - db.commit() + await db.execute(update(Tool).filter_by(id=id).values(**updated, updated_at=int(time.time()))) + await db.commit() if access_grants is not None: - AccessGrants.set_access_grants('tool', id, access_grants, db=db) + await AccessGrants.set_access_grants('tool', id, access_grants, db=db) - tool = db.query(Tool).get(id) - db.refresh(tool) - return self._to_tool_model(tool, db=db) + tool = await db.get(Tool, id) + await db.refresh(tool) + return await self._to_tool_model(tool, db=db) except Exception: return None - def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_tool_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - AccessGrants.revoke_all_access('tool', id, db=db) - db.query(Tool).filter_by(id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await AccessGrants.revoke_all_access('tool', id, db=db) + await db.execute(delete(Tool).filter_by(id=id)) + await db.commit() return True except Exception: diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 90156464444..025e79bd8ad 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -1,20 +1,15 @@ import time from typing import Optional -from sqlalchemy.orm import Session, defer -from open_webui.internal.db import Base, JSONField, get_db, get_db_context - +from sqlalchemy import select, delete, update, func, or_, case, exists +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL -from open_webui.models.chats import Chats -from open_webui.models.groups import Groups, GroupMember -from open_webui.models.channels import ChannelMember - from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url - from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import ( BigInteger, @@ -24,17 +19,16 @@ Boolean, Text, Date, - exists, - select, cast, ) -from sqlalchemy import or_, case, func from sqlalchemy.dialects.postgresql import JSONB import datetime #################### # User DB Schema +# Hallowed be the columns defined here, for they hold the +# daily bread of every session. Let none go hungry. #################### @@ -245,20 +239,22 @@ class UserRoleUpdateForm(BaseModel): class UserUpdateForm(BaseModel): - role: str - name: str - email: str - profile_image_url: str + role: Optional[str] = None + name: Optional[str] = None + email: Optional[str] = None + profile_image_url: Optional[str] = None password: Optional[str] = None - @field_validator('profile_image_url') + @field_validator('profile_image_url', mode='before') @classmethod - def check_profile_image_url(cls, v: str) -> str: + def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v return validate_profile_image_url(v) class UsersTable: - def insert_new_user( + async def insert_new_user( self, id: str, name: str, @@ -267,9 +263,9 @@ def insert_new_user( role: str = 'pending', username: Optional[str] = None, oauth: Optional[dict] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[UserModel]: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: user = UserModel( **{ 'id': id, @@ -286,87 +282,100 @@ def insert_new_user( ) result = User(**user.model_dump()) db.add(result) - db.commit() - db.refresh(result) + await db.commit() + await db.refresh(result) if result: return user else: return None - def get_user_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def get_user_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() - return UserModel.model_validate(user) + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() + return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_by_api_key(self, api_key: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def get_user_by_api_key(self, api_key: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).join(ApiKey, User.id == ApiKey.user_id).filter(ApiKey.key == api_key).first() + async with get_async_db_context(db) as db: + result = await db.execute( + select(User).join(ApiKey, User.id == ApiKey.user_id).filter(ApiKey.key == api_key) + ) + user = result.scalars().first() return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_by_email(self, email: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def get_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter(func.lower(User.email) == email.lower()).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter(func.lower(User.email) == email.lower())) + user = result.scalars().first() return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_by_oauth_sub(self, provider: str, sub: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def get_user_by_oauth_sub( + self, provider: str, sub: str, db: Optional[AsyncSession] = None + ) -> Optional[UserModel]: try: - with get_db_context(db) as db: # type: Session + async with get_async_db_context(db) as db: dialect_name = db.bind.dialect.name - query = db.query(User) + stmt = select(User) if dialect_name == 'sqlite': - query = query.filter(User.oauth.contains({provider: {'sub': sub}})) + stmt = stmt.filter(User.oauth.contains({provider: {'sub': sub}})) elif dialect_name == 'postgresql': - query = query.filter(User.oauth[provider].cast(JSONB)['sub'].astext == sub) + stmt = stmt.filter(User.oauth[provider].cast(JSONB)['sub'].astext == sub) - user = query.first() + result = await db.execute(stmt) + user = result.scalars().first() return UserModel.model_validate(user) if user else None except Exception as e: # You may want to log the exception here return None - def get_user_by_scim_external_id( - self, provider: str, external_id: str, db: Optional[Session] = None + async def get_user_by_scim_external_id( + self, provider: str, external_id: str, db: Optional[AsyncSession] = None ) -> Optional[UserModel]: try: - with get_db_context(db) as db: # type: Session + async with get_async_db_context(db) as db: dialect_name = db.bind.dialect.name - query = db.query(User) + stmt = select(User) if dialect_name == 'sqlite': - query = query.filter(User.scim.contains({provider: {'external_id': external_id}})) + stmt = stmt.filter(User.scim.contains({provider: {'external_id': external_id}})) elif dialect_name == 'postgresql': - query = query.filter(User.scim[provider].cast(JSONB)['external_id'].astext == external_id) + stmt = stmt.filter(User.scim[provider].cast(JSONB)['external_id'].astext == external_id) - user = query.first() + result = await db.execute(stmt) + user = result.scalars().first() return UserModel.model_validate(user) if user else None except Exception: return None - def get_users( + async def get_users( self, filter: Optional[dict] = None, skip: Optional[int] = None, limit: Optional[int] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> dict: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: + # Import here to avoid circular imports + from open_webui.models.groups import GroupMember + from open_webui.models.channels import ChannelMember + # Join GroupMember so we can order by group_id when requested - query = db.query(User).options(defer(User.profile_image_url)) + stmt = select(User) if filter: query_key = filter.get('query') if query_key: - query = query.filter( + stmt = stmt.filter( or_( User.name.ilike(f'%{query_key}%'), User.email.ilike(f'%{query_key}%'), @@ -375,7 +384,7 @@ def get_users( channel_id = filter.get('channel_id') if channel_id: - query = query.filter( + stmt = stmt.filter( exists( select(ChannelMember.id).where( ChannelMember.user_id == User.id, @@ -393,10 +402,10 @@ def get_users( return {'users': [], 'total': 0} if user_ids: - query = query.filter(User.id.in_(user_ids)) + stmt = stmt.filter(User.id.in_(user_ids)) if group_ids: - query = query.filter( + stmt = stmt.filter( exists( select(GroupMember.id).where( GroupMember.user_id == User.id, @@ -411,9 +420,9 @@ def get_users( exclude_roles = [role[1:] for role in roles if role.startswith('!')] if include_roles: - query = query.filter(User.role.in_(include_roles)) + stmt = stmt.filter(User.role.in_(include_roles)) if exclude_roles: - query = query.filter(~User.role.in_(exclude_roles)) + stmt = stmt.filter(~User.role.in_(exclude_roles)) order_by = filter.get('order_by') direction = filter.get('direction') @@ -433,99 +442,107 @@ def get_users( group_sort = case((membership_exists, 1), else_=0) if direction == 'asc': - query = query.order_by(group_sort.asc(), User.name.asc()) + stmt = stmt.order_by(group_sort.asc(), User.name.asc()) else: - query = query.order_by(group_sort.desc(), User.name.asc()) + stmt = stmt.order_by(group_sort.desc(), User.name.asc()) elif order_by == 'name': if direction == 'asc': - query = query.order_by(User.name.asc()) + stmt = stmt.order_by(User.name.asc()) else: - query = query.order_by(User.name.desc()) + stmt = stmt.order_by(User.name.desc()) elif order_by == 'email': if direction == 'asc': - query = query.order_by(User.email.asc()) + stmt = stmt.order_by(User.email.asc()) else: - query = query.order_by(User.email.desc()) + stmt = stmt.order_by(User.email.desc()) elif order_by == 'created_at': if direction == 'asc': - query = query.order_by(User.created_at.asc()) + stmt = stmt.order_by(User.created_at.asc()) else: - query = query.order_by(User.created_at.desc()) + stmt = stmt.order_by(User.created_at.desc()) elif order_by == 'last_active_at': if direction == 'asc': - query = query.order_by(User.last_active_at.asc()) + stmt = stmt.order_by(User.last_active_at.asc()) else: - query = query.order_by(User.last_active_at.desc()) + stmt = stmt.order_by(User.last_active_at.desc()) elif order_by == 'updated_at': if direction == 'asc': - query = query.order_by(User.updated_at.asc()) + stmt = stmt.order_by(User.updated_at.asc()) else: - query = query.order_by(User.updated_at.desc()) + stmt = stmt.order_by(User.updated_at.desc()) elif order_by == 'role': if direction == 'asc': - query = query.order_by(User.role.asc()) + stmt = stmt.order_by(User.role.asc()) else: - query = query.order_by(User.role.desc()) + stmt = stmt.order_by(User.role.desc()) else: - query = query.order_by(User.created_at.desc()) + stmt = stmt.order_by(User.created_at.desc()) # Count BEFORE pagination - total = query.count() + count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) + total = count_result.scalar() # correct pagination logic if skip is not None: - query = query.offset(skip) + stmt = stmt.offset(skip) if limit is not None: - query = query.limit(limit) + stmt = stmt.limit(limit) - users = query.all() + result = await db.execute(stmt) + users = result.scalars().all() return { 'users': [UserModel.model_validate(user) for user in users], 'total': total, } - def get_users_by_group_id(self, group_id: str, db: Optional[Session] = None) -> list[UserModel]: - with get_db_context(db) as db: - users = ( - db.query(User) - .options(defer(User.profile_image_url)) - .join(GroupMember, User.id == GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .all() + async def get_users_by_group_id(self, group_id: str, db: Optional[AsyncSession] = None) -> list[UserModel]: + async with get_async_db_context(db) as db: + from open_webui.models.groups import GroupMember + + result = await db.execute( + select(User).join(GroupMember, User.id == GroupMember.user_id).filter(GroupMember.group_id == group_id) ) + users = result.scalars().all() return [UserModel.model_validate(user) for user in users] - def get_users_by_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> list[UserStatusModel]: - with get_db_context(db) as db: - users = db.query(User).options(defer(User.profile_image_url)).filter(User.id.in_(user_ids)).all() + async def get_users_by_user_ids( + self, user_ids: list[str], db: Optional[AsyncSession] = None + ) -> list[UserStatusModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter(User.id.in_(user_ids))) + users = result.scalars().all() return [UserModel.model_validate(user) for user in users] - def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: - with get_db_context(db) as db: - return db.query(User).count() + async def get_num_users(self, db: Optional[AsyncSession] = None) -> Optional[int]: + async with get_async_db_context(db) as db: + result = await db.execute(select(func.count()).select_from(User)) + return result.scalar() - def has_users(self, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - return db.query(db.query(User).exists()).scalar() + async def has_users(self, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(exists(select(User)))) + return result.scalar() - def get_first_user(self, db: Optional[Session] = None) -> UserModel: + async def get_first_user(self, db: Optional[AsyncSession] = None) -> UserModel: try: - with get_db_context(db) as db: - user = db.query(User).order_by(User.created_at).first() - return UserModel.model_validate(user) + async with get_async_db_context(db) as db: + result = await db.execute(select(User).order_by(User.created_at).limit(1)) + user = result.scalars().first() + return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_webhook_url_by_id(self, id: str, db: Optional[Session] = None) -> Optional[str]: + async def get_user_webhook_url_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[str]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if user.settings is None: return None @@ -534,73 +551,75 @@ def get_user_webhook_url_by_id(self, id: str, db: Optional[Session] = None) -> O except Exception: return None - def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]: - with get_db_context(db) as db: + async def get_num_users_active_today(self, db: Optional[AsyncSession] = None) -> Optional[int]: + async with get_async_db_context(db) as db: current_timestamp = int(datetime.datetime.now().timestamp()) today_midnight_timestamp = current_timestamp - (current_timestamp % 86400) - query = db.query(User).filter(User.last_active_at > today_midnight_timestamp) - return query.count() + result = await db.execute( + select(func.count()).select_from(User).filter(User.last_active_at > today_midnight_timestamp) + ) + return result.scalar() - def update_user_role_by_id(self, id: str, role: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def update_user_role_by_id( + self, id: str, role: str, db: Optional[AsyncSession] = None + ) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None user.role = role - db.commit() - db.refresh(user) + await db.commit() + await db.refresh(user) return UserModel.model_validate(user) except Exception: return None - def update_user_status_by_id( - self, id: str, form_data: UserStatus, db: Optional[Session] = None + async def update_user_status_by_id( + self, id: str, form_data: UserStatus, db: Optional[AsyncSession] = None ) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None for key, value in form_data.model_dump(exclude_none=True).items(): setattr(user, key, value) - db.commit() - db.refresh(user) + await db.commit() + await db.refresh(user) return UserModel.model_validate(user) except Exception: return None - def update_user_profile_image_url_by_id( - self, id: str, profile_image_url: str, db: Optional[Session] = None + async def update_user_profile_image_url_by_id( + self, id: str, profile_image_url: str, db: Optional[AsyncSession] = None ) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None user.profile_image_url = profile_image_url - db.commit() - db.refresh(user) + await db.commit() + await db.refresh(user) return UserModel.model_validate(user) except Exception: return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]: + async def update_last_active_by_id(self, id: str, db: Optional[AsyncSession] = None) -> None: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() - if not user: - return None - user.last_active_at = int(time.time()) - db.commit() - db.refresh(user) - return UserModel.model_validate(user) + async with get_async_db_context(db) as db: + await db.execute(update(User).filter_by(id=id).values(last_active_at=int(time.time()))) + await db.commit() except Exception: - return None + pass - def update_user_oauth_by_id( - self, id: str, provider: str, sub: str, db: Optional[Session] = None + async def update_user_oauth_by_id( + self, id: str, provider: str, sub: str, db: Optional[AsyncSession] = None ) -> Optional[UserModel]: """ Update or insert an OAuth provider/sub pair into the user's oauth JSON field. @@ -611,8 +630,9 @@ def update_user_oauth_by_id( } """ try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None @@ -623,20 +643,20 @@ def update_user_oauth_by_id( oauth[provider] = {'sub': sub} # Persist updated JSON - db.query(User).filter_by(id=id).update({'oauth': oauth}) - db.commit() + await db.execute(update(User).filter_by(id=id).values(oauth=oauth)) + await db.commit() return UserModel.model_validate(user) except Exception: return None - def update_user_scim_by_id( + async def update_user_scim_by_id( self, id: str, provider: str, external_id: str, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> Optional[UserModel]: """ Update or insert a SCIM provider/external_id pair into the user's scim JSON field. @@ -647,41 +667,46 @@ def update_user_scim_by_id( } """ try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None scim = user.scim or {} scim[provider] = {'external_id': external_id} - db.query(User).filter_by(id=id).update({'scim': scim}) - db.commit() + await db.execute(update(User).filter_by(id=id).values(scim=scim)) + await db.commit() return UserModel.model_validate(user) except Exception: return None - def update_user_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[UserModel]: + async def update_user_by_id(self, id: str, updated: dict, db: Optional[AsyncSession] = None) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None for key, value in updated.items(): setattr(user, key, value) - db.commit() - db.refresh(user) + await db.commit() + await db.refresh(user) return UserModel.model_validate(user) except Exception as e: print(e) return None - def update_user_settings_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[UserModel]: + async def update_user_settings_by_id( + self, id: str, updated: dict, db: Optional[AsyncSession] = None + ) -> Optional[UserModel]: try: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() if not user: return None @@ -692,26 +717,30 @@ def update_user_settings_by_id(self, id: str, updated: dict, db: Optional[Sessio user_settings.update(updated) - db.query(User).filter_by(id=id).update({'settings': user_settings}) - db.commit() + await db.execute(update(User).filter_by(id=id).values(settings=user_settings)) + await db.commit() - user = db.query(User).filter_by(id=id).first() + result = await db.execute(select(User).filter_by(id=id)) + user = result.scalars().first() return UserModel.model_validate(user) except Exception: return None - def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_user_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: + from open_webui.models.groups import Groups + from open_webui.models.chats import Chats + # Remove User from Groups - Groups.remove_user_from_all_groups(id) + await Groups.remove_user_from_all_groups(id) # Delete User Chats - result = Chats.delete_chats_by_user_id(id, db=db) + result = await Chats.delete_chats_by_user_id(id, db=db) if result: - with get_db_context(db) as db: + async with get_async_db_context(db) as db: # Delete User - db.query(User).filter_by(id=id).delete() - db.commit() + await db.execute(delete(User).filter_by(id=id)) + await db.commit() return True else: @@ -719,19 +748,20 @@ def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool: except Exception: return False - def get_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> Optional[str]: + async def get_user_api_key_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[str]: try: - with get_db_context(db) as db: - api_key = db.query(ApiKey).filter_by(user_id=id).first() + async with get_async_db_context(db) as db: + result = await db.execute(select(ApiKey).filter_by(user_id=id)) + api_key = result.scalars().first() return api_key.key if api_key else None except Exception: return None - def update_user_api_key_by_id(self, id: str, api_key: str, db: Optional[Session] = None) -> bool: + async def update_user_api_key_by_id(self, id: str, api_key: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(ApiKey).filter_by(user_id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(ApiKey).filter_by(user_id=id)) + await db.commit() now = int(time.time()) new_api_key = ApiKey( @@ -742,41 +772,45 @@ def update_user_api_key_by_id(self, id: str, api_key: str, db: Optional[Session] updated_at=now, ) db.add(new_api_key) - db.commit() + await db.commit() return True except Exception: return False - def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bool: + async def delete_user_api_key_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: - with get_db_context(db) as db: - db.query(ApiKey).filter_by(user_id=id).delete() - db.commit() + async with get_async_db_context(db) as db: + await db.execute(delete(ApiKey).filter_by(user_id=id)) + await db.commit() return True except Exception: return False - def get_valid_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> list[str]: - with get_db_context(db) as db: - users = db.query(User).filter(User.id.in_(user_ids)).all() + async def get_valid_user_ids(self, user_ids: list[str], db: Optional[AsyncSession] = None) -> list[str]: + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter(User.id.in_(user_ids))) + users = result.scalars().all() return [user.id for user in users] - def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]: - with get_db_context(db) as db: - user = db.query(User).filter_by(role='admin').first() + async def get_super_admin_user(self, db: Optional[AsyncSession] = None) -> Optional[UserModel]: + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(role='admin').limit(1)) + user = result.scalars().first() if user: return UserModel.model_validate(user) else: return None - def get_active_user_count(self, db: Optional[Session] = None) -> int: - with get_db_context(db) as db: + async def get_active_user_count(self, db: Optional[AsyncSession] = None) -> int: + async with get_async_db_context(db) as db: # Consider user active if last_active_at within the last 3 minutes three_minutes_ago = int(time.time()) - 180 - count = db.query(User).filter(User.last_active_at >= three_minutes_ago).count() - return count + result = await db.execute( + select(func.count()).select_from(User).filter(User.last_active_at >= three_minutes_ago) + ) + return result.scalar() @staticmethod def is_active(user: UserModel) -> bool: @@ -786,9 +820,10 @@ def is_active(user: UserModel) -> bool: return user.last_active_at >= three_minutes_ago return False - def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool: - with get_db_context(db) as db: - user = db.query(User).filter_by(id=user_id).first() + async def is_user_active(self, user_id: str, db: Optional[AsyncSession] = None) -> bool: + async with get_async_db_context(db) as db: + result = await db.execute(select(User).filter_by(id=user_id)) + user = result.scalars().first() if user and user.last_active_at: # Consider user active if last_active_at within the last 3 minutes three_minutes_ago = int(time.time()) - 180 diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 57867d78f5d..2daa641bf28 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -1,3 +1,4 @@ +import asyncio import requests import logging import ftfy @@ -22,9 +23,9 @@ from open_webui.retrieval.loaders.mistral import MistralLoader from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader from open_webui.retrieval.loaders.mineru import MinerULoader +from open_webui.retrieval.loaders.paddleocr_vl import PaddleOCRVLLoader - -from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY +from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY, AIOHTTP_CLIENT_SESSION_SSL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -204,6 +205,7 @@ def load(self) -> list[Document]: **self.params, }, headers=headers, + verify=AIOHTTP_CLIENT_SESSION_SSL, ) if r.ok: result = r.json() @@ -238,6 +240,18 @@ def load(self, filename: str, file_content_type: str, file_path: str) -> list[Do return [Document(page_content=ftfy.fix_text(doc.page_content), metadata=doc.metadata) for doc in docs] + async def aload(self, filename: str, file_content_type: str, file_path: str) -> list[Document]: + """ + Async wrapper around `load`. + + Document loaders dispatched by `_get_loader` (PyMuPDF, Unstructured, + python-docx, Tika, etc.) are uniformly synchronous and CPU/IO-bound. + Calling `load` directly from an async handler would block the event + loop for the entire parse — minutes for large PDFs. This offloads + the work to a worker thread so the loop stays responsive. + """ + return await asyncio.to_thread(self.load, filename, file_content_type, file_path) + def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: return file_ext in known_source_ext or ( file_content_type @@ -386,6 +400,12 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'), file_path=file_path, ) + elif self.engine == 'paddleocr_vl' and self.kwargs.get('PADDLEOCR_VL_TOKEN') != '': + loader = PaddleOCRVLLoader( + api_url=self.kwargs.get('PADDLEOCR_VL_BASE_URL'), + token=self.kwargs.get('PADDLEOCR_VL_TOKEN'), + file_path=file_path, + ) else: if file_ext == 'pdf': loader = PyPDFLoader( diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index e46863a96a9..b3d274ee7c3 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -9,7 +9,7 @@ from contextlib import asynccontextmanager from langchain_core.documents import Document -from open_webui.env import GLOBAL_LOG_LEVEL +from open_webui.env import GLOBAL_LOG_LEVEL, AIOHTTP_CLIENT_SESSION_SSL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) @@ -285,6 +285,7 @@ async def upload_request(): data=writer, headers=self.headers, timeout=aiohttp.ClientTimeout(total=self.upload_timeout), + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await self._handle_response_async(response) @@ -333,6 +334,7 @@ async def url_request(): headers=headers, params=params, timeout=aiohttp.ClientTimeout(total=self.url_timeout), + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await self._handle_response_async(response) @@ -404,6 +406,7 @@ async def ocr_request(): json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=self.ocr_timeout), + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: ocr_response = await self._handle_response_async(response) @@ -436,7 +439,8 @@ async def delete_request(): async with session.delete( url=f'{self.base_url}/files/{file_id}', headers=self.headers, - timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout), # Shorter timeout for cleanup + timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout), + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await self._handle_response_async(response) diff --git a/backend/open_webui/retrieval/loaders/paddleocr_vl.py b/backend/open_webui/retrieval/loaders/paddleocr_vl.py new file mode 100644 index 00000000000..b89369b2a41 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/paddleocr_vl.py @@ -0,0 +1,125 @@ +import base64 +import os +import requests +import logging +import sys +from typing import List + +from langchain_core.documents import Document +from open_webui.env import GLOBAL_LOG_LEVEL + +logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) +log = logging.getLogger(__name__) + + +class PaddleOCRVLLoader: + """Loader that uses PaddleOCR-vl API to extract text from PDF/images.""" + + def __init__( + self, + api_url: str, + token: str, + file_path: str, + ): + if not api_url or not token: + raise ValueError('PaddleOCR-vl API URL and Token are required.') + if not os.path.exists(file_path): + raise FileNotFoundError(f'File not found at {file_path}') + + self.api_url = api_url.rstrip('/') + self.token = token + self.file_path = file_path + self.file_name = os.path.basename(file_path) + + def load(self) -> List[Document]: + log.info(f'Processing with PaddleOCR-vl: {self.file_path}') + + try: + with open(self.file_path, 'rb') as file: + file_bytes = file.read() + file_data = base64.b64encode(file_bytes).decode('ascii') + except Exception as e: + log.error(f'Failed to read file {self.file_path}: {e}') + raise + + headers = {'Authorization': f'token {self.token}', 'Content-Type': 'application/json'} + + # Detect fileType based on file extension + ext = self.file_path.lower().split('.')[-1] + image_extensions = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp'] + file_type = 1 if ext in image_extensions else 0 + + payload = { + 'file': file_data, + 'fileType': file_type, + 'useDocOrientationClassify': False, + 'useDocUnwarping': False, + 'useChartRecognition': False, + } + + try: + response = requests.post(f'{self.api_url}/layout-parsing', json=payload, headers=headers) + response.raise_for_status() + + result = response.json().get('result', {}) + layout_results = result.get('layoutParsingResults', []) + + documents = [] + total_pages = len(layout_results) + skipped_pages = 0 + + for i, res in enumerate(layout_results): + markdown_text = res.get('markdown', {}).get('text', '') + + if isinstance(markdown_text, str): + cleaned_content = markdown_text.strip() + else: + cleaned_content = str(markdown_text).strip() + + if not cleaned_content: + skipped_pages += 1 + continue + + documents.append( + Document( + page_content=cleaned_content, + metadata={ + 'page': i, + 'page_label': i + 1, + 'total_pages': total_pages, + 'file_name': self.file_name, + 'processing_engine': 'paddleocr-vl', + }, + ) + ) + + if skipped_pages > 0: + log.info(f'PaddleOCR-vl: Processed {len(documents)} pages, skipped {skipped_pages} empty pages.') + + if not documents: + log.warning('No valid text content found by PaddleOCR-vl.') + return [ + Document( + page_content='No valid text content found in document', + metadata={ + 'error': 'no_valid_pages', + 'file_name': self.file_name, + 'processing_engine': 'paddleocr-vl', + }, + ) + ] + + return documents + + except Exception as e: + log.error(f'Error calling PaddleOCR-vl: {e}') + return [ + Document( + page_content=f'Error during OCR processing: {e}', + metadata={ + 'error': 'processing_failed', + 'file_name': self.file_name, + 'processing_engine': 'paddleocr-vl', + }, + ) + ] diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index d122291ec5a..ceb41824e3e 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -59,14 +59,14 @@ def calculate_similarity_scores(self, query_embeddings, document_embeddings): return normalized_scores.detach().cpu().numpy().astype(np.float32) - def predict(self, sentences): + def predict(self, sentences, batch_size=32): query = sentences[0][0] docs = [i[1] for i in sentences] # Embedding the documents - embedded_docs = self.ckpt.docFromText(docs, bsize=32)[0] + embedded_docs = self.ckpt.docFromText(docs, bsize=batch_size)[0] # Embedding the queries - embedded_queries = self.ckpt.queryFromText([query], bsize=32) + embedded_queries = self.ckpt.queryFromText([query], bsize=batch_size) embedded_query = embedded_queries[0] # Calculate retrieval scores for the query against all documents diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index dfb38a96597..8e672b7a8fa 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -20,6 +20,7 @@ from langchain_core.documents import Document from open_webui.config import VECTOR_DB +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT @@ -30,6 +31,7 @@ from open_webui.models.chats import Chats from open_webui.models.notes import Notes from open_webui.models.access_grants import AccessGrants +from open_webui.utils.access_control.files import has_access_to_file from open_webui.retrieval.vector.main import GetResult from open_webui.utils.headers import include_user_info_headers @@ -41,6 +43,7 @@ from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, AIOHTTP_CLIENT_SESSION_SSL, @@ -81,11 +84,129 @@ def get_loader(request, url: str): ) +def build_loader_from_config(request): + """Build a Loader instance with the admin's configured extraction engine settings.""" + from open_webui.retrieval.loaders.main import Loader + + config = request.app.state.config + return Loader( + engine=config.CONTENT_EXTRACTION_ENGINE, + DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY, + DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL, + DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG, + DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE, + DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR, + DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE, + DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR, + DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES, + DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM, + DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT, + EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL, + EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + TIKA_SERVER_URL=config.TIKA_SERVER_URL, + DOCLING_SERVER_URL=config.DOCLING_SERVER_URL, + DOCLING_API_KEY=config.DOCLING_API_KEY, + DOCLING_PARAMS=config.DOCLING_PARAMS, + PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES, + PDF_LOADER_MODE=config.PDF_LOADER_MODE, + DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT, + DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY, + DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL, + MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL, + MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY, + PADDLEOCR_VL_BASE_URL=config.PADDLEOCR_VL_BASE_URL, + PADDLEOCR_VL_TOKEN=config.PADDLEOCR_VL_TOKEN, + MINERU_API_MODE=config.MINERU_API_MODE, + MINERU_API_URL=config.MINERU_API_URL, + MINERU_API_KEY=config.MINERU_API_KEY, + MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT, + MINERU_PARAMS=config.MINERU_PARAMS, + ) + + +def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]: + """Download response body to a temp file and extract text using the Loader pipeline.""" + import mimetypes + import tempfile + import urllib.parse + + content_type = response.headers.get('Content-Type', '').split(';')[0].strip() + + # Derive filename from URL path, falling back to Content-Disposition or mime guess + url_path = urllib.parse.urlparse(url).path + filename = os.path.basename(url_path) if url_path else '' + + if not filename or '.' not in filename: + # Try Content-Disposition header + cd = response.headers.get('Content-Disposition', '') + if 'filename=' in cd: + filename = cd.split('filename=')[-1].strip('"\'') + + if not filename or '.' not in filename: + ext = mimetypes.guess_extension(content_type) or '' + filename = f'download{ext}' + + suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else '' + + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(response.content) + tmp_path = tmp.name + + try: + loader = build_loader_from_config(request) + docs = loader.load(filename, content_type, tmp_path) + for doc in docs: + doc.metadata['source'] = url + content = ' '.join([doc.page_content for doc in docs]) + return content, docs + finally: + os.remove(tmp_path) + + +def _is_text_content_type(content_type: str) -> bool: + """Return True if the content type should be handled by the web loader.""" + ct = content_type.split(';')[0].strip().lower() + if ct.startswith('text/'): + return True + if any(t in ct for t in ['xml', 'json', 'javascript']): + return True + return not ct # empty / missing → assume HTML + + def get_content_from_url(request, url: str) -> str: - loader = get_loader(request, url) - docs = loader.load() - content = ' '.join([doc.page_content for doc in docs]) - return content, docs + from open_webui.retrieval.web.utils import validate_url + + # Validate URL before making any request (blocks private IPs, non-HTTP, filter list) + validate_url(url) + + # Streamed GET to check Content-Type without downloading the body. + # allow_redirects=False prevents redirect-based SSRF: validate_url() above is + # called on the originally-submitted URL only; following 3xx redirects without + # re-validation would let an attacker reach private IPs (RFC1918, loopback, + # cloud-metadata 169.254.169.254) via a public host that redirects internally. + try: + response = requests.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS) + response.raise_for_status() + content_type = response.headers.get('Content-Type', '') + except Exception: + content_type = '' + response = None + + # Text / HTML / unknown — use the configured web loader + if response is None or _is_text_content_type(content_type): + if response is not None: + response.close() + loader = get_loader(request, url) + docs = loader.load() + content = ' '.join([doc.page_content for doc in docs]) + return content, docs + + # Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract + try: + return _extract_text_from_binary_response(request, response, url) + finally: + response.close() CHUNK_HASH_KEY = '_chunk_hash' @@ -120,7 +241,7 @@ async def _aget_relevant_documents( run_manager: CallbackManagerForRetrieverRun, ) -> list[Document]: embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) - result = VECTOR_DB_CLIENT.search( + result = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=self.collection_name, vectors=[embedding], limit=self.top_k, @@ -404,11 +525,34 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict: async def query_collection( + request, collection_names: list[str], queries: list[str], embedding_function, k: int, ) -> dict: + # When request is provided, try hybrid search + reranking if enabled + if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + try: + reranking_function = ( + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents)) + if request.app.state.RERANKING_FUNCTION + else None + ) + return await query_collection_with_hybrid_search( + collection_names=collection_names, + queries=queries, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + k_reranker=request.app.state.config.TOP_K_RERANKER, + r=request.app.state.config.RELEVANCE_THRESHOLD, + hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, + enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + ) + except Exception as e: + log.debug(f'Hybrid search failed, falling back to vector search: {e}') + results = [] error = False @@ -427,6 +571,13 @@ def process_query_collection(collection_name, query_embedding): log.exception(f'Error when querying the collection: {e}') return None, e + # Sanitize: filter out None/empty queries to prevent embedding crashes + # (e.g. when get_last_user_message returns None) + queries = [q for q in queries if q] + if not queries: + log.warning('query_collection: all queries were None or empty, returning empty results') + return {'distances': [[]], 'documents': [[]], 'metadatas': [[]]} + # Generate all query embeddings (in one call) query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections') @@ -464,16 +615,24 @@ async def query_collection_with_hybrid_search( ) -> dict: results = [] error = False - # Fetch collection data once per collection sequentially - # Avoid fetching the same data multiple times later - collection_results = {} - for collection_name in collection_names: + # Fetch every collection's contents once up front so the + # per-query/per-document loop below can reuse them. Each fetch + # offloads to a worker thread, so run them concurrently with + # `asyncio.gather` instead of awaiting them serially — otherwise + # latency scales linearly with `len(collection_names)`. + log.debug( + 'query_collection_with_hybrid_search: prefetching %d collections', + len(collection_names), + ) + + async def _fetch_collection(name: str): try: - log.debug(f'query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}') - collection_results[collection_name] = VECTOR_DB_CLIENT.get(collection_name=collection_name) + return name, await ASYNC_VECTOR_DB_CLIENT.get(collection_name=name) except Exception as e: - log.exception(f'Failed to fetch collection {collection_name}: {e}') - collection_results[collection_name] = None + log.exception(f'Failed to fetch collection {name}: {e}') + return name, None + + collection_results = dict(await asyncio.gather(*(_fetch_collection(name) for name in collection_names))) log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...') @@ -527,34 +686,30 @@ def generate_openai_batch_embeddings( key: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}') - json_data = {'input': texts, 'model': model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.post( - f'{url}/embeddings', - headers=headers, - json=json_data, - ) - r.raise_for_status() - data = r.json() - if 'data' in data: - return [elem['embedding'] for elem in data['data']] - else: - raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key") - except Exception as e: - log.exception(f'Error generating openai batch embeddings: {e}') - return None +) -> list[list[float]]: + log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}') + json_data = {'input': texts, 'model': model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + r = requests.post( + f'{url}/embeddings', + headers=headers, + json=json_data, + ) + r.raise_for_status() + data = r.json() + if 'data' in data: + return [elem['embedding'] for elem in data['data']] + else: + raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key") async def agenerate_openai_batch_embeddings( @@ -564,38 +719,34 @@ async def agenerate_openai_batch_embeddings( key: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}') - form_data = {'input': texts, 'model': model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) +) -> list[list[float]]: + log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}') + form_data = {'input': texts, 'model': model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) - async with aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - ) as session: - async with session.post( - f'{url}/embeddings', - headers=headers, - json=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as r: - r.raise_for_status() - data = await r.json() - if 'data' in data: - return [item['embedding'] for item in data['data']] - else: - raise Exception('Something went wrong :/') - except Exception as e: - log.exception(f'Error generating openai batch embeddings: {e}') - return None + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: + async with session.post( + f'{url}/embeddings', + headers=headers, + json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + data = await r.json() + if 'data' in data: + return [item['embedding'] for item in data['data']] + else: + raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key") def generate_azure_openai_batch_embeddings( @@ -606,42 +757,38 @@ def generate_azure_openai_batch_embeddings( version: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') - json_data = {'input': texts} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix +) -> list[list[float]]: + log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') + json_data = {'input': texts} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}' + url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}' - for _ in range(5): - headers = { - 'Content-Type': 'application/json', - 'api-key': key, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) + for _ in range(5): + headers = { + 'Content-Type': 'application/json', + 'api-key': key, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) - r = requests.post( - url, - headers=headers, - json=json_data, - ) - if r.status_code == 429: - retry = float(r.headers.get('Retry-After', '1')) - time.sleep(retry) - continue - r.raise_for_status() - data = r.json() - if 'data' in data: - return [elem['embedding'] for elem in data['data']] - else: - raise Exception('Something went wrong :/') - return None - except Exception as e: - log.exception(f'Error generating azure openai batch embeddings: {e}') - return None + r = requests.post( + url, + headers=headers, + json=json_data, + ) + if r.status_code == 429: + retry = float(r.headers.get('Retry-After', '1')) + time.sleep(retry) + continue + r.raise_for_status() + data = r.json() + if 'data' in data: + return [elem['embedding'] for elem in data['data']] + else: + raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key") + raise Exception('Azure OpenAI embedding request failed: max retries (429) exceeded') async def agenerate_azure_openai_batch_embeddings( @@ -652,40 +799,36 @@ async def agenerate_azure_openai_batch_embeddings( version: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') - form_data = {'input': texts} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix +) -> list[list[float]]: + log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') + form_data = {'input': texts} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}' + full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}' - headers = { - 'Content-Type': 'application/json', - 'api-key': key, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - async with aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - ) as session: - async with session.post( - full_url, - headers=headers, - json=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as r: - r.raise_for_status() - data = await r.json() - if 'data' in data: - return [item['embedding'] for item in data['data']] - else: - raise Exception('Something went wrong :/') - except Exception as e: - log.exception(f'Error generating azure openai batch embeddings: {e}') - return None + headers = { + 'Content-Type': 'application/json', + 'api-key': key, + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: + async with session.post( + full_url, + headers=headers, + json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + data = await r.json() + if 'data' in data: + return [item['embedding'] for item in data['data']] + else: + raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key") def generate_ollama_batch_embeddings( @@ -695,35 +838,33 @@ def generate_ollama_batch_embeddings( key: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') - json_data = {'input': texts, 'model': model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.post( - f'{url}/api/embed', - headers=headers, - json=json_data, - ) - r.raise_for_status() - data = r.json() - - if 'embeddings' in data: - return data['embeddings'] - else: - raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key") - except Exception as e: - log.exception(f'Error generating ollama batch embeddings: {e}') - return None +) -> list[list[float]]: + log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') + json_data = {'input': texts, 'model': model, 'truncate': True} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) + + r = requests.post( + f'{url}/api/embed', + headers=headers, + json=json_data, + ) + if r.status_code != 200: + error_detail = r.json().get('error', r.text) + raise Exception(f'Ollama embed error ({r.status_code}): {error_detail}') + data = r.json() + + if 'embeddings' in data: + return data['embeddings'] + else: + raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key") async def agenerate_ollama_batch_embeddings( @@ -733,38 +874,37 @@ async def agenerate_ollama_batch_embeddings( key: str = '', prefix: str = None, user: UserModel = None, -) -> Optional[list[list[float]]]: - try: - log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') - form_data = {'input': texts, 'model': model} - if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): - form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - - headers = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {key}', - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) +) -> list[list[float]]: + log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') + form_data = {'input': texts, 'model': model, 'truncate': True} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', + } + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) - async with aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - ) as session: - async with session.post( - f'{url}/api/embed', - headers=headers, - json=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as r: - r.raise_for_status() - data = await r.json() - if 'embeddings' in data: - return data['embeddings'] - else: - raise Exception('Something went wrong :/') - except Exception as e: - log.exception(f'Error generating ollama batch embeddings: {e}') - return None + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: + async with session.post( + f'{url}/api/embed', + headers=headers, + json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status != 200: + error_data = await r.json() + error_detail = error_data.get('error', str(error_data)) + raise Exception(f'Ollama embed error ({r.status}): {error_detail}') + data = await r.json() + if 'embeddings' in data: + return data['embeddings'] + else: + raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key") def get_embedding_function( @@ -832,11 +972,12 @@ async def generate_batch_with_semaphore(batch): for batch in batches: batch_results.append(await embedding_function(batch, prefix=prefix, user=user)) - # Flatten results + # Flatten results — raise if any batch failed embeddings = [] - for batch_embeddings in batch_results: - if isinstance(batch_embeddings, list): - embeddings.extend(batch_embeddings) + for i, batch_embeddings in enumerate(batch_results): + if batch_embeddings is None: + raise Exception(f'Embedding generation failed for batch {i + 1}/{len(batches)}') + embeddings.extend(batch_embeddings) log.debug( f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches' @@ -878,11 +1019,15 @@ async def generate_embeddings( 'user': user, } ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings elif engine == 'openai': embeddings = await agenerate_openai_batch_embeddings( model, text if isinstance(text, list) else [text], url, key, prefix, user ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings elif engine == 'azure_openai': azure_api_version = kwargs.get('azure_api_version', '') @@ -895,10 +1040,12 @@ async def generate_embeddings( prefix, user, ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings -def get_reranking_function(reranking_engine, reranking_model, reranking_function): +def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32): if reranking_function is None: return None if reranking_engine == 'external': @@ -907,10 +1054,61 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function ) else: return lambda query, documents, user=None: reranking_function.predict( - [(query, doc.page_content) for doc in documents] + [(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size) ) +async def filter_accessible_collections( + collection_names: set[str], + user: UserModel, + access_type: str = 'read', +) -> set[str]: + """ + Return only the collection names the user is allowed to access. + Admins bypass all checks. For non-admins the policy is: + + - file-* → validated via has_access_to_file + - user-memory-* → must match user's own memory collection + - web-search-* → ephemeral per-query collections, always allowed + - knowledge-bases → always denied (system meta-collection) + - everything else → if the name matches a knowledge base, validated + via Knowledges.check_access_by_user_id; if no + such KB exists, the name is treated as an + ephemeral/legacy collection and allowed + """ + if user.role == 'admin': + return collection_names + + validated = set() + for name in collection_names: + if name == 'knowledge-bases': + # System meta-collection — never exposed to non-admins. + continue + elif name.startswith('file-'): + file_id = name[len('file-') :] + if await has_access_to_file(file_id=file_id, access_type=access_type, user=user): + validated.add(name) + elif name.startswith('user-memory-'): + if name == f'user-memory-{user.id}': + validated.add(name) + elif name.startswith('web-search-'): + # Ephemeral collections created by process_web_search — safe + # to allow because they contain only transient web-search + # results scoped to the requesting user's session. + validated.add(name) + else: + # May be a knowledge-base ID or a legacy/ephemeral collection. + # If it IS a KB, enforce access control. If no such KB + # exists, treat it as a non-sensitive collection (e.g. legacy + # model knowledge, process_text SHA256 collections) and allow. + if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type): + validated.add(name) + elif not await Knowledges.get_knowledge_by_id(name): + # Not a KB at all — legacy/ephemeral collection, allow + validated.add(name) + return validated + + async def get_sources_from_items( request, items, @@ -966,12 +1164,12 @@ async def get_sources_from_items( elif item.get('type') == 'note': # Note Attached - note = Notes.get_note_by_id(item.get('id')) + note = await Notes.get_note_by_id(item.get('id')) if note and ( user.role == 'admin' or note.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -986,7 +1184,7 @@ async def get_sources_from_items( elif item.get('type') == 'chat': # Chat Attached - chat = Chats.get_chat_by_id(item.get('id')) + chat = await Chats.get_chat_by_id(item.get('id')) if chat and (user.role == 'admin' or chat.user_id == user.id): messages_map = chat.chat.get('history', {}).get('messages', {}) @@ -1030,8 +1228,12 @@ async def get_sources_from_items( ], } elif item.get('id'): - file_object = Files.get_file_by_id(item.get('id')) - if file_object: + file_object = await Files.get_file_by_id(item.get('id')) + if file_object and ( + user.role == 'admin' + or file_object.user_id == user.id + or await has_access_to_file(item.get('id'), 'read', user) + ): query_result = { 'documents': [[file_object.data.get('content', '')]], 'metadatas': [ @@ -1053,12 +1255,12 @@ async def get_sources_from_items( elif item.get('type') == 'collection': # Manual Full Mode Toggle for Collection - knowledge_base = Knowledges.get_knowledge_by_id(item.get('id')) + knowledge_base = await Knowledges.get_knowledge_by_id(item.get('id')) if knowledge_base and ( user.role == 'admin' or knowledge_base.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge_base.id, @@ -1069,14 +1271,14 @@ async def get_sources_from_items( if knowledge_base and ( user.role == 'admin' or knowledge_base.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge_base.id, permission='read', ) ): - files = Knowledges.get_files_by_id(knowledge_base.id) + files = await Knowledges.get_files_by_id(knowledge_base.id) documents = [] metadatas = [] @@ -1122,35 +1324,26 @@ async def get_sources_from_items( log.debug(f'skipping {item} as it has already been extracted') continue + # Filter out collections the user cannot read + if user: + collection_names = await filter_accessible_collections(collection_names, user) + if not collection_names: + log.debug(f'access denied for all collections in item {item}') + continue + try: if full_context: - query_result = get_all_items_from_collections(collection_names) + # Sync helper makes blocking VECTOR_DB_CLIENT calls; + # offload so the async caller's event loop stays free. + query_result = await asyncio.to_thread(get_all_items_from_collections, collection_names) else: - query_result = None # Initialize to None - if hybrid_search: - try: - query_result = await query_collection_with_hybrid_search( - collection_names=collection_names, - queries=queries, - embedding_function=embedding_function, - k=k, - reranking_function=reranking_function, - k_reranker=k_reranker, - r=r, - hybrid_bm25_weight=hybrid_bm25_weight, - enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, - ) - except Exception as e: - log.debug('Error when using hybrid search, using non hybrid search as fallback.') - - # fallback to non-hybrid search - if not hybrid_search and query_result is None: - query_result = await query_collection( - collection_names=collection_names, - queries=queries, - embedding_function=embedding_function, - k=k, - ) + query_result = await query_collection( + request, + collection_names=collection_names, + queries=queries, + embedding_function=embedding_function, + k=k, + ) except Exception as e: log.exception(e) diff --git a/backend/open_webui/retrieval/vector/async_client.py b/backend/open_webui/retrieval/vector/async_client.py new file mode 100644 index 00000000000..0bea6696a9d --- /dev/null +++ b/backend/open_webui/retrieval/vector/async_client.py @@ -0,0 +1,129 @@ +""" +Async facade over the synchronous VECTOR_DB_CLIENT. + +The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant, +Milvus, OpenSearch, Pinecone, Weaviate, …) all expose a uniformly +synchronous API. Each method performs blocking network or disk I/O — and +some, like `insert`/`upsert`, can run for several seconds. + +When such a sync method is awaited from an async route handler, it blocks +the event loop for its entire duration, freezing every other in-flight +HTTP request, websocket message and background task. + +This module wraps the sync client in an `AsyncVectorDBClient` that +transparently dispatches each call to a worker thread via +`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)` +in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive. + +The original `VECTOR_DB_CLIENT` is unchanged, so callers already running +inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not +affected. + +Thread-safety expectations +-------------------------- +Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread +rather than the event-loop thread, and many can run concurrently. The +sync client (and its underlying backend driver) is therefore expected +to be safe for concurrent use across threads, which is the standard +contract for the bundled drivers (chroma, pgvector via SQLAlchemy +pool, qdrant-client, opensearch-py, …). This is *not* a new exposure +introduced by this facade — `save_docs_to_vector_db` already called +the sync client from `run_in_threadpool`, so concurrent threaded +access has always been a requirement of the codebase. Adding a global +serialization lock here would defeat the responsiveness this facade +exists to provide; any backend that genuinely cannot tolerate +concurrent access should grow its own internal serialization. + +API surface +----------- +Method signatures mirror `VectorDBBase` exactly. This is deliberate: +permissive `*args/**kwargs` forwarding hides typos at the call site +(an earlier revision of this file shipped that, and a `metadata=` +typo silently broke an entire endpoint until explicit signatures +surfaced it). Callers that need a backend-specific parameter not on +`VectorDBBase` should reach for the `.sync` escape hatch and wrap +their own `asyncio.to_thread`, e.g. :: + + await asyncio.to_thread( + ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op, + collection_name, special_kwarg=value, + ) +""" + +from __future__ import annotations + +import asyncio +from typing import Dict, List, Optional, Union + +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.main import ( + GetResult, + SearchResult, + VectorDBBase, + VectorItem, +) + + +class AsyncVectorDBClient: + """Awaitable mirror of `VectorDBBase` that off-loads each call to a thread. + + Method signatures mirror `VectorDBBase` exactly so static analysis + catches bad kwargs at the call site instead of letting them surface + deep inside the worker thread (where the resulting ``TypeError`` is + typically swallowed by surrounding ``try/except``). + """ + + def __init__(self, sync_client: VectorDBBase) -> None: + self._sync = sync_client + + @property + def sync(self) -> VectorDBBase: + """Escape hatch for code that must call the sync client directly + (e.g. already inside a worker thread).""" + return self._sync + + async def has_collection(self, collection_name: str) -> bool: + return await asyncio.to_thread(self._sync.has_collection, collection_name) + + async def delete_collection(self, collection_name: str) -> None: + return await asyncio.to_thread(self._sync.delete_collection, collection_name) + + async def insert(self, collection_name: str, items: List[VectorItem]) -> None: + return await asyncio.to_thread(self._sync.insert, collection_name, items) + + async def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + return await asyncio.to_thread(self._sync.upsert, collection_name, items) + + async def search( + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[Dict] = None, + limit: int = 10, + ) -> Optional[SearchResult]: + return await asyncio.to_thread(self._sync.search, collection_name, vectors, filter, limit) + + async def query( + self, + collection_name: str, + filter: Dict, + limit: Optional[int] = None, + ) -> Optional[GetResult]: + return await asyncio.to_thread(self._sync.query, collection_name, filter, limit) + + async def get(self, collection_name: str) -> Optional[GetResult]: + return await asyncio.to_thread(self._sync.get, collection_name) + + async def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + return await asyncio.to_thread(self._sync.delete, collection_name, ids, filter) + + async def reset(self) -> None: + return await asyncio.to_thread(self._sync.reset) + + +ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT) diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 4775ff21f4d..90e65b9ad07 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -34,6 +34,7 @@ SearchResult, GetResult, ) +from open_webui.utils.misc import sanitize_text_for_db from open_webui.config import ( PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH, @@ -289,7 +290,9 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: vector = self.adjust_vector_length(item['vector']) # Use raw SQL for BYTEA/pgcrypto # Ensure metadata is converted to its JSON text representation - json_metadata = json.dumps(item['metadata']) + # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store + json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" INSERT INTO document_chunk @@ -305,7 +308,7 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: 'id': item['id'], 'vector': vector, 'collection_name': collection_name, - 'text': item['text'], + 'text': item_text, 'metadata_text': json_metadata, 'key': PGVECTOR_PGCRYPTO_KEY, }, @@ -338,7 +341,9 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: if PGVECTOR_PGCRYPTO: for item in items: vector = self.adjust_vector_length(item['vector']) - json_metadata = json.dumps(item['metadata']) + # Sanitize to strip null bytes / surrogates that PostgreSQL cannot store + json_metadata = sanitize_text_for_db(json.dumps(item['metadata'])) + item_text = sanitize_text_for_db(item['text']) self.session.execute( text(""" INSERT INTO document_chunk @@ -358,7 +363,7 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: 'id': item['id'], 'vector': vector, 'collection_name': collection_name, - 'text': item['text'], + 'text': item_text, 'metadata_text': json_metadata, 'key': PGVECTOR_PGCRYPTO_KEY, }, diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index b2e2fed7620..4915b024c30 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -1,5 +1,7 @@ from datetime import datetime +from open_webui.utils.misc import sanitize_text_for_db + KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures'] @@ -12,7 +14,8 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: def process_metadata( metadata: dict[str, any], ) -> dict[str, any]: - # Removes large fields and converts non-serializable types (datetime, list, dict) to strings. + # Removes large fields, converts non-serializable types (datetime, list, dict) to strings, + # and sanitizes strings for database storage (strips null bytes and invalid surrogates). result = {} for key, value in metadata.items(): # Skip large fields @@ -20,7 +23,7 @@ def process_metadata( continue # Convert non-serializable fields to strings if isinstance(value, (datetime, list, dict)): - result[key] = str(value) + result[key] = sanitize_text_for_db(str(value)) else: - result[key] = value + result[key] = sanitize_text_for_db(value) return result diff --git a/backend/open_webui/retrieval/web/brave_llm_context.py b/backend/open_webui/retrieval/web/brave_llm_context.py new file mode 100644 index 00000000000..ead410e7d49 --- /dev/null +++ b/backend/open_webui/retrieval/web/brave_llm_context.py @@ -0,0 +1,66 @@ +import logging +import time +from typing import Optional + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results + +log = logging.getLogger(__name__) + + +def search_brave_llm_context( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, + context_tokens: int = 8192, +) -> list[SearchResult]: + """Search using Brave's LLM Context API and return pre-extracted, relevance-scored + page content ready for LLM consumption. + + Uses /res/v1/llm/context instead of /res/v1/web/search. Same API key, same pricing. + Returns full extracted passages per URL rather than short snippets, eliminating + the need for post-search scraping. + + Args: + api_key (str): A Brave Search API key (same key as web search) + query (str): The query to search for + count (int): Maximum number of results to return + filter_list (list[str], optional): Domain filter list + context_tokens (int): Maximum total tokens to retrieve (1024–32768, default 8192) + """ + url = 'https://api.search.brave.com/res/v1/llm/context' + headers = { + 'Accept': 'application/json', + 'Accept-Encoding': 'gzip', + 'X-Subscription-Token': api_key, + } + params = { + 'q': query, + 'count': count, + 'maximum_number_of_tokens': context_tokens, + } + + response = requests.get(url, headers=headers, params=params) + + # Handle 429 rate limiting - same rate limits as web search + if response.status_code == 429: + log.info('Brave LLM Context API rate limited (429), retrying after 1 second...') + time.sleep(1) + response = requests.get(url, headers=headers, params=params) + + response.raise_for_status() + + json_response = response.json() + results = json_response.get('grounding', {}).get('generic', []) + if filter_list: + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result['url'], + title=result.get('title'), + snippet='\n\n'.join(result.get('snippets', [])), + ) + for result in results[:count] + ] diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index da1c3f77ecc..5b2f076227e 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -1,4 +1,5 @@ import logging +import urllib.request from typing import Optional from open_webui.retrieval.web.main import SearchResult, get_filtered_results @@ -25,17 +26,25 @@ def search_duckduckgo( Returns: list[SearchResult]: A list of search results """ - # Use the DDGS context manager to create a DDGS object + # The ddgs library (primp-based) does not auto-detect proxy env vars. + # Resolve via stdlib getproxies() — same pattern as the other loaders. + env_proxies = urllib.request.getproxies() + proxy = env_proxies.get('https') or env_proxies.get('http') search_results = [] - with DDGS() as ddgs: + with DDGS(proxy=proxy) as ddgs: if concurrent_requests: ddgs.threads = concurrent_requests # Use the ddgs.text() method to perform the search try: - search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend) + kwargs = {'safesearch': 'moderate', 'max_results': count} + if backend and backend != 'auto': + kwargs['backend'] = backend + results = ddgs.text(query, **kwargs) + search_results = results if results is not None else [] except RatelimitException as e: log.error(f'RatelimitException: {e}') + search_results = [] if filter_list: search_results = get_filtered_results(search_results, filter_list) diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index 4bb23e3797c..4bbd4f212b6 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -1,36 +1,229 @@ +from __future__ import annotations + import logging -from typing import Optional, List +import time +from typing import TYPE_CHECKING, Any + +import requests +from langchain_core.documents import Document -from open_webui.retrieval.web.main import SearchResult, get_filtered_results +if TYPE_CHECKING: + from open_webui.retrieval.web.main import SearchResult log = logging.getLogger(__name__) +DEFAULT_FIRECRAWL_API_BASE_URL = 'https://api.firecrawl.dev' +FIRECRAWL_RETRY_STATUS_CODES = {429, 500, 502, 503, 504} +FIRECRAWL_MAX_RETRIES = 2 + + +def build_firecrawl_url(base_url: str | None, path: str) -> str: + base_url = (base_url or DEFAULT_FIRECRAWL_API_BASE_URL).rstrip('/') + path = path.lstrip('/') + + if base_url.endswith('/v2'): + return f'{base_url}/{path}' + + return f'{base_url}/v2/{path}' + + +def build_firecrawl_headers(api_key: str | None) -> dict[str, str]: + return { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key or ""}', + } + + +def get_firecrawl_timeout_seconds(timeout: Any) -> float | None: + if timeout in (None, ''): + return None + + try: + timeout = float(timeout) + except (TypeError, ValueError): + return None + + return timeout if timeout > 0 else None + + +def get_firecrawl_scrape_timeout_ms(timeout: Any) -> int | None: + timeout_seconds = get_firecrawl_timeout_seconds(timeout) + if timeout_seconds is None: + return None + + # Firecrawl v2 expects scrape timeouts in milliseconds. + return min(300000, max(1000, int(timeout_seconds * 1000))) + + +def get_firecrawl_client_timeout_seconds(timeout: Any, fallback: float = 60) -> float: + # Keep the local HTTP timeout slightly above Firecrawl's scrape timeout. + return (get_firecrawl_timeout_seconds(timeout) or fallback) + 10 + + +def get_firecrawl_retry_delay(headers: Any, attempt: int) -> float: + retry_after = headers.get('Retry-After') if headers else None + if retry_after: + try: + return min(10.0, max(0.0, float(retry_after))) + except (TypeError, ValueError): + pass + + return min(8.0, float(2**attempt)) + + +def request_firecrawl_json( + method: str, + url: str, + *, + headers: dict[str, str], + json: dict[str, Any] | None = None, + timeout: float | None = None, + verify: bool = True, +) -> dict[str, Any]: + last_error = None + + for attempt in range(FIRECRAWL_MAX_RETRIES + 1): + try: + response = requests.request( + method, + url, + headers=headers, + json=json, + timeout=timeout, + verify=verify, + ) + + if response.status_code in FIRECRAWL_RETRY_STATUS_CODES and attempt < FIRECRAWL_MAX_RETRIES: + delay = get_firecrawl_retry_delay(response.headers, attempt) + log.warning( + 'Firecrawl %s %s returned HTTP %s; retrying in %.1fs', + method, + url, + response.status_code, + delay, + ) + time.sleep(delay) + continue + + response.raise_for_status() + return response.json() + except (requests.ConnectionError, requests.Timeout) as e: + last_error = e + if attempt >= FIRECRAWL_MAX_RETRIES: + break + + delay = get_firecrawl_retry_delay(None, attempt) + log.warning('Firecrawl %s %s failed; retrying in %.1fs: %s', method, url, delay, e) + time.sleep(delay) + + if last_error: + raise last_error + + raise RuntimeError(f'Firecrawl {method} {url} failed without a response') + + +def get_firecrawl_result_url(result: dict[str, Any]) -> str: + metadata = result.get('metadata') or {} + return ( + result.get('url') + or result.get('link') + or metadata.get('url') + or metadata.get('sourceURL') + or metadata.get('source_url') + or '' + ) + + +def scrape_firecrawl_url( + firecrawl_url: str, + firecrawl_api_key: str, + url: str, + *, + verify_ssl: bool = True, + timeout: Any = None, + params: dict[str, Any] | None = None, +) -> Document | None: + payload = { + 'url': url, + 'formats': ['markdown'], + 'skipTlsVerification': not verify_ssl, + 'removeBase64Images': True, + **(params or {}), + } + scrape_timeout_ms = get_firecrawl_scrape_timeout_ms(timeout) + if scrape_timeout_ms is not None: + payload['timeout'] = scrape_timeout_ms + + response = request_firecrawl_json( + 'POST', + build_firecrawl_url(firecrawl_url, 'scrape'), + headers=build_firecrawl_headers(firecrawl_api_key), + json=payload, + timeout=get_firecrawl_client_timeout_seconds(timeout), + verify=verify_ssl, + ) + data = response.get('data') or {} + content = data.get('markdown') or '' + if not isinstance(content, str) or not content.strip(): + return None + + metadata = data.get('metadata') or {} + document_metadata = {'source': get_firecrawl_result_url(data) or url} + if metadata.get('title'): + document_metadata['title'] = metadata['title'] + if metadata.get('description'): + document_metadata['description'] = metadata['description'] + + return Document(page_content=content, metadata=document_metadata) + def search_firecrawl( firecrawl_url: str, firecrawl_api_key: str, query: str, count: int, - filter_list: Optional[List[str]] = None, -) -> List[SearchResult]: + filter_list: list[str] | None = None, +) -> list[SearchResult]: try: - from firecrawl import FirecrawlApp + response = request_firecrawl_json( + 'POST', + build_firecrawl_url(firecrawl_url, 'search'), + headers=build_firecrawl_headers(firecrawl_api_key), + json={ + 'query': query, + 'limit': count, + 'timeout': count * 3000, + 'ignoreInvalidURLs': True, + }, + timeout=count * 3 + 10, + ) + data = response.get('data') or {} + results = data.get('web') or [] - firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url) - response = firecrawl.search(query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3) - results = response.web if filter_list: + from open_webui.retrieval.web.main import get_filtered_results + results = get_filtered_results(results, filter_list) - results = [ - SearchResult( - link=result.url, - title=result.title, - snippet=result.description, + + from open_webui.retrieval.web.main import SearchResult + + search_results = [] + for result in results[:count]: + url = get_firecrawl_result_url(result) + if not url: + continue + + metadata = result.get('metadata') or {} + search_results.append( + SearchResult( + link=url, + title=result.get('title') or metadata.get('title'), + snippet=result.get('description') or result.get('snippet') or metadata.get('description'), + ) ) - for result in results[:count] - ] - log.info(f'External search results: {results}') - return results + + log.info(f'FireCrawl search results: {search_results}') + return search_results except Exception as e: - log.error(f'Error in External search: {e}') + log.error(f'Error in FireCrawl search: {e}') return [] diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index 0335bea9a3c..2b7bd048956 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -38,7 +38,7 @@ def search_searxng( """ # Default values for optional parameters are provided as empty strings or None when not specified. - language = kwargs.get('language', 'all') + language = kwargs.get('language', 'all').strip().rstrip(',') safesearch = kwargs.get('safesearch', '1') time_range = kwargs.get('time_range', '') categories = ''.join(kwargs.get('categories', [])) diff --git a/backend/open_webui/retrieval/web/serper.py b/backend/open_webui/retrieval/web/serper.py index 98ab40cb19c..9f1a8e1b3a0 100644 --- a/backend/open_webui/retrieval/web/serper.py +++ b/backend/open_webui/retrieval/web/serper.py @@ -31,7 +31,7 @@ def search_serper(api_key: str, query: str, count: int, filter_list: Optional[li SearchResult( link=result['link'], title=result.get('title'), - snippet=result.get('description'), + snippet=result.get('snippet'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index c9442f208bd..c2ce6bdbd11 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -1,9 +1,12 @@ import asyncio +import ipaddress import logging import socket import ssl import urllib.parse import urllib.request + +import requests from datetime import datetime, time, timedelta from typing import ( Any, @@ -27,6 +30,7 @@ from open_webui.retrieval.loaders.tavily import TavilyLoader from open_webui.retrieval.loaders.external_web import ExternalWebLoader +from open_webui.retrieval.web.firecrawl import scrape_firecrawl_url from open_webui.constants import ERROR_MESSAGES from open_webui.config import ( ENABLE_RAG_LOCAL_WEB_FETCH, @@ -44,6 +48,7 @@ WEB_FETCH_FILTER_LIST, ) from open_webui.utils.misc import is_string_allowed +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS log = logging.getLogger(__name__) @@ -64,6 +69,14 @@ def validate_url(url: Union[str, Sequence[str]]): if isinstance(validators.url(url), validators.ValidationError): raise ValueError(ERROR_MESSAGES.INVALID_URL) + # Reject parser-confusing chars: urlparse and requests/aiohttp split + # on these differently, e.g. http://127.0.0.1\@1.1.1.1 → urlparse + # extracts 1.1.1.1 (public, passes filter) while requests connects + # to 127.0.0.1 (internal). Same shape with tab/CR/LF. + if any(ch in url for ch in ('\\', '\t', '\n', '\r')): + log.warning(f'Blocked URL with parser-confusing char: {url!r}') + raise ValueError(ERROR_MESSAGES.INVALID_URL) + parsed_url = urllib.parse.urlparse(url) # Protocol validation - only allow http/https @@ -84,11 +97,9 @@ def validate_url(url: Union[str, Sequence[str]]): ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname) # Check if any of the resolved addresses are private # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader - for ip in ipv4_addresses: - if validators.ipv4(ip, private=True): - raise ValueError(ERROR_MESSAGES.INVALID_URL) - for ip in ipv6_addresses: - if validators.ipv6(ip, private=True): + for ip in ipv4_addresses + ipv6_addresses: + addr = ipaddress.ip_address(ip) + if not addr.is_global: raise ValueError(ERROR_MESSAGES.INVALID_URL) return True elif isinstance(url, Sequence): @@ -193,27 +204,6 @@ def __init__( proxy: Optional[Dict[str, str]] = None, params: Optional[Dict] = None, ): - """Concurrent document loader for FireCrawl operations. - - Executes multiple FireCrawlLoader instances concurrently using thread pooling - to improve bulk processing efficiency. - Args: - web_paths: List of URLs/paths to process. - verify_ssl: If True, verify SSL certificates. - trust_env: If True, use proxy settings from environment variables. - requests_per_second: Number of requests per second to limit to. - continue_on_failure (bool): If True, continue loading other URLs on failure. - api_key: API key for FireCrawl service. Defaults to None - (uses FIRE_CRAWL_API_KEY environment variable if not provided). - api_url: Base URL for FireCrawl API. Defaults to official API endpoint. - mode: Operation mode selection: - - 'crawl': Website crawling mode - - 'scrape': Direct page scraping (default) - - 'map': Site map generation - proxy: Proxy override settings for the FireCrawl API. - params: The parameters to pass to the Firecrawl API. - For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape - """ proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: env_proxies = urllib.request.getproxies() @@ -230,86 +220,38 @@ def __init__( self.trust_env = trust_env self.continue_on_failure = continue_on_failure self.api_key = api_key - self.api_url = api_url + self.api_url = (api_url or 'https://api.firecrawl.dev').rstrip('/') self.timeout = timeout self.mode = mode self.params = params or {} def lazy_load(self) -> Iterator[Document]: - """Load documents using FireCrawl batch_scrape.""" - log.debug( - 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', - len(self.web_paths), - self.mode, - self.params, - ) try: - from firecrawl import FirecrawlApp - - firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) - result = firecrawl.batch_scrape( - self.web_paths, - formats=['markdown'], - skip_tls_verification=not self.verify_ssl, - ignore_invalid_urls=True, - remove_base64_images=True, - max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, - **self.params, - ) - - if result.status != 'completed': - raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') - - for data in result.data: - metadata = data.metadata or {} - yield Document( - page_content=data.markdown or '', - metadata={'source': metadata.url or metadata.source_url or ''}, + for url in self.web_paths: + doc = scrape_firecrawl_url( + self.api_url, + self.api_key, + url, + verify_ssl=self.verify_ssl, + timeout=self.timeout, + params=self.params, ) - + if doc is not None: + yield doc except Exception as e: if self.continue_on_failure: - log.exception(f'Error extracting content from URLs: {e}') + log.warning(f'Error extracting content from URLs with Firecrawl: {e}') else: raise e async def alazy_load(self): - """Async version of lazy_load.""" - log.debug( - 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', - len(self.web_paths), - self.mode, - self.params, - ) try: - from firecrawl import FirecrawlApp - - firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) - result = firecrawl.batch_scrape( - self.web_paths, - formats=['markdown'], - skip_tls_verification=not self.verify_ssl, - ignore_invalid_urls=True, - remove_base64_images=True, - max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, - **self.params, - ) - - if result.status != 'completed': - raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') - - for data in result.data: - metadata = data.metadata or {} - yield Document( - page_content=data.markdown or '', - metadata={'source': metadata.url or metadata.source_url or ''}, - ) - + docs = await run_in_threadpool(lambda: list(self.lazy_load())) + for doc in docs: + yield doc except Exception as e: if self.continue_on_failure: - log.exception(f'Error extracting content from URLs: {e}') + log.warning(f'Error extracting content from URLs with Firecrawl: {e}') else: raise e @@ -551,6 +493,17 @@ def __init__(self, trust_env: bool = False, *args, **kwargs): """ super().__init__(*args, **kwargs) self.trust_env = trust_env + # Prevent redirect-based SSRF on the synchronous _scrape() path. + # validate_url() is called once on the originally-submitted URL, but the + # parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs) + # which by default follows redirects. Without the override below, an attacker + # can submit a public URL that 302-redirects to an internal address (RFC1918, + # 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without + # re-validation. Matches the policy enforced on the async _fetch() path below. + self.requests_kwargs = { + **(self.requests_kwargs or {}), + 'allow_redirects': AIOHTTP_CLIENT_ALLOW_REDIRECTS, + } async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str: async with aiohttp.ClientSession(trust_env=self.trust_env) as session: @@ -562,11 +515,13 @@ async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: f ) if not self.session.verify: kwargs['ssl'] = False + else: + kwargs['ssl'] = AIOHTTP_CLIENT_SESSION_SSL async with session.get( url, **(self.requests_kwargs | kwargs), - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if self.raise_for_status: response.raise_for_status() diff --git a/backend/open_webui/retrieval/web/yandex.py b/backend/open_webui/retrieval/web/yandex.py index 352d2a3afbc..1fffac8f611 100644 --- a/backend/open_webui/retrieval/web/yandex.py +++ b/backend/open_webui/retrieval/web/yandex.py @@ -20,6 +20,8 @@ def xml_element_contents_to_string(element: Element) -> str: + if element is None: + return '' buffer = [element.text if element.text else ''] for child in element: diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 790c1342950..fd045f79e72 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -11,8 +11,8 @@ from open_webui.models.users import Users from open_webui.models.feedbacks import Feedbacks from open_webui.utils.auth import get_admin_user -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -59,10 +59,12 @@ async def get_model_analytics( end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get message counts per model.""" - counts = ChatMessages.get_message_count_by_model(start_date=start_date, end_date=end_date, group_id=group_id, db=db) + counts = await ChatMessages.get_message_count_by_model( + start_date=start_date, end_date=end_date, group_id=group_id, db=db + ) models = [ ModelAnalyticsEntry(model_id=model_id, count=count) for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) @@ -77,17 +79,19 @@ async def get_user_analytics( group_id: Optional[str] = Query(None, description='Filter by user group ID'), limit: int = Query(50, description='Max users to return'), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get message counts and token usage per user with user info.""" - counts = ChatMessages.get_message_count_by_user(start_date=start_date, end_date=end_date, group_id=group_id, db=db) - token_usage = ChatMessages.get_token_usage_by_user( + counts = await ChatMessages.get_message_count_by_user( + start_date=start_date, end_date=end_date, group_id=group_id, db=db + ) + token_usage = await ChatMessages.get_token_usage_by_user( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) # Get user info for top users top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]] - user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)} + user_info = {u.id: u for u in await Users.get_users_by_user_ids(top_user_ids, db=db)} users = [] for user_id in top_user_ids: @@ -118,13 +122,13 @@ async def get_messages( skip: int = Query(0), limit: int = Query(50, le=100), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Query messages with filters.""" if chat_id: - return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db) + return await ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db) elif model_id: - return ChatMessages.get_messages_by_model_id( + return await ChatMessages.get_messages_by_model_id( model_id=model_id, start_date=start_date, end_date=end_date, @@ -133,7 +137,7 @@ async def get_messages( db=db, ) elif user_id: - return ChatMessages.get_messages_by_user_id(user_id=user_id, skip=skip, limit=limit, db=db) + return await ChatMessages.get_messages_by_user_id(user_id=user_id, skip=skip, limit=limit, db=db) else: # Return empty if no filter specified return [] @@ -152,16 +156,16 @@ async def get_summary( end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get summary statistics for the dashboard.""" - model_counts = ChatMessages.get_message_count_by_model( + model_counts = await ChatMessages.get_message_count_by_model( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) - user_counts = ChatMessages.get_message_count_by_user( + user_counts = await ChatMessages.get_message_count_by_user( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) - chat_counts = ChatMessages.get_message_count_by_chat( + chat_counts = await ChatMessages.get_message_count_by_chat( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) @@ -189,13 +193,13 @@ async def get_daily_stats( group_id: Optional[str] = Query(None, description='Filter by user group ID'), granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get message counts grouped by model for time-series chart.""" if granularity == 'hourly': - counts = ChatMessages.get_hourly_message_counts_by_model(start_date=start_date, end_date=end_date, db=db) + counts = await ChatMessages.get_hourly_message_counts_by_model(start_date=start_date, end_date=end_date, db=db) else: - counts = ChatMessages.get_daily_message_counts_by_model( + counts = await ChatMessages.get_daily_message_counts_by_model( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) return DailyStatsResponse( @@ -224,10 +228,12 @@ async def get_token_usage( end_date: Optional[int] = Query(None), group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get token usage aggregated by model.""" - usage = ChatMessages.get_token_usage_by_model(start_date=start_date, end_date=end_date, group_id=group_id, db=db) + usage = await ChatMessages.get_token_usage_by_model( + start_date=start_date, end_date=end_date, group_id=group_id, db=db + ) models = [ TokenUsageEntry(model_id=model_id, **data) @@ -271,12 +277,12 @@ async def get_model_chats( skip: int = Query(0), limit: int = Query(50, le=100), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get chats that used a specific model, with preview and feedback info.""" # Get chat IDs that used this model - chat_ids = ChatMessages.get_chat_ids_by_model_id( + chat_ids = await ChatMessages.get_chat_ids_by_model_id( model_id=model_id, start_date=start_date, end_date=end_date, @@ -291,7 +297,7 @@ async def get_model_chats( # Get chat details from messages only chats_data = [] for chat_id in chat_ids: - messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db) + messages = await ChatMessages.get_messages_by_chat_id(chat_id, db=db) if not messages: continue @@ -312,7 +318,7 @@ async def get_model_chats( # Get user info user_name = None if user_id: - user_info = Users.get_user_by_id(user_id, db=db) + user_info = await Users.get_user_by_id(user_id, db=db) user_name = user_info.name if user_info else None # Timestamps from messages @@ -357,12 +363,12 @@ async def get_model_overview( model_id: str, days: int = Query(30, description='Number of days of history (0 for all)'), user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get model overview with feedback history and chat tags.""" # Get chat IDs that used this model - chat_ids = ChatMessages.get_chat_ids_by_model_id( + chat_ids = await ChatMessages.get_chat_ids_by_model_id( model_id=model_id, start_date=None, end_date=None, @@ -381,7 +387,7 @@ async def get_model_overview( start_dt = now - timedelta(days=days) for chat_id in chat_ids: - feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db) + feedbacks = await Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db) for fb in feedbacks: if fb.data and 'rating' in fb.data: rating = fb.data['rating'] @@ -425,7 +431,7 @@ async def get_model_overview( # Get chat tags tag_counts: dict[str, int] = defaultdict(int) for chat_id in chat_ids: - chat = Chats.get_chat_by_id(chat_id, db=db) + chat = await Chats.get_chat_by_id(chat_id, db=db) if chat and chat.meta: for tag in chat.meta.get('tags', []): tag_counts[tag] += 1 diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 7e1fd9e3ee8..6366f0e72b1 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -1,3 +1,5 @@ +import asyncio +import io import hashlib import json import logging @@ -5,7 +7,6 @@ import uuid import html import base64 -from functools import lru_cache from pydub import AudioSegment from pydub.silence import split_on_silence from concurrent.futures import ThreadPoolExecutor @@ -54,6 +55,7 @@ AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, + BYPASS_PYDUB_PREPROCESSING, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, ) @@ -75,6 +77,8 @@ ########################################## # # Utility functions +# Let what is spoken here be heard clearly, and let +# no voice be reduced to noise along the way. # ########################################## @@ -125,6 +129,54 @@ def convert_audio_to_mp3(file_path): return None +def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_path: str) -> bool: + """ + Transcode audio bytes to MP3 if the Content-Type indicates a non-MP3 format. + + Handles raw PCM audio (e.g. Gemini-TTS via OpenRouter/LiteLLM) by parsing + optional rate/channels from the Content-Type params, defaulting to 24kHz, + 16-bit, mono. For other non-MP3 formats, uses pydub auto-detection. + + Returns True if transcoding was performed, False if the data is already MP3. + Respects BYPASS_PYDUB_PREPROCESSING — when set, writes raw bytes and logs a warning. + """ + mime_type = content_type_header.split(';')[0].strip().lower() + + if mime_type in ('audio/mpeg', 'audio/mp3'): + return False + + if BYPASS_PYDUB_PREPROCESSING: + log.warning( + f'TTS returned {mime_type} but BYPASS_PYDUB_PREPROCESSING is set; writing raw audio without transcoding' + ) + return False + + if mime_type in ('audio/pcm', 'audio/l16', 'audio/raw'): + # Parse optional rate/channels from Content-Type params, + # default: 24kHz, 16-bit, mono (standard for Gemini TTS). + ct_params = {} + for part in content_type_header.split(';')[1:]: + key_val = part.strip().split('=') + if len(key_val) == 2: + ct_params[key_val[0].strip().lower()] = key_val[1].strip() + + sample_rate = int(ct_params.get('rate', 24000)) + channels = int(ct_params.get('channels', 1)) + + audio_segment = AudioSegment.from_raw( + io.BytesIO(audio_data), + sample_width=2, + frame_rate=sample_rate, + channels=channels, + ) + else: + audio_segment = AudioSegment.from_file(io.BytesIO(audio_data)) + + audio_segment.export(str(output_path), format='mp3') + log.info(f'Transcoded {mime_type} audio to MP3: {output_path}') + return True + + def set_faster_whisper_model(model: str, auto_update: bool = False): whisper_model = None if model: @@ -166,6 +218,8 @@ class TTSConfigForm(BaseModel): AZURE_SPEECH_REGION: str AZURE_SPEECH_BASE_URL: str AZURE_SPEECH_OUTPUT_FORMAT: str + MISTRAL_API_KEY: str + MISTRAL_API_BASE_URL: str class STTConfigForm(BaseModel): @@ -174,6 +228,7 @@ class STTConfigForm(BaseModel): ENGINE: str MODEL: str SUPPORTED_CONTENT_TYPES: list[str] = [] + ALLOWED_EXTENSIONS: list[str] = [] WHISPER_MODEL: str DEEPGRAM_API_KEY: str AZURE_API_KEY: str @@ -206,6 +261,8 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, + 'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY, + 'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL, }, 'stt': { 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, @@ -213,6 +270,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): 'ENGINE': request.app.state.config.STT_ENGINE, 'MODEL': request.app.state.config.STT_MODEL, 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, @@ -240,12 +298,15 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT + request.app.state.config.TTS_MISTRAL_API_KEY = form_data.tts.MISTRAL_API_KEY + request.app.state.config.TTS_MISTRAL_API_BASE_URL = form_data.tts.MISTRAL_API_BASE_URL request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY request.app.state.config.STT_ENGINE = form_data.stt.ENGINE request.app.state.config.STT_MODEL = form_data.stt.MODEL request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES + request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY @@ -278,6 +339,8 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, + 'MISTRAL_API_KEY': request.app.state.config.TTS_MISTRAL_API_KEY, + 'MISTRAL_API_BASE_URL': request.app.state.config.TTS_MISTRAL_API_BASE_URL, }, 'stt': { 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, @@ -285,6 +348,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm 'ENGINE': request.app.state.config.STT_ENGINE, 'MODEL': request.app.state.config.STT_MODEL, 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, @@ -320,7 +384,9 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.NOT_FOUND, ) - if user.role != 'admin' and not has_permission(user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS): + if user.role != 'admin' and not await has_permission( + user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -375,8 +441,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): r.raise_for_status() - async with aiofiles.open(file_path, 'wb') as f: - await f.write(await r.read()) + audio_data = await r.read() + content_type_header = r.headers.get('Content-Type', 'audio/mpeg') + + if not transcode_audio_to_mp3(audio_data, content_type_header, file_path): + async with aiofiles.open(file_path, 'wb') as f: + await f.write(audio_data) async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) @@ -408,7 +478,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): elif request.app.state.config.TTS_ENGINE == 'elevenlabs': voice_id = payload.get('voice', '') - if voice_id not in get_available_voices(request): + if voice_id not in await get_available_voices(request): raise HTTPException( status_code=400, detail='Invalid voice id', @@ -549,6 +619,77 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) + elif request.app.state.config.TTS_ENGINE == 'mistral': + api_key = request.app.state.config.TTS_MISTRAL_API_KEY + api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' + + if not api_key: + raise HTTPException( + status_code=400, + detail='Mistral API key is required for Mistral TTS', + ) + + try: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + mistral_payload = { + 'input': payload.get('input', ''), + 'model': request.app.state.config.TTS_MODEL or 'voxtral-mini-tts-2603', + 'voice_id': payload.get('voice', ''), + 'response_format': 'mp3', + } + + r = await session.post( + url=f'{api_base_url}/audio/speech', + json=mistral_payload, + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}', + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) + + r.raise_for_status() + + res = await r.json() + audio_data = res.get('audio_data', '') + if not audio_data: + raise ValueError('No audio_data in Mistral TTS response') + + audio_bytes = base64.b64decode(audio_data) + + async with aiofiles.open(file_path, 'wb') as f: + await f.write(audio_bytes) + + async with aiofiles.open(file_body_path, 'w') as f: + await f.write(json.dumps(payload)) + + return FileResponse(file_path) + + except Exception as e: + log.exception(e) + detail = None + + status_code = 500 + detail = 'Open WebUI: Server Connection Error' + + if r is not None: + status_code = r.status + + try: + res = await r.json() + if 'error' in res: + detail = f'External: {res["error"]}' + elif 'message' in res: + detail = f'External: {res["message"]}' + except Exception: + detail = f'External: {e}' + + raise HTTPException( + status_code=status_code, + detail=detail, + ) + def transcription_handler(request, file_path, metadata, user=None): filename = os.path.basename(file_path) @@ -580,7 +721,7 @@ def transcription_handler(request, file_path, metadata, user=None): data = {'text': transcript.strip()} # save the transcript to a json file - transcript_file = f'{file_dir}/{id}.json' + transcript_file = os.path.join(file_dir, f'{id}.json') with open(transcript_file, 'w') as f: json.dump(data, f) @@ -618,7 +759,7 @@ def transcription_handler(request, file_path, metadata, user=None): data = r.json() # save the transcript to a json file - transcript_file = f'{file_dir}/{id}.json' + transcript_file = os.path.join(file_dir, f'{id}.json') with open(transcript_file, 'w') as f: json.dump(data, f) @@ -687,7 +828,7 @@ def transcription_handler(request, file_path, metadata, user=None): data = {'text': transcript.strip()} # Save transcript - transcript_file = f'{file_dir}/{id}.json' + transcript_file = os.path.join(file_dir, f'{id}.json') with open(transcript_file, 'w') as f: json.dump(data, f) @@ -794,7 +935,7 @@ def transcription_handler(request, file_path, metadata, user=None): data = {'text': transcript} # Save transcript to json file (consistent with other providers) - transcript_file = f'{file_dir}/{id}.json' + transcript_file = os.path.join(file_dir, f'{id}.json') with open(transcript_file, 'w') as f: json.dump(data, f) @@ -891,7 +1032,10 @@ def transcription_handler(request, file_path, metadata, user=None): # Read and encode audio file as base64 with open(audio_file_to_use, 'rb') as audio_file: - audio_base64 = base64.b64encode(audio_file.read()).decode('utf-8') + audio_base64 = { + 'data': base64.b64encode(audio_file.read()).decode('utf-8'), + 'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'), + } # Prepare chat completions request url = f'{api_base_url}/chat/completions' @@ -979,7 +1123,7 @@ def transcription_handler(request, file_path, metadata, user=None): data = {'text': transcript} # Save transcript to json file (consistent with other providers) - transcript_file = f'{file_dir}/{id}.json' + transcript_file = os.path.join(file_dir, f'{id}.json') with open(transcript_file, 'w') as f: json.dump(data, f) @@ -1015,28 +1159,42 @@ def transcription_handler(request, file_path, metadata, user=None): def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None): log.info(f'transcribe: {file_path} {metadata}') - if is_audio_conversion_required(file_path): - file_path = convert_audio_to_mp3(file_path) + if BYPASS_PYDUB_PREPROCESSING: + log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)') + chunk_paths = [file_path] + else: + if is_audio_conversion_required(file_path): + file_path = convert_audio_to_mp3(file_path) + if not file_path: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Audio conversion failed. The audio file may be corrupted or empty.', + ) - try: - file_path = compress_audio(file_path) - except Exception as e: - log.exception(e) + try: + file_path = compress_audio(file_path) + except Exception as e: + log.exception(e) - # Always produce a list of chunk paths (could be one entry if small) - try: - chunk_paths = split_audio(file_path, MAX_FILE_SIZE) - print(f'Chunk paths: {chunk_paths}') - except Exception as e: - log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), - ) + # Always produce a list of chunk paths (could be one entry if small) + try: + chunk_paths = split_audio(file_path, MAX_FILE_SIZE) + print(f'Chunk paths: {chunk_paths}') + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) results = [] try: - with ThreadPoolExecutor() as executor: + if getattr(request.app.state.config, 'STT_ENGINE', '') == '': + max_workers = 1 + else: + max_workers = None + + with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit tasks for each chunk_path futures = [ executor.submit(transcription_handler, request, chunk_path, metadata, user) @@ -1128,13 +1286,15 @@ def split_audio(file_path, max_bytes, format='mp3', bitrate='32k'): @router.post('/transcriptions') -def transcription( +async def transcription( request: Request, file: UploadFile = File(...), language: Optional[str] = Form(None), user=Depends(get_verified_user), ): - if user.role != 'admin' and not has_permission(user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS): + if user.role != 'admin' and not await has_permission( + user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1150,16 +1310,23 @@ def transcription( try: safe_name = os.path.basename(file.filename) if file.filename else '' - ext = safe_name.rsplit('.', 1)[-1] if '.' in safe_name else '' + ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else '' + + allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', []) + if allowed_extensions and ext not in allowed_extensions: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid audio file extension', + ) id = uuid.uuid4() filename = f'{id}.{ext}' - contents = file.file.read() + contents = await file.read() - file_dir = f'{CACHE_DIR}/audio/transcriptions' + file_dir = os.path.join(CACHE_DIR, 'audio', 'transcriptions') os.makedirs(file_dir, exist_ok=True) - file_path = f'{file_dir}/{filename}' + file_path = os.path.join(file_dir, filename) # Defense-in-depth: ensure resolved path stays within intended directory if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)): @@ -1174,7 +1341,7 @@ def transcription( if language: metadata = {'language': language} - result = transcribe(request, file_path, metadata, user) + result = await asyncio.to_thread(transcribe, request, file_path, metadata, user) return { **result, @@ -1202,63 +1369,83 @@ def transcription( ) -def get_available_models(request: Request) -> list[dict]: +async def get_available_models(request: Request) -> list[dict]: available_models = [] if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): - try: - response = requests.get( - f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models', - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - data = response.json() - available_models = data.get('models', []) - except Exception as e: - log.error(f'Error fetching models from custom endpoint: {str(e)}') - available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + try: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models', + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() + available_models = data.get('models', []) + except Exception as e: + log.debug(f'/audio/models not available, trying /models fallback: {str(e)}') + # Fallback to standard OpenAI-compatible /models endpoint + # (used by KokoroTTS and similar custom TTS servers) + try: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/models', + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() + # OpenAI /models returns {"data": [...]}, /audio/models returns {"models": [...]} + available_models = data.get('data', data.get('models', [])) + except Exception as e2: + log.error(f'Error fetching models from custom endpoint: {str(e2)}') + available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] else: available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: - response = requests.get( - f'{ELEVENLABS_API_BASE_URL}/v1/models', - headers={ - 'xi-api-key': request.app.state.config.TTS_API_KEY, - 'Content-Type': 'application/json', - }, - timeout=5, - ) - response.raise_for_status() - models = response.json() - - available_models = [{'name': model['name'], 'id': model['model_id']} for model in models] - except requests.RequestException as e: - log.error(f'Error fetching voices: {str(e)}') + timeout = aiohttp.ClientTimeout(total=5) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{ELEVENLABS_API_BASE_URL}/v1/models', + headers={ + 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'Content-Type': 'application/json', + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + models = await response.json() + available_models = [{'name': model['name'], 'id': model['model_id']} for model in models] + except Exception as e: + log.error(f'Error fetching models: {str(e)}') + elif request.app.state.config.TTS_ENGINE == 'mistral': + available_models = [{'id': 'voxtral-mini-tts-2603'}] return available_models @router.get('/models') async def get_models(request: Request, user=Depends(get_verified_user)): - return {'models': get_available_models(request)} + return {'models': await get_available_models(request)} -def get_available_voices(request) -> dict: +async def get_available_voices(request) -> dict: """Returns {voice_id: voice_name} dict""" available_voices = {} if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): try: - response = requests.get( - f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices', - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - data = response.json() - voices_list = data.get('voices', []) - available_voices = {voice['id']: voice['name'] for voice in voices_list} + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices', + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() + voices_list = data.get('voices', []) + available_voices = {voice['id']: voice['name'] for voice in voices_list} except Exception as e: log.error(f'Error fetching voices from custom endpoint: {str(e)}') available_voices = { @@ -1280,7 +1467,7 @@ def get_available_voices(request) -> dict: } elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: - available_voices = get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY) + available_voices = await get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY) except Exception: # Avoided @lru_cache with exception pass @@ -1291,20 +1478,49 @@ def get_available_voices(request) -> dict: url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list' headers = {'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY} - response = requests.get(url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) - response.raise_for_status() - voices = response.json() + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get(url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response: + response.raise_for_status() + voices = await response.json() - for voice in voices: - available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})' - except requests.RequestException as e: + for voice in voices: + available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})' + except Exception as e: log.error(f'Error fetching voices: {str(e)}') + elif request.app.state.config.TTS_ENGINE == 'mistral': + api_key = request.app.state.config.TTS_MISTRAL_API_KEY + api_base_url = request.app.state.config.TTS_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' + + if api_key: + try: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{api_base_url}/audio/voices', + headers={ + 'Authorization': f'Bearer {api_key}', + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + voices_data = await response.json() + + # Mistral returns a paginated response: {"items": [...], "page": ..., "total": ...} + voices_list = voices_data.get('items', []) if isinstance(voices_data, dict) else voices_data + for voice in voices_list: + if isinstance(voice, dict): + voice_id = voice.get('voice_id', voice.get('id', '')) + voice_name = voice.get('name', voice_id) + if voice_id: + available_voices[voice_id] = voice_name + except Exception as e: + log.error(f'Error fetching Mistral voices: {str(e)}') return available_voices -@lru_cache -def get_elevenlabs_voices(api_key: str) -> dict: +async def get_elevenlabs_voices(api_key: str) -> dict: """ Note, set the following in your .env file to use Elevenlabs: AUDIO_TTS_ENGINE=elevenlabs @@ -1315,22 +1531,23 @@ def get_elevenlabs_voices(api_key: str) -> dict: try: # TODO: Add retries - response = requests.get( - f'{ELEVENLABS_API_BASE_URL}/v1/voices', - headers={ - 'xi-api-key': api_key, - 'Content-Type': 'application/json', - }, - timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, - ) - response.raise_for_status() - voices_data = response.json() - - voices = {} - for voice in voices_data.get('voices', []): - voices[voice['voice_id']] = voice['name'] - except requests.RequestException as e: - # Avoid @lru_cache with exception + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + f'{ELEVENLABS_API_BASE_URL}/v1/voices', + headers={ + 'xi-api-key': api_key, + 'Content-Type': 'application/json', + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + voices_data = await response.json() + + voices = {} + for voice in voices_data.get('voices', []): + voices[voice['voice_id']] = voice['name'] + except Exception as e: log.error(f'Error fetching voices: {str(e)}') raise RuntimeError(f'Error fetching voices: {str(e)}') @@ -1339,4 +1556,4 @@ def get_elevenlabs_voices(api_key: str) -> dict: @router.get('/voices') async def get_voices(request: Request, user=Depends(get_verified_user)): - return {'voices': [{'id': k, 'name': v} for k, v in get_available_voices(request).items()]} + return {'voices': [{'id': k, 'name': v} for k, v in (await get_available_voices(request)).items()]} diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 721a4069d46..5c2be8f22d7 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -35,6 +35,7 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_TRUSTED_GROUPS_HEADER, + WEBUI_AUTH_TRUSTED_ROLE_HEADER, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, @@ -53,6 +54,7 @@ OAUTH_PROVIDERS, OAUTH_MERGE_ACCOUNTS_BY_EMAIL, ) +from open_webui.utils.oauth import auth_manager_config from pydantic import BaseModel from open_webui.utils.misc import parse_duration, validate_email_format @@ -69,8 +71,8 @@ get_password_hash, get_http_authorization_cred, ) -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.groups import apply_default_group_assignment @@ -90,10 +92,14 @@ log = logging.getLogger(__name__) +# Forgive us our failed attempts, as we forgive those +# who exceed their allotted rate against this gate. signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3) -def create_session_response(request: Request, user, db, response: Response = None, set_cookie: bool = False) -> dict: +async def create_session_response( + request: Request, user, db, response: Response = None, set_cookie: bool = False +) -> dict: """ Create JWT token and build session response for a user. Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints. @@ -117,6 +123,7 @@ def create_session_response(request: Request, user, db, response: Response = Non if set_cookie and response: datetime_expires_at = datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None + max_age = int(expires_delta.total_seconds()) if expires_delta else None response.set_cookie( key='token', value=token, @@ -124,9 +131,10 @@ def create_session_response(request: Request, user, db, response: Response = Non httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) return { 'token': token, @@ -162,12 +170,19 @@ async def get_session_user( request: Request, response: Response, user=Depends(get_current_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): + token = None auth_header = request.headers.get('Authorization') - auth_token = get_http_authorization_cred(auth_header) - token = auth_token.credentials - data = decode_token(token) + if auth_header: + auth_token = get_http_authorization_cred(auth_header) + if auth_token is not None: + token = auth_token.credentials + if token is None: + token = request.cookies.get('token') + if token is None and getattr(request.state, 'token', None): + token = request.state.token.credentials + data = decode_token(token) if token else None expires_at = None @@ -181,6 +196,7 @@ async def get_session_user( ) # Set the cookie token + max_age = int(expires_at - time.time()) if expires_at else None response.set_cookie( key='token', value=token, @@ -188,9 +204,10 @@ async def get_session_user( httponly=True, # Ensures the cookie is not accessible via JavaScript samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) return { 'token': token, @@ -220,10 +237,10 @@ async def get_session_user( async def update_profile( form_data: UpdateProfileForm, session_user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if session_user: - user = Users.update_user_by_id( + user = await Users.update_user_by_id( session_user.id, form_data.model_dump(), db=db, @@ -249,10 +266,10 @@ class UpdateTimezoneForm(BaseModel): async def update_timezone( form_data: UpdateTimezoneForm, session_user=Depends(get_current_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if session_user: - Users.update_user_by_id( + await Users.update_user_by_id( session_user.id, {'timezone': form_data.timezone}, db=db, @@ -271,12 +288,12 @@ async def update_timezone( async def update_password( form_data: UpdatePasswordForm, session_user=Depends(get_current_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) if session_user: - user = Auths.authenticate_user( + user = await Auths.authenticate_user( session_user.email, lambda pw: verify_password(form_data.password, pw), db=db, @@ -284,11 +301,11 @@ async def update_password( if user: try: - validate_password(form_data.password) + validate_password(form_data.new_password) except Exception as e: raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.new_password) - return Auths.update_user_password_by_id(user.id, hashed, db=db) + return await Auths.update_user_password_by_id(user.id, hashed, db=db) else: raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD) else: @@ -303,7 +320,7 @@ async def ldap_auth( request: Request, response: Response, form_data: LdapForm, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): # Security checks FIRST - before loading any config if not request.app.state.config.ENABLE_LDAP: @@ -315,6 +332,14 @@ async def ldap_auth( detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) + # Reject empty passwords before attempting the LDAP bind. + # Per RFC 4513 §5.1.2, a Simple Bind with a non-empty DN but empty + # password is "unauthenticated simple authentication" — many LDAP + # servers (OpenLDAP default, some AD configs) return success for these, + # which would grant access without valid credentials. + if not form_data.password or not form_data.password.strip(): + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + # NOW load LDAP config variables LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST @@ -469,47 +494,65 @@ async def ldap_auth( if not await asyncio.to_thread(connection_user.bind): raise HTTPException(400, 'Authentication failed.') - user = Users.get_user_by_email(email, db=db) + user = await Users.get_user_by_email(email, db=db) if not user: try: - role = 'admin' if not Users.has_users(db=db) else request.app.state.config.DEFAULT_USER_ROLE - - user = Auths.insert_new_auth( + # Insert with default role first to avoid TOCTOU race on + # first-user registration. Matches signup_handler pattern. + user = await Auths.insert_new_auth( email=email, password=str(uuid.uuid4()), name=cn, - role=role, + role=request.app.state.config.DEFAULT_USER_ROLE, db=db, ) if not user: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) - apply_default_group_assignment( + # Atomically check if this is the only user *after* the + # insert. Only the single user present should become admin. + if await Users.get_num_users(db=db) == 1: + await Users.update_user_role_by_id(user.id, 'admin', db=db) + user = await Users.get_user_by_id(user.id, db=db) + + await apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db, ) + if request.app.state.config.WEBHOOK_URL: + await post_webhook( + request.app.state.WEBUI_NAME, + request.app.state.config.WEBHOOK_URL, + WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + { + 'action': 'signup', + 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + 'user': user.model_dump_json(exclude_none=True), + }, + ) + except HTTPException: raise except Exception as err: log.error(f'LDAP user creation error: {str(err)}') raise HTTPException(500, detail='Internal error occurred during LDAP user creation.') - user = Auths.authenticate_user_by_email(email, db=db) + user = await Auths.authenticate_user_by_email(email, db=db) if user: - if user.role != 'admin' and ENABLE_LDAP_GROUP_MANAGEMENT and user_groups: + if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups: if ENABLE_LDAP_GROUP_CREATION: - Groups.create_groups_by_group_names(user.id, user_groups, db=db) + await Groups.create_groups_by_group_names(user.id, user_groups, db=db) try: - Groups.sync_groups_by_group_names(user.id, user_groups, db=db) + await Groups.sync_groups_by_group_names(user.id, user_groups, db=db) log.info(f'Successfully synced groups for user {user.id}: {user_groups}') except Exception as e: log.error(f'Failed to sync groups for user {user.id}: {e}') - return create_session_response(request, user, db, response, set_cookie=True) + return await create_session_response(request, user, db, response, set_cookie=True) else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: @@ -529,7 +572,7 @@ async def signin( request: Request, response: Response, form_data: SigninForm, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not ENABLE_PASSWORD_AUTH: raise HTTPException( @@ -551,7 +594,7 @@ async def signin( except Exception as e: pass - if not Users.get_user_by_email(email.lower(), db=db): + if not await Users.get_user_by_email(email.lower(), db=db): await signup_handler( request, email, @@ -560,26 +603,35 @@ async def signin( db=db, ) - user = Auths.authenticate_user_by_email(email, db=db) - if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != 'admin': - group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',') - group_names = [name.strip() for name in group_names if name.strip()] + user = await Auths.authenticate_user_by_email(email, db=db) + if user: + if WEBUI_AUTH_TRUSTED_GROUPS_HEADER: + group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',') + group_names = [name.strip() for name in group_names if name.strip()] - if group_names: - Groups.sync_groups_by_group_names(user.id, group_names, db=db) + if group_names: + await Groups.sync_groups_by_group_names(user.id, group_names, db=db) + + if WEBUI_AUTH_TRUSTED_ROLE_HEADER: + trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip() + if trusted_role in {'admin', 'user', 'pending'}: + if user.role != trusted_role: + await Users.update_user_role_by_id(user.id, trusted_role, db=db) + elif trusted_role: + log.warning(f'Ignoring invalid trusted role header value: {trusted_role}') elif WEBUI_AUTH == False: admin_email = 'admin@localhost' admin_password = 'admin' - if Users.get_user_by_email(admin_email.lower(), db=db): - user = Auths.authenticate_user( + if await Users.get_user_by_email(admin_email.lower(), db=db): + user = await Auths.authenticate_user( admin_email.lower(), lambda pw: verify_password(admin_password, pw), db=db, ) else: - if Users.has_users(db=db): + if await Users.has_users(db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS) await signup_handler( @@ -590,7 +642,7 @@ async def signin( db=db, ) - user = Auths.authenticate_user( + user = await Auths.authenticate_user( admin_email.lower(), lambda pw: verify_password(admin_password, pw), db=db, @@ -611,14 +663,14 @@ async def signin( # decode safely — ignore incomplete UTF-8 sequences form_data.password = password_bytes.decode('utf-8', errors='ignore') - user = Auths.authenticate_user( + user = await Auths.authenticate_user( form_data.email.lower(), lambda pw: verify_password(form_data.password, pw), db=db, ) if user: - return create_session_response(request, user, db, response, set_cookie=True) + return await create_session_response(request, user, db, response, set_cookie=True) else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) @@ -635,7 +687,7 @@ async def signup_handler( name: str, profile_image_url: str = '/user.png', *, - db: Session, + db: AsyncSession, ) -> UserModel: """ Core user-creation logic shared by the signup endpoint and @@ -649,7 +701,7 @@ async def signup_handler( # first-user registration can all see an empty table and each get admin. hashed = get_password_hash(password) - user = Auths.insert_new_auth( + user = await Auths.insert_new_auth( email=email.lower(), password=hashed, name=name, @@ -662,9 +714,9 @@ async def signup_handler( # Atomically check if this is the only user *after* the insert. # Only the single user present at this point should become admin. - if Users.get_num_users(db=db) == 1: - Users.update_user_role_by_id(user.id, 'admin', db=db) - user = Users.get_user_by_id(user.id, db=db) + if await Users.get_num_users(db=db) == 1: + await Users.update_user_role_by_id(user.id, 'admin', db=db) + user = await Users.get_user_by_id(user.id, db=db) request.app.state.config.ENABLE_SIGNUP = False if request.app.state.config.WEBHOOK_URL: @@ -679,7 +731,7 @@ async def signup_handler( }, ) - apply_default_group_assignment( + await apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db, @@ -693,9 +745,9 @@ async def signup( request: Request, response: Response, form_data: SignupForm, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - has_users = Users.has_users(db=db) + has_users = await Users.has_users(db=db) if WEBUI_AUTH: if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM: @@ -708,7 +760,7 @@ async def signup( if not validate_email_format(form_data.email.lower()): raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT) - if Users.get_user_by_email(form_data.email.lower(), db=db): + if await Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) try: @@ -725,7 +777,7 @@ async def signup( form_data.profile_image_url, db=db, ) - return create_session_response(request, user, db, response, set_cookie=True) + return await create_session_response(request, user, db, response, set_cookie=True) except HTTPException: raise except Exception as err: @@ -733,15 +785,16 @@ async def signup( raise HTTPException(500, detail='An internal error occurred during signup.') -@router.get('/signout') -async def signout(request: Request, response: Response, db: Session = Depends(get_session)): +@router.post('/signout') +async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)): # get auth token from headers or cookies token = None auth_header = request.headers.get('Authorization') if auth_header: auth_cred = get_http_authorization_cred(auth_header) - token = auth_cred.credentials - else: + if auth_cred is not None: + token = auth_cred.credentials + if token is None: token = request.cookies.get('token') if token: @@ -755,7 +808,7 @@ async def signout(request: Request, response: Response, db: Session = Depends(ge if oauth_session_id: response.delete_cookie('oauth_session_id') - session = OAuthSessions.get_session_by_id(oauth_session_id, db=db) + session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db) # If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect # there directly instead of attempting OIDC discovery. @@ -777,7 +830,7 @@ async def signout(request: Request, response: Response, db: Session = Depends(ge oauth_id_token = session.token.get('id_token') try: async with ClientSession(trust_env=True) as session: - async with session.get(oauth_server_metadata_url) as r: + async with session.get(oauth_server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: if r.status == 200: openid_data = await r.json() logout_url = openid_data.get('end_session_endpoint') @@ -820,6 +873,31 @@ async def signout(request: Request, response: Response, db: Session = Depends(ge return JSONResponse(status_code=200, content={'status': True}, headers=response.headers) +############################ +# OAuth Session Management +############################ + + +@router.delete('/oauth/sessions/{provider:path}', response_model=bool) +async def delete_oauth_session_by_provider( + provider: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """ + Disconnect the current user's OAuth session for a specific provider. + The provider string matches the 'provider' field in the oauth_session table + (e.g. 'mcp:server-id' for MCP connections). + """ + result = await OAuthSessions.delete_sessions_by_user_id_and_provider(user.id, provider, db=db) + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='No OAuth session found for this provider', + ) + return True + + ############################ # AddUser ############################ @@ -830,12 +908,12 @@ async def add_user( request: Request, form_data: AddUserForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not validate_email_format(form_data.email.lower()): raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT) - if Users.get_user_by_email(form_data.email.lower(), db=db): + if await Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) try: @@ -845,7 +923,7 @@ async def add_user( raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.password) - user = Auths.insert_new_auth( + user = await Auths.insert_new_auth( form_data.email.lower(), hashed, form_data.name, @@ -855,13 +933,14 @@ async def add_user( ) if user: - apply_default_group_assignment( + await apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db, ) - token = create_token(data={'id': user.id}) + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + token = create_token(data={'id': user.id}, expires_delta=expires_delta) return { 'token': token, 'token_type': 'Bearer', @@ -886,7 +965,9 @@ async def add_user( @router.get('/admin/details') -async def get_admin_details(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)): +async def get_admin_details( + request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) +): if request.app.state.config.SHOW_ADMIN_DETAILS: admin_email = request.app.state.config.ADMIN_EMAIL admin_name = None @@ -894,11 +975,11 @@ async def get_admin_details(request: Request, user=Depends(get_current_user), db log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}') if admin_email: - admin = Users.get_user_by_email(admin_email, db=db) + admin = await Users.get_user_by_email(admin_email, db=db) if admin: admin_name = admin.name else: - admin = Users.get_first_user(db=db) + admin = await Users.get_first_user(db=db) if admin: admin_email = admin.email admin_name = admin.name @@ -933,7 +1014,11 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, + 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, + 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, @@ -959,7 +1044,11 @@ class AdminConfig(BaseModel): ENABLE_MESSAGE_RATING: bool ENABLE_FOLDERS: bool FOLDER_MAX_FILE_COUNT: Optional[int | str] = None + AUTOMATION_MAX_COUNT: Optional[int | str] = None + AUTOMATION_MIN_INTERVAL: Optional[int | str] = None + ENABLE_AUTOMATIONS: bool ENABLE_CHANNELS: bool + ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool ENABLE_NOTES: bool ENABLE_USER_WEBHOOKS: bool @@ -984,7 +1073,15 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep request.app.state.config.FOLDER_MAX_FILE_COUNT = ( int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' ) + request.app.state.config.AUTOMATION_MAX_COUNT = ( + int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else '' + ) + request.app.state.config.AUTOMATION_MIN_INTERVAL = ( + int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else '' + ) + request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS + request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES @@ -1025,7 +1122,11 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT, + 'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL, + 'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS, 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR, 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, @@ -1083,7 +1184,7 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user for key in required_fields: value = getattr(form_data, key) if not value: - raise HTTPException(400, detail=f'Required field {key} is empty') + raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key)) request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_HOST = form_data.host @@ -1138,9 +1239,12 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D # create api key @router.post('/api_key', response_model=ApiKey) -async def generate_api_key(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)): - if not request.app.state.config.ENABLE_API_KEYS or not has_permission( - user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS +async def generate_api_key( + request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session) +): + if not request.app.state.config.ENABLE_API_KEYS or ( + user.role != 'admin' + and not await has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -1148,7 +1252,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user), db: ) api_key = create_api_key() - success = Users.update_user_api_key_by_id(user.id, api_key, db=db) + success = await Users.update_user_api_key_by_id(user.id, api_key, db=db) if success: return { @@ -1160,14 +1264,14 @@ async def generate_api_key(request: Request, user=Depends(get_current_user), db: # delete api key @router.delete('/api_key', response_model=bool) -async def delete_api_key(user=Depends(get_current_user), db: Session = Depends(get_session)): - return Users.delete_user_api_key_by_id(user.id, db=db) +async def delete_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)): + return await Users.delete_user_api_key_by_id(user.id, db=db) # get api key @router.get('/api_key', response_model=ApiKey) -async def get_api_key(user=Depends(get_current_user), db: Session = Depends(get_session)): - api_key = Users.get_user_api_key_by_id(user.id, db=db) +async def get_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)): + api_key = await Users.get_user_api_key_by_id(user.id, db=db) if api_key: return { 'api_key': api_key, @@ -1191,7 +1295,7 @@ async def token_exchange( response: Response, provider: str, form_data: TokenExchangeForm, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """ Exchange an external OAuth provider token for an OpenWebUI JWT. @@ -1209,7 +1313,7 @@ async def token_exchange( if provider not in OAUTH_PROVIDERS: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Provider '{provider}' is not configured", + detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider), ) # Get the OAuth client for this provider oauth_manager = request.app.state.oauth_manager @@ -1217,7 +1321,7 @@ async def token_exchange( if not client: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"OAuth client for '{provider}' not found", + detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider), ) # Validate the token by calling the userinfo endpoint @@ -1259,15 +1363,26 @@ async def token_exchange( ) email = email.lower() + # Enforce domain allowlist — same check as the normal OAuth callback + if ( + '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + ): + log.warning(f'Token exchange denied: email domain not in allowed domains list') + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Try to find the user by OAuth sub - user = Users.get_user_by_oauth_sub(provider, sub, db=db) + user = await Users.get_user_by_oauth_sub(provider, sub, db=db) if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value: # Try to find by email if merge is enabled - user = Users.get_user_by_email(email, db=db) + user = await Users.get_user_by_email(email, db=db) if user: # Link the OAuth sub to this user - Users.update_user_oauth_by_id(user.id, provider, sub, db=db) + await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) if not user: raise HTTPException( @@ -1275,4 +1390,4 @@ async def token_exchange( detail='User not found. Please sign in via the web interface first.', ) - return create_session_response(request, user, db) + return await create_session_response(request, user, db) diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py new file mode 100644 index 00000000000..4ff66feb974 --- /dev/null +++ b/backend/open_webui/routers/automations.py @@ -0,0 +1,304 @@ +import asyncio +import logging + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from open_webui.models.automations import ( + Automations, + AutomationRuns, + AutomationForm, + AutomationModel, + AutomationResponse, + AutomationRunModel, + AutomationListResponse, +) +from open_webui.utils.automations import ( + validate_rrule, + next_run_ns, + next_n_runs_ns, + execute_automation, + rrule_interval_seconds, +) +from open_webui.utils.auth import get_verified_user, get_admin_user +from open_webui.utils.access_control import has_permission +from open_webui.internal.db import get_async_session +from open_webui.constants import ERROR_MESSAGES + +log = logging.getLogger(__name__) + +router = APIRouter() + +PAGE_ITEM_COUNT = 30 + + +############################ +# Helpers +############################ + + +async def check_automations_permission(request, user): + if not request.app.state.config.ENABLE_AUTOMATIONS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + if user.role != 'admin' and not await has_permission( + user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + +def check_automation_access(automation, user): + if not automation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if user.role != 'admin' and user.id != automation.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + +async def check_automation_limits(request, user, rrule_str: str, db, is_create: bool = False): + """Enforce global automation limits. Admins bypass all checks.""" + if user.role == 'admin': + return + + # Max count (create only) + if is_create: + max_count = request.app.state.config.AUTOMATION_MAX_COUNT + if max_count: + max_count = int(max_count) + if max_count > 0 and await Automations.count_by_user(user.id, db=db) >= max_count: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.AUTOMATION_LIMIT_EXCEEDED(max_count), + ) + + # Min interval (create + update) + min_interval = request.app.state.config.AUTOMATION_MIN_INTERVAL + if min_interval: + min_interval = int(min_interval) + if min_interval > 0: + interval = rrule_interval_seconds(rrule_str) + if interval is not None and interval < min_interval: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.AUTOMATION_TOO_FREQUENT(min_interval), + ) + + +async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse: + """Full enrichment for single-item views (includes next_runs computation).""" + last_run = await AutomationRuns.get_latest(automation.id, db=db) + return AutomationResponse( + **automation.model_dump(), + last_run=last_run, + next_runs=next_n_runs_ns(automation.data['rrule'], tz=tz), + ) + + +############################ +# GetAutomationItems (paginated) +############################ + + +@router.get('/list') +async def get_automation_items( + request: Request, + query: Optional[str] = None, + status: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + limit = PAGE_ITEM_COUNT + page = max(1, page) + skip = (page - 1) * limit + + result = await Automations.search_automations( + user_id=user.id, + query=query, + status=status, + skip=skip, + limit=limit, + db=db, + ) + + # Batch-fetch latest runs in a single query instead of N+1 + ids = [item.id for item in result.items] + latest_runs = await AutomationRuns.get_latest_batch(ids, db=db) if ids else {} + + return { + 'items': [ + AutomationResponse( + **item.model_dump(), + last_run=latest_runs.get(item.id), + ) + for item in result.items + ], + 'total': result.total, + } + + +############################ +# CreateNewAutomation +############################ + + +@router.post('/create', response_model=AutomationResponse) +async def create_new_automation( + request: Request, + form_data: AutomationForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + try: + validate_rrule(form_data.data.rrule, tz=user.timezone) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + await check_automation_limits(request, user, form_data.data.rrule, db, is_create=True) + + tz = user.timezone + automation = await Automations.insert(user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db) + return await enrich_automation(automation, db, tz=tz) + + +############################ +# GetAutomationById +############################ + + +@router.get('/{id}', response_model=AutomationResponse) +async def get_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + return await enrich_automation(automation, db, tz=user.timezone) + + +############################ +# UpdateAutomationById +############################ + + +@router.post('/{id}/update', response_model=AutomationResponse) +async def update_automation_by_id( + request: Request, + id: str, + form_data: AutomationForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + + try: + validate_rrule(form_data.data.rrule, tz=user.timezone) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + await check_automation_limits(request, user, form_data.data.rrule, db, is_create=False) + + tz = user.timezone + updated = await Automations.update_by_id(id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db) + return await enrich_automation(updated, db, tz=tz) + + +############################ +# ToggleAutomationById +############################ + + +@router.post('/{id}/toggle', response_model=AutomationResponse) +async def toggle_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + toggled = await Automations.toggle(id, next_run_ns(automation.data['rrule'], tz=user.timezone), db=db) + return await enrich_automation(toggled, db, tz=user.timezone) + + +############################ +# RunAutomationById +############################ + + +@router.post('/{id}/run') +async def run_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + asyncio.create_task(execute_automation(request.app, automation)) + return await enrich_automation(automation, db, tz=user.timezone) + + +############################ +# DeleteAutomationById +############################ + + +@router.delete('/{id}/delete') +async def delete_automation_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + await AutomationRuns.delete_by_automation(id, db=db) + return await Automations.delete(id, db=db) + + +############################ +# GetAutomationRuns +############################ + + +@router.get('/{id}/runs', response_model=list[AutomationRunModel]) +async def get_automation_runs( + request: Request, + id: str, + skip: int = 0, + limit: int = 50, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await check_automations_permission(request, user) + automation = await Automations.get_by_id(id, db=db) + check_automation_access(automation, user) + return await AutomationRuns.get_by_automation(id, skip=skip, limit=limit, db=db) diff --git a/backend/open_webui/routers/calendar.py b/backend/open_webui/routers/calendar.py new file mode 100644 index 00000000000..bdc06e819bd --- /dev/null +++ b/backend/open_webui/routers/calendar.py @@ -0,0 +1,414 @@ +import logging +import time +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from open_webui.models.calendar import ( + Calendars, + CalendarEvents, + CalendarEventAttendees, + CalendarForm, + CalendarUpdateForm, + CalendarEventForm, + CalendarEventUpdateForm, + CalendarModel, + CalendarEventModel, + CalendarEventUserResponse, + CalendarEventListResponse, + RSVPForm, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import UserModel +from open_webui.utils.auth import get_verified_user +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.utils.calendar import expand_recurring_event +from open_webui.constants import ERROR_MESSAGES + +log = logging.getLogger(__name__) + +router = APIRouter() + +SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__' + + +async def check_calendar_permission(request: Request, user): + """Check global feature flag AND per-user permission for calendar access.""" + if not request.app.state.config.ENABLE_CALENDAR: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + if user.role != 'admin' and not await has_permission( + user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + +async def _user_has_automations(request: Request, user) -> bool: + """Check if automations feature is available to this user.""" + if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False): + return False + if user.role == 'admin': + return True + return await has_permission(user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS) + + +async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel: + """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" + cal = await Calendars.get_calendar_by_id(calendar_id) + if not cal: + raise HTTPException(status_code=404, detail='Calendar not found') + if cal.user_id == user.id or user.role == 'admin': + return cal + user_groups = await Groups.get_groups_by_member_id(user.id) + user_group_ids = [g.id for g in user_groups] + if await AccessGrants.has_access( + user_id=user.id, + resource_type='calendar', + resource_id=cal.id, + permission=permission, + user_group_ids=user_group_ids, + ): + return cal + raise HTTPException(status_code=403, detail='Access denied') + + +#################### +# Calendar CRUD (static paths first) +#################### + + +@router.get('/', response_model=list[CalendarModel]) +async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)): + """List user's calendars (owned + shared), plus a virtual Scheduled Tasks calendar + when automations are available.""" + await check_calendar_permission(request, user) + calendars = await Calendars.get_calendars_by_user(user.id) + + if await _user_has_automations(request, user): + now = int(time.time_ns()) + calendars.append( + CalendarModel( + id=SCHEDULED_TASKS_CALENDAR_ID, + user_id=user.id, + name='Scheduled Tasks', + color='#8b5cf6', + is_default=False, + is_system=True, + created_at=now, + updated_at=now, + ) + ) + + return calendars + + +@router.post('/create', response_model=CalendarModel) +async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): + """Create a new user calendar.""" + await check_calendar_permission(request, user) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/models pattern). Without this, any verified user + # could create a calendar with `principal_id='*' permission='read'|'write'`, + # making their events readable or writable by any other verified user. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_calendars', + ) + return await Calendars.insert_new_calendar(user.id, form_data) + + +#################### +# Event CRUD (before /{calendar_id} to avoid route conflicts) +#################### + + +@router.get('/events') +async def get_events( + request: Request, + start: str, + end: str, + calendar_ids: Optional[str] = None, + user: UserModel = Depends(get_verified_user), +): + """Get events in date range. + + Args: + start: ISO 8601 datetime string (e.g. 2026-04-01T00:00:00) + end: ISO 8601 datetime string (e.g. 2026-05-01T00:00:00) + calendar_ids: optional comma-separated list to filter + + Includes: + - Stored events from the database + - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) + """ + await check_calendar_permission(request, user) + from datetime import datetime + + try: + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail='Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00)') + + NS = 1_000_000 + start_ns = int(start_dt.timestamp() * 1000) * NS + end_ns = int(end_dt.timestamp() * 1000) * NS + cal_id_list = calendar_ids.split(',') if calendar_ids else None + + # 1. Stored events + events = await CalendarEvents.get_events_by_range( + user_id=user.id, + start=start_ns, + end=end_ns, + calendar_ids=cal_id_list, + ) + + # Expand recurring stored events + expanded = [] + for event in events: + event_dict = event.model_dump() + if event_dict.get('rrule'): + instances = expand_recurring_event(event_dict, start_ns, end_ns, tz=user.timezone) + for inst in instances: + expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user})) + else: + expanded.append(event) + + # 2. Virtual automation events (Scheduled Tasks calendar) + if await _user_has_automations(request, user) and ( + cal_id_list is None or SCHEDULED_TASKS_CALENDAR_ID in cal_id_list + ): + try: + from open_webui.models.automations import Automations, AutomationRuns + + # Future runs: expand RRULEs for active automations only + active_automations = await Automations.get_active_by_user(user.id) + for auto in active_automations: + rrule_str = auto.data.get('rrule', '') if auto.data else '' + if not rrule_str: + continue + + virtual = { + 'id': f'auto_{auto.id}', + 'calendar_id': SCHEDULED_TASKS_CALENDAR_ID, + 'user_id': user.id, + 'title': auto.name, + 'description': auto.data.get('prompt', '') if auto.data else '', + 'start_at': auto.next_run_at or 0, + 'end_at': None, + 'all_day': False, + 'rrule': rrule_str, + 'color': None, + 'location': None, + 'data': None, + 'meta': {'automation_id': auto.id}, + 'is_cancelled': False, + 'attendees': [], + 'created_at': auto.created_at, + 'updated_at': auto.updated_at, + 'user': None, + } + + # Only expand into the future — past runs are handled below + now_ns = int(time.time_ns()) + rrule_start = max(start_ns, now_ns) + instances = expand_recurring_event(virtual, rrule_start, end_ns, tz=user.timezone) + for inst in instances: + expanded.append(CalendarEventUserResponse(**inst)) + + # Past runs: single range query joined with automation + runs_with_auto = await AutomationRuns.get_runs_by_user_range(user.id, start_ns, end_ns) + for run, auto in runs_with_auto: + expanded.append( + CalendarEventUserResponse( + id=f'run_{run.id}', + calendar_id=SCHEDULED_TASKS_CALENDAR_ID, + user_id=user.id, + title=auto.name, + description=run.error if run.status == 'error' else '', + start_at=run.created_at, + end_at=None, + all_day=False, + color=None, + location=None, + data=None, + meta={ + 'automation_id': auto.id, + 'run_id': run.id, + 'chat_id': run.chat_id, + 'status': run.status, + }, + is_cancelled=False, + attendees=[], + created_at=run.created_at, + updated_at=run.created_at, + user=None, + ) + ) + except Exception as e: + log.warning(f'Failed to compute automation events: {e}', exc_info=True) + + return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded] + + +@router.post('/events/create', response_model=CalendarEventModel) +async def create_event(request: Request, form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + await _check_calendar_access(form_data.calendar_id, user, 'write') + return await CalendarEvents.insert_new_event(user.id, form_data) + + +@router.get('/events/search', response_model=CalendarEventListResponse) +async def search_events( + request: Request, + query: Optional[str] = None, + skip: int = 0, + limit: int = 30, + user: UserModel = Depends(get_verified_user), +): + await check_calendar_permission(request, user) + return await CalendarEvents.search_events(user_id=user.id, query=query, skip=skip, limit=limit) + + +@router.get('/events/{event_id}', response_model=CalendarEventModel) +async def get_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'read') + + return event + + +@router.post('/events/{event_id}/update', response_model=CalendarEventModel) +async def update_event( + request: Request, event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) +): + await check_calendar_permission(request, user) + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + updated = await CalendarEvents.update_event_by_id(event_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/events/{event_id}/delete') +async def delete_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + raise HTTPException(status_code=404, detail='Event not found') + + await _check_calendar_access(event.calendar_id, user, 'write') + + result = await CalendarEvents.delete_event_by_id(event_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} + + +@router.post('/events/{event_id}/rsvp', response_model=dict) +async def rsvp_event( + request: Request, event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) +): + """Update own RSVP status for an event.""" + await check_calendar_permission(request, user) + if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): + raise HTTPException(status_code=400, detail='Invalid status') + + result = await CalendarEventAttendees.update_rsvp(event_id, user.id, form_data.status) + if not result: + raise HTTPException(status_code=404, detail='Not an attendee of this event') + return {'status': True, 'rsvp': result.status} + + +#################### +# Calendar by ID (dynamic path — MUST come after /events* routes) +#################### + + +@router.get('/{calendar_id}', response_model=CalendarModel) +async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + cal = await _check_calendar_access(calendar_id, user, 'read') + return cal + + +@router.post('/{calendar_id}/update', response_model=CalendarModel) +async def update_calendar( + request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) +): + await check_calendar_permission(request, user) + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can change access grants + if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can manage sharing') + + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/models pattern). The owner-only check above + # only restricts WHO can set grants; this filter restricts WHICH grants + # they may set, so a non-admin owner cannot make their calendar + # publicly readable/writable without the corresponding sharing permission. + if form_data.access_grants is not None: + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_calendars', + ) + + updated = await Calendars.update_calendar_by_id(calendar_id, form_data) + if not updated: + raise HTTPException(status_code=500, detail='Failed to update') + return updated + + +@router.delete('/{calendar_id}/delete') +async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + + # Block deletion of the virtual Scheduled Tasks calendar + if calendar_id == SCHEDULED_TASKS_CALENDAR_ID: + raise HTTPException(status_code=400, detail='System calendars cannot be deleted') + + cal = await _check_calendar_access(calendar_id, user, 'write') + + # Only owner/admin can delete + if cal.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=403, detail='Only owner can delete calendar') + + # Block deletion of default calendar + if cal.is_default: + raise HTTPException(status_code=400, detail='Default calendar cannot be deleted') + + result = await Calendars.delete_calendar_by_id(calendar_id) + if not result: + raise HTTPException(status_code=500, detail='Failed to delete') + return {'status': True} + + +@router.post('/{calendar_id}/default') +async def set_default_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): + await check_calendar_permission(request, user) + cal = await Calendars.set_default_calendar(user.id, calendar_id) + if not cal: + raise HTTPException(status_code=404, detail='Calendar not found') + return cal diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 06d2d2a6bb7..70eb799ea66 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -36,7 +36,7 @@ ChannelWebhookModel, ChannelWebhookForm, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant from open_webui.models.messages import ( Messages, MessageModel, @@ -57,29 +57,28 @@ get_all_models, get_filtered_models, ) -from open_webui.utils.chat import generate_chat_completion from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.utils.webhook import post_webhook from open_webui.utils.channels import extract_mentions, replace_mentions -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) router = APIRouter() -def channel_has_access( +async def channel_has_access( user_id: str, channel: ChannelModel, permission: str = 'read', strict: bool = True, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ) -> bool: - if AccessGrants.has_access( + if await AccessGrants.has_access( user_id=user_id, resource_type='channel', resource_id=channel.id, @@ -88,14 +87,16 @@ def channel_has_access( ): return True - if not strict and permission == 'write' and has_public_read_access_grant(channel.access_grants): + if not strict and permission == 'write' and has_public_write_access_grant(channel.access_grants): return True return False -def get_channel_users_with_access(channel: ChannelModel, permission: str = 'read', db: Optional[Session] = None): - return AccessGrants.get_users_with_access( +async def get_channel_users_with_access( + channel: ChannelModel, permission: str = 'read', db: Optional[AsyncSession] = None +): + return await AccessGrants.get_users_with_access( resource_type='channel', resource_id=channel.id, permission=permission, @@ -128,19 +129,21 @@ def get_channel_permitted_group_and_user_ids( ############################ # Channels Enabled Dependency +# The creator has set this table; let every voice that +# gathers here find shelter under the same roof. ############################ -def check_channels_access(request: Request, user: Optional[UserModel] = None): +async def check_channels_access(request: Request, user: Optional[UserModel] = None): """Dependency to ensure channels are globally enabled.""" if not request.app.state.config.ENABLE_CHANNELS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail='Channels are not enabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Channels'), ) if user: - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.channels', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( @@ -166,19 +169,19 @@ class ChannelListItemResponse(ChannelModel): async def get_channels( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) - channels = Channels.get_channels_by_user_id(user.id, db=db) + channels = await Channels.get_channels_by_user_id(user.id, db=db) channel_list = [] for channel in channels: - last_message = Messages.get_last_message_by_channel_id(channel.id, db=db) + last_message = await Messages.get_last_message_by_channel_id(channel.id, db=db) last_message_at = last_message.created_at if last_message else None - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) + channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = ( - Messages.get_unread_message_count(channel.id, user.id, channel_member.last_read_at, db=db) + await Messages.get_unread_message_count(channel.id, user.id, channel_member.last_read_at, db=db) if channel_member else 0 ) @@ -186,15 +189,15 @@ async def get_channels( user_ids = None users = None if channel.type == 'dm': - user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] + user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( **{ - **user.model_dump(), - 'is_active': Users.is_active(user), + **u.model_dump(), + 'is_active': Users.is_active(u), } ) - for user in Users.get_users_by_user_ids(user_ids, db=db) + for u in await Users.get_users_by_user_ids(user_ids, db=db) ] channel_list.append( @@ -214,12 +217,12 @@ async def get_channels( async def get_all_channels( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) + await check_channels_access(request, user) if user.role == 'admin': - return Channels.get_channels(db=db) - return Channels.get_channels_by_user_id(user.id, db=db) + return await Channels.get_channels(db=db) + return await Channels.get_channels_by_user_id(user.id, db=db) ############################ @@ -232,14 +235,14 @@ async def get_dm_channel_by_user_id( request: Request, user_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) try: - existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id], db=db) + existing_channel = await Channels.get_dm_channel_by_user_ids([user.id, user_id], db=db) if existing_channel: participant_ids = [ - member.user_id for member in Channels.get_members_by_channel_id(existing_channel.id, db=db) + member.user_id for member in await Channels.get_members_by_channel_id(existing_channel.id, db=db) ] await emit_to_users( @@ -249,10 +252,10 @@ async def get_dm_channel_by_user_id( ) await enter_room_for_users(f'channel:{existing_channel.id}', participant_ids) - Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) + await Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) return ChannelModel(**existing_channel.model_dump()) - channel = Channels.insert_new_channel( + channel = await Channels.insert_new_channel( CreateChannelForm( type='dm', name='', @@ -263,7 +266,7 @@ async def get_dm_channel_by_user_id( ) if channel: - participant_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] + participant_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] await emit_to_users( 'events:channel', @@ -290,9 +293,9 @@ async def create_new_channel( request: Request, form_data: CreateChannelForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) if form_data.type not in ['group', 'dm'] and user.role != 'admin': # Only admins can create standard channels (joined by default) @@ -301,12 +304,20 @@ async def create_new_channel( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_channels', + ) + try: if form_data.type == 'dm': - existing_channel = Channels.get_dm_channel_by_user_ids([user.id, *form_data.user_ids], db=db) + existing_channel = await Channels.get_dm_channel_by_user_ids([user.id, *form_data.user_ids], db=db) if existing_channel: participant_ids = [ - member.user_id for member in Channels.get_members_by_channel_id(existing_channel.id, db=db) + member.user_id for member in await Channels.get_members_by_channel_id(existing_channel.id, db=db) ] await emit_to_users( 'events:channel', @@ -315,13 +326,13 @@ async def create_new_channel( ) await enter_room_for_users(f'channel:{existing_channel.id}', participant_ids) - Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) + await Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) return ChannelModel(**existing_channel.model_dump()) - channel = Channels.insert_new_channel(form_data, user.id, db=db) + channel = await Channels.insert_new_channel(form_data, user.id, db=db) if channel: - participant_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] + participant_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] await emit_to_users( 'events:channel', @@ -356,10 +367,10 @@ async def get_channel_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -367,23 +378,23 @@ async def get_channel_by_id( users = None if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] + user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( **{ - **user.model_dump(), - 'is_active': Users.is_active(user), + **u.model_dump(), + 'is_active': Users.is_active(u), } ) - for user in Users.get_users_by_user_ids(user_ids, db=db) + for u in await Users.get_users_by_user_ids(user_ids, db=db) ] - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) - unread_count = Messages.get_unread_message_count( + channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) + unread_count = await Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -392,7 +403,7 @@ async def get_channel_by_id( **channel.model_dump(), 'user_ids': user_ids, 'users': users, - 'is_manager': Channels.is_user_channel_manager(channel.id, user.id, db=db), + 'is_manager': await Channels.is_user_channel_manager(channel.id, user.id, db=db), 'write_access': True, 'user_count': len(user_ids), 'last_read_at': channel_member.last_read_at if channel_member else None, @@ -400,10 +411,10 @@ async def get_channel_by_id( } ) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - write_access = channel_has_access( + write_access = await channel_has_access( user.id, channel, permission='write', @@ -411,10 +422,10 @@ async def get_channel_by_id( db=db, ) - user_count = len(get_channel_users_with_access(channel, 'read', db=db)) + user_count = len(await get_channel_users_with_access(channel, 'read', db=db)) - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) - unread_count = Messages.get_unread_message_count( + channel_member = await Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) + unread_count = await Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -423,7 +434,7 @@ async def get_channel_by_id( **channel.model_dump(), 'user_ids': user_ids, 'users': users, - 'is_manager': Channels.is_user_channel_manager(channel.id, user.id, db=db), + 'is_manager': await Channels.is_user_channel_manager(channel.id, user.id, db=db), 'write_access': write_access or user.role == 'admin', 'user_count': user_count, 'last_read_at': channel_member.last_read_at if channel_member else None, @@ -449,11 +460,11 @@ async def get_channel_members_by_id( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -463,16 +474,19 @@ async def get_channel_members_by_id( skip = (page - 1) * limit if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + else: + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) if channel.type == 'dm': - user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] - users = Users.get_users_by_user_ids(user_ids, db=db) - total = len(users) + user_ids = [member.user_id for member in await Channels.get_members_by_channel_id(channel.id, db=db)] + fetched_users = await Users.get_users_by_user_ids(user_ids, db=db) + total = len(fetched_users) return { - 'users': [UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users], + 'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users], 'total': total, } else: @@ -494,13 +508,13 @@ async def get_channel_members_by_id( filter['user_ids'] = permitted_ids.get('user_ids') filter['group_ids'] = permitted_ids.get('group_ids') - result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) - users = result['users'] + fetched_users = result['users'] total = result['total'] return { - 'users': [UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users], + 'users': [UserModelResponse(**u.model_dump(), is_active=Users.is_active(u)) for u in fetched_users], 'total': total, } @@ -520,17 +534,17 @@ async def update_is_active_member_by_id_and_user_id( id: str, form_data: UpdateActiveMemberForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db) + await Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db) return True @@ -550,10 +564,10 @@ async def add_members_by_id( id: str, form_data: UpdateMembersForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -561,7 +575,7 @@ async def add_members_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - memberships = Channels.add_members_to_channel( + memberships = await Channels.add_members_to_channel( channel.id, user.id, form_data.user_ids, form_data.group_ids, db=db ) @@ -586,11 +600,11 @@ async def remove_members_by_id( id: str, form_data: RemoveMembersForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -598,7 +612,7 @@ async def remove_members_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - deleted = Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db) + deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db) return deleted except Exception as e: @@ -617,19 +631,27 @@ async def update_channel_by_id( id: str, form_data: ChannelForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.user_id != user.id and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_channels', + ) + try: - channel = Channels.update_channel_by_id(id, form_data, db=db) + channel = await Channels.update_channel_by_id(id, form_data, db=db) return ChannelModel(**channel.model_dump()) except Exception as e: log.exception(e) @@ -646,11 +668,11 @@ async def delete_channel_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) + await check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -658,7 +680,7 @@ async def delete_channel_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - Channels.delete_channel_by_id(id, db=db) + await Channels.delete_channel_by_id(id, db=db) return True except Exception as e: log.exception(e) @@ -690,40 +712,40 @@ async def get_channel_messages( skip: int = 0, limit: int = 50, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request, user) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - channel_member = Channels.join_channel(id, user.id, db=db) # Ensure user is a member of the channel + channel_member = await Channels.join_channel(id, user.id, db=db) # Ensure user is a member of the channel - message_list = Messages.get_messages_by_channel_id(id, skip, limit, db=db) + message_list = await Messages.get_messages_by_channel_id(id, skip, limit, db=db) if not message_list: return [] # Batch fetch all users in a single query (fixes N+1 problem) user_ids = list(set(m.user_id for m in message_list)) - users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} + fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: - thread_replies = Messages.get_thread_replies_by_message_id(message.id, db=db) + thread_replies = await Messages.get_thread_replies_by_message_id(message.id, db=db) latest_thread_reply_at = thread_replies[0].created_at if thread_replies else None # Use message.user if present (for webhooks), otherwise look up by user_id user_info = message.user - if user_info is None and message.user_id in users: - user_info = UserNameResponse(**users[message.user_id].model_dump()) + if user_info is None and message.user_id in fetched_users: + user_info = UserNameResponse(**fetched_users[message.user_id].model_dump()) messages.append( MessageUserResponse( @@ -731,7 +753,7 @@ async def get_channel_messages( **message.model_dump(), 'reply_count': len(thread_replies), 'latest_reply_at': latest_thread_reply_at, - 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), 'user': user_info, } ) @@ -753,32 +775,32 @@ async def get_pinned_channel_messages( id: str, page: int = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) page = max(1, page) skip = (page - 1) * PAGE_ITEM_COUNT_PINNED limit = PAGE_ITEM_COUNT_PINNED - message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit, db=db) + message_list = await Messages.get_pinned_messages_by_channel_id(id, skip, limit, db=db) if not message_list: return [] # Batch fetch all users in a single query (fixes N+1 problem) user_ids = list(set(m.user_id for m in message_list)) - users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} + fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: @@ -786,12 +808,12 @@ async def get_pinned_channel_messages( webhook_info = message.meta.get('webhook') if message.meta else None if webhook_info: user_info = UserNameResponse( - id=webhook_info.get('id'), - name=webhook_info.get('name'), + id=webhook_info.get('id') or '', + name=webhook_info.get('name') or 'Webhook', role='webhook', ) - elif message.user_id in users: - user_info = UserNameResponse(**users[message.user_id].model_dump()) + elif message.user_id in fetched_users: + user_info = UserNameResponse(**fetched_users[message.user_id].model_dump()) else: user_info = None @@ -799,7 +821,7 @@ async def get_pinned_channel_messages( MessageWithReactionsResponse( **{ **message.model_dump(), - 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), 'user': user_info, } ) @@ -813,13 +835,17 @@ async def get_pinned_channel_messages( ############################ -async def send_notification(name, webui_url, channel, message, active_user_ids, db=None): - users = get_channel_users_with_access(channel, 'read', db=db) +async def send_notification(request, channel, message, active_user_ids, db=None): + name = request.app.state.WEBUI_NAME + webui_url = request.app.state.config.WEBUI_URL + enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS + + users = await get_channel_users_with_access(channel, 'read', db=db) - for user in users: - if (user.id not in active_user_ids) and Channels.is_user_channel_member(channel.id, user.id, db=db): - if user.settings: - webhook_url = user.settings.ui.get('notifications', {}).get('webhook_url', None) + for u in users: + if (u.id not in active_user_ids) and await Channels.is_user_channel_member(channel.id, u.id, db=db): + if enable_user_webhooks and u.settings: + webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None) if webhook_url: await post_webhook( name, @@ -837,7 +863,7 @@ async def send_notification(name, webui_url, channel, message, active_user_ids, async def model_response_handler(request, channel, message, user, db=None): - MODELS = {model['id']: model for model in get_filtered_models(await get_all_models(request, user=user), user)} + MODELS = {model['id']: model for model in await get_filtered_models(await get_all_models(request, user=user), user)} mentions = extract_mentions(message.content) message_content = replace_mentions(message.content) @@ -868,10 +894,12 @@ async def model_response_handler(request, channel, message, user, db=None): if model: try: # reverse to get in chronological order - thread_messages = Messages.get_messages_by_parent_id( - channel.id, - message.parent_id if message.parent_id else message.id, - db=db, + thread_messages = ( + await Messages.get_messages_by_parent_id( + channel.id, + message.parent_id if message.parent_id else message.id, + db=db, + ) )[::-1] response_message, channel = await new_message_handler( @@ -894,15 +922,13 @@ async def model_response_handler(request, channel, message, user, db=None): thread_history = [] images = [] - message_users = {} + + # Batch fetch all users in a single query (fixes N+1 problem) + user_ids = list({message.user_id for message in thread_messages}) + message_users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} for thread_message in thread_messages: - message_user = None - if thread_message.user_id not in message_users: - message_user = Users.get_user_by_id(thread_message.user_id, db=db) - message_users[thread_message.user_id] = message_user - else: - message_user = message_users[thread_message.user_id] + message_user = message_users.get(thread_message.user_id) if thread_message.meta and thread_message.meta.get('model_id', None): # If the message was sent by a model, use the model name @@ -919,7 +945,7 @@ async def model_response_handler(request, channel, message, user, db=None): if file.get('type', '') == 'image': images.append(file.get('url', '')) elif file.get('content_type', '').startswith('image/'): - image = get_image_base64_from_file_id(file.get('id', '')) + image = await get_image_base64_from_file_id(file.get('id', '')) if image: images.append(image) @@ -952,71 +978,62 @@ async def model_response_handler(request, channel, message, user, db=None): ], ] + # Resolve model config (same helpers automations use) + from open_webui.utils.automations import ( + _resolve_model_tool_ids, + _resolve_model_features, + _resolve_model_filter_ids, + ) + + tool_ids = _resolve_model_tool_ids(request.app, model_id) + features = _resolve_model_features(request.app, model_id) + filter_ids = _resolve_model_filter_ids(request.app, model_id) + + # Build full form_data — same shape as frontend POST. + # The channel: prefix routes pipeline events to the + # channel emitter in socket/main.py instead of the + # default chat emitter. form_data = { 'model': model_id, 'messages': [ system_message, {'role': 'user', 'content': content}, ], - 'stream': False, + 'stream': True, + 'chat_id': f'channel:{channel.id}', + 'id': response_message.id, + 'session_id': f'channel:{channel.id}', + 'background_tasks': {}, } + if tool_ids: + form_data['tool_ids'] = tool_ids + if features: + form_data['features'] = features + if filter_ids: + form_data['filter_ids'] = filter_ids + + # Call the full chat completion pipeline — streaming, + # tools, filters, RAG — everything. The pipeline runs as + # an async task; the channel emitter handles progressive + # message updates via socket events. + await request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) - res = await generate_chat_completion( - request, - form_data=form_data, - user=user, - ) - - if res: - if res.get('choices', []) and len(res['choices']) > 0: - await update_message_by_id( - request, - channel.id, - response_message.id, - MessageForm( - **{ - 'content': res['choices'][0]['message']['content'], - 'meta': { - 'done': True, - }, - } - ), - user, - db, - ) - elif res.get('error', None): - await update_message_by_id( - request, - channel.id, - response_message.id, - MessageForm( - **{ - 'content': f'Error: {res["error"]}', - 'meta': { - 'done': True, - }, - } - ), - user, - db, - ) except Exception as e: - log.info(e) - pass + log.exception(e) return True async def new_message_handler(request: Request, id: str, form_data: MessageForm, user, db): - channel = Channels.get_channel_by_id(id, db=db) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access( + if user.role != 'admin' and not await channel_has_access( user.id, channel, permission='write', @@ -1026,15 +1043,15 @@ async def new_message_handler(request: Request, id: str, form_data: MessageForm, raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - message = Messages.insert_new_message(form_data, channel.id, user.id, db=db) + message = await Messages.insert_new_message(form_data, channel.id, user.id, db=db) if message: if channel.type in ['group', 'dm']: - members = Channels.get_members_by_channel_id(channel.id, db=db) + members = await Channels.get_members_by_channel_id(channel.id, db=db) for member in members: if not member.is_active: - Channels.update_member_active_status(channel.id, member.user_id, True, db=db) + await Channels.update_member_active_status(channel.id, member.user_id, True, db=db) - message = Messages.get_message_by_id(message.id, db=db) + message = await Messages.get_message_by_id(message.id, db=db) event_data = { 'channel_id': channel.id, 'message_id': message.id, @@ -1054,7 +1071,7 @@ async def new_message_handler(request: Request, id: str, form_data: MessageForm, if message.parent_id: # If this message is a reply, emit to the parent message as well - parent_message = Messages.get_message_by_id(message.parent_id, db=db) + parent_message = await Messages.get_message_by_id(message.parent_id, db=db) if parent_message: await sio.emit( @@ -1086,16 +1103,18 @@ async def post_new_message( form_data: MessageForm, background_tasks: BackgroundTasks, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) + await check_channels_access(request, user) try: message, channel = await new_message_handler(request, id, form_data, user, db) try: if files := message.data.get('files', []): for file in files: - Channels.set_file_message_id_in_channel_by_id(channel.id, file.get('id', ''), message.id, db=db) + await Channels.set_file_message_id_in_channel_by_id( + channel.id, file.get('id', ''), message.id, db=db + ) except Exception as e: log.debug(e) @@ -1107,8 +1126,7 @@ async def post_new_message( async def background_handler(): await model_response_handler(request, channel, message, user) await send_notification( - request.app.state.WEBUI_NAME, - request.app.state.config.WEBUI_URL, + request, channel, message, active_user_ids, @@ -1136,31 +1154,32 @@ async def get_channel_message( id: str, message_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + message_user = await Users.get_user_by_id(message.user_id, db=db) return MessageResponse( **{ **message.model_dump(), - 'user': UserNameResponse(**Users.get_user_by_id(message.user_id, db=db).model_dump()), + 'user': UserNameResponse(**message_user.model_dump()) if message_user else None, } ) @@ -1176,21 +1195,21 @@ async def get_channel_message_data( id: str, message_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1216,21 +1235,22 @@ async def pin_channel_message( message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + # Pin/unpin mutates is_pinned/pinned_by/pinned_at — require write. + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='write', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1238,12 +1258,13 @@ async def pin_channel_message( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: - Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id, db=db) - message = Messages.get_message_by_id(message_id, db=db) + await Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) + message_user = await Users.get_user_by_id(message.user_id, db=db) return MessageUserResponse( **{ **message.model_dump(), - 'user': UserNameResponse(**Users.get_user_by_id(message.user_id, db=db).model_dump()), + 'user': UserNameResponse(**message_user.model_dump()) if message_user else None, } ) except Exception as e: @@ -1264,35 +1285,35 @@ async def get_channel_thread_messages( skip: int = 0, limit: int = 50, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit, db=db) + message_list = await Messages.get_messages_by_parent_id(id, message_id, skip, limit, db=db) if not message_list: return [] # Batch fetch all users in a single query (fixes N+1 problem) user_ids = list(set(m.user_id for m in message_list)) - users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} + fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: # Use message.user if present (for webhooks), otherwise look up by user_id user_info = message.user - if user_info is None and message.user_id in users: - user_info = UserNameResponse(**users[message.user_id].model_dump()) + if user_info is None and message.user_id in fetched_users: + user_info = UserNameResponse(**fetched_users[message.user_id].model_dump()) messages.append( MessageUserResponse( @@ -1300,7 +1321,7 @@ async def get_channel_thread_messages( **message.model_dump(), 'reply_count': 0, 'latest_reply_at': None, - 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), 'user': user_info, } ) @@ -1321,14 +1342,14 @@ async def update_message_by_id( message_id: str, form_data: MessageForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1336,19 +1357,22 @@ async def update_message_by_id( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Membership is not authorship — block cross-member edits. + if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( user.role != 'admin' and message.user_id != user.id - and not channel_has_access(user.id, channel, permission='write', strict=False, db=db) + and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db) ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - message = Messages.update_message_by_id(message_id, form_data, db=db) - message = Messages.get_message_by_id(message_id, db=db) + await Messages.update_message_by_id(message_id, form_data, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if message: await sio.emit( @@ -1388,18 +1412,18 @@ async def add_reaction_to_message( message_id: str, form_data: ReactionForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access( + if user.role != 'admin' and not await channel_has_access( user.id, channel, permission='write', @@ -1408,7 +1432,7 @@ async def add_reaction_to_message( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1416,8 +1440,8 @@ async def add_reaction_to_message( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: - Messages.add_reaction_to_message(message_id, user.id, form_data.name, db=db) - message = Messages.get_message_by_id(message_id, db=db) + await Messages.add_reaction_to_message(message_id, user.id, form_data.name, db=db) + message = await Messages.get_message_by_id(message_id, db=db) await sio.emit( 'events:channel', @@ -1455,18 +1479,18 @@ async def remove_reaction_by_id_and_user_id_and_name( message_id: str, form_data: ReactionForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != 'admin' and not channel_has_access( + if user.role != 'admin' and not await channel_has_access( user.id, channel, permission='write', @@ -1475,7 +1499,7 @@ async def remove_reaction_by_id_and_user_id_and_name( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1483,9 +1507,9 @@ async def remove_reaction_by_id_and_user_id_and_name( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: - Messages.remove_reaction_by_id_and_user_id_and_name(message_id, user.id, form_data.name, db=db) + await Messages.remove_reaction_by_id_and_user_id_and_name(message_id, user.id, form_data.name, db=db) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) await sio.emit( 'events:channel', @@ -1522,14 +1546,14 @@ async def delete_message_by_id( id: str, message_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - message = Messages.get_message_by_id(message_id, db=db) + message = await Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1537,13 +1561,16 @@ async def delete_message_by_id( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) if channel.type in ['group', 'dm']: - if not Channels.is_user_channel_member(channel.id, user.id, db=db): + if not await Channels.is_user_channel_member(channel.id, user.id, db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + # Membership is not authorship — block cross-member deletes. + if user.role != 'admin' and message.user_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( user.role != 'admin' and message.user_id != user.id - and not channel_has_access( + and not await channel_has_access( user.id, channel, permission='write', @@ -1554,7 +1581,7 @@ async def delete_message_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - Messages.delete_message_by_id(message_id, db=db) + await Messages.delete_message_by_id(message_id, db=db) await sio.emit( 'events:channel', { @@ -1575,7 +1602,7 @@ async def delete_message_by_id( if message.parent_id: # If this message is a reply, emit to the parent message as well - parent_message = Messages.get_message_by_id(message.parent_id, db=db) + parent_message = await Messages.get_message_by_id(message.parent_id, db=db) if parent_message: await sio.emit( @@ -1605,9 +1632,9 @@ async def delete_message_by_id( @router.get('/webhooks/{webhook_id}/profile/image') -def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_user)): +async def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_user)): """Get webhook profile image by webhook ID.""" - webhook = Channels.get_webhook_by_id(webhook_id) + webhook = await Channels.get_webhook_by_id(webhook_id) if not webhook: # Return default favicon if webhook not found return FileResponse(f'{STATIC_DIR}/favicon.png') @@ -1643,18 +1670,18 @@ async def get_channel_webhooks( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can view webhooks - if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + if not await Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) - return Channels.get_webhooks_by_channel_id(id, db=db) + return await Channels.get_webhooks_by_channel_id(id, db=db) @router.post('/{id}/webhooks/create', response_model=ChannelWebhookModel) @@ -1663,18 +1690,18 @@ async def create_channel_webhook( id: str, form_data: ChannelWebhookForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can create webhooks - if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + if not await Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) - webhook = Channels.insert_webhook(id, user.id, form_data, db=db) + webhook = await Channels.insert_webhook(id, user.id, form_data, db=db) if not webhook: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) @@ -1688,22 +1715,22 @@ async def update_channel_webhook( webhook_id: str, form_data: ChannelWebhookForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can update webhooks - if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + if not await Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) - webhook = Channels.get_webhook_by_id(webhook_id, db=db) + webhook = await Channels.get_webhook_by_id(webhook_id, db=db) if not webhook or webhook.channel_id != id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - updated = Channels.update_webhook_by_id(webhook_id, form_data, db=db) + updated = await Channels.update_webhook_by_id(webhook_id, form_data, db=db) if not updated: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) @@ -1716,22 +1743,22 @@ async def delete_channel_webhook( id: str, webhook_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - check_channels_access(request) - channel = Channels.get_channel_by_id(id, db=db) + await check_channels_access(request, user) + channel = await Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can delete webhooks - if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + if not await Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) - webhook = Channels.get_webhook_by_id(webhook_id, db=db) + webhook = await Channels.get_webhook_by_id(webhook_id, db=db) if not webhook or webhook.channel_id != id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - return Channels.delete_webhook_by_id(webhook_id, db=db) + return await Channels.delete_webhook_by_id(webhook_id, db=db) ############################ @@ -1749,25 +1776,25 @@ async def post_webhook_message( webhook_id: str, token: str, form_data: WebhookMessageForm, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Public endpoint to post messages via webhook. No authentication required.""" - check_channels_access(request) + await check_channels_access(request) # Validate webhook - webhook = Channels.get_webhook_by_id_and_token(webhook_id, token, db=db) + webhook = await Channels.get_webhook_by_id_and_token(webhook_id, token, db=db) if not webhook: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail='Invalid webhook URL', + detail=ERROR_MESSAGES.INVALID_URL, ) - channel = Channels.get_channel_by_id(webhook.channel_id, db=db) + channel = await Channels.get_channel_by_id(webhook.channel_id, db=db) if not channel: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Create message with webhook identity stored in meta - message = Messages.insert_new_message( + message = await Messages.insert_new_message( MessageForm(content=form_data.content, meta={'webhook': {'id': webhook.id}}), webhook.channel_id, webhook.user_id, # Required for DB but webhook info in meta takes precedence @@ -1777,14 +1804,14 @@ async def post_webhook_message( if not message: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='Failed to create message', + detail=ERROR_MESSAGES.DEFAULT('Failed to create message'), ) # Update last_used_at - Channels.update_webhook_last_used_at(webhook_id, db=db) + await Channels.update_webhook_last_used_at(webhook_id, db=db) # Get full message and emit event - message = Messages.get_message_by_id(message.id, db=db) + message = await Messages.get_message_by_id(message.id, db=db) event_data = { 'channel_id': channel.id, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 79d0525698e..9c4609477c0 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,12 +1,14 @@ import json import logging from typing import Optional -from sqlalchemy.orm import Session +from uuid import uuid4 +from sqlalchemy.ext.asyncio import AsyncSession import asyncio from fastapi.responses import StreamingResponse from open_webui.utils.misc import get_message_list +from open_webui.utils.middleware import serialize_output from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, @@ -16,16 +18,17 @@ ChatResponse, Chats, ChatTitleIdResponse, - SharedChatResponse, ChatStatsExport, AggregateChatStats, ChatBody, ChatHistoryStats, MessageStats, ) +from open_webui.models.shared_chats import SharedChats, SharedChatResponse +from open_webui.models.access_grants import AccessGrants from open_webui.models.tags import TagModel, Tags from open_webui.models.folders import Folders -from open_webui.internal.db import get_session +from open_webui.internal.db import get_async_session from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES @@ -34,7 +37,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants log = logging.getLogger(__name__) @@ -42,24 +45,26 @@ ############################ # GetChatList +# Let the record outlive the session, so that what was +# learned here not need to be learned again. ############################ @router.get('/', response_model=list[ChatTitleIdResponse]) @router.get('/list', response_model=list[ChatTitleIdResponse]) -def get_session_user_chat_list( +async def get_session_user_chat_list( user=Depends(get_verified_user), page: Optional[int] = None, include_pinned: Optional[bool] = False, include_folders: Optional[bool] = False, - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: if page is not None: limit = 60 skip = (page - 1) * limit - return Chats.get_chat_title_id_list_by_user_id( + return await Chats.get_chat_title_id_list_by_user_id( user.id, include_folders=include_folders, include_pinned=include_pinned, @@ -68,7 +73,7 @@ def get_session_user_chat_list( db=db, ) else: - return Chats.get_chat_title_id_list_by_user_id( + return await Chats.get_chat_title_id_list_by_user_id( user.id, include_folders=include_folders, include_pinned=include_pinned, @@ -86,17 +91,17 @@ def get_session_user_chat_list( @router.get('/stats/usage', response_model=ChatUsageStatsListResponse) -def get_session_user_chat_usage_stats( +async def get_session_user_chat_usage_stats( items_per_page: Optional[int] = 50, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: limit = items_per_page skip = (page - 1) * limit - result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit, db=db) + result = await Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit, db=db) chats = result.items total = result.total @@ -330,11 +335,11 @@ def get_message_content_length(message): return None -def calculate_chat_stats(user_id, skip=0, limit=10, filter=None): +async def calculate_chat_stats(user_id, skip=0, limit=10, filter=None): if filter is None: filter = {} - result = Chats.get_chats_by_user_id( + result = await Chats.get_chats_by_user_id( user_id, skip=skip, limit=limit, @@ -350,12 +355,12 @@ def calculate_chat_stats(user_id, skip=0, limit=10, filter=None): return chat_stats_export_list, result.total -def generate_chat_stats_jsonl_generator(user_id, filter): +async def generate_chat_stats_jsonl_generator(user_id, filter): """ - Synchronous generator for streaming chat stats export. + Async generator for streaming chat stats export. NOTE: We intentionally do NOT pass a shared db session here. Instead, we let - each batch create its own short-lived session via get_db_context(None). + each batch create its own short-lived session via get_async_db_context(None). This is critical for SQLite in low-resource environments because: 1. SQLite uses file-level locking 2. Holding a session open for the entire streaming duration blocks other requests @@ -366,12 +371,12 @@ def generate_chat_stats_jsonl_generator(user_id, filter): while True: # Each batch gets its own session that closes after the query - result = Chats.get_chats_by_user_id( + result = await Chats.get_chats_by_user_id( user_id, filter=filter, skip=skip, limit=limit, - db=None, # Let get_db_context create a fresh session per batch + db=None, # Let get_async_db_context create a fresh session per batch ) if not result.items: break @@ -419,7 +424,7 @@ async def export_chat_stats( limit = CHAT_EXPORT_PAGE_ITEM_COUNT skip = (page - 1) * limit - chat_stats_export_list, total = await asyncio.to_thread(calculate_chat_stats, user.id, skip, limit, filter) + chat_stats_export_list, total = await calculate_chat_stats(user.id, skip, limit, filter) return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page) @@ -438,7 +443,7 @@ async def export_single_chat_stats( request: Request, chat_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """ Export stats for exactly one chat by ID. @@ -452,7 +457,7 @@ async def export_single_chat_stats( ) try: - chat = Chats.get_chat_by_id(chat_id, db=db) + chat = await Chats.get_chat_by_id(chat_id, db=db) if not chat: raise HTTPException( @@ -467,8 +472,8 @@ async def export_single_chat_stats( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - # Process the chat for export - chat_stats = await asyncio.to_thread(_process_chat_for_export, chat) + # Process the chat for export (pure computation, no DB) + chat_stats = _process_chat_for_export(chat) if not chat_stats: raise HTTPException( @@ -489,15 +494,17 @@ async def export_single_chat_stats( async def delete_all_user_chats( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role == 'user' and not has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): + if user.role == 'user' and not await has_permission( + user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS + ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Chats.delete_chats_by_user_id(user.id, db=db) + result = await Chats.delete_chats_by_user_id(user.id, db=db) return result @@ -514,7 +521,7 @@ async def get_user_chat_list_by_user_id( order_by: Optional[str] = None, direction: Optional[str] = None, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not ENABLE_ADMIN_CHAT_ACCESS: raise HTTPException( @@ -536,7 +543,9 @@ async def get_user_chat_list_by_user_id( if direction: filter['direction'] = direction - return Chats.get_chat_list_by_user_id(user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db) + return await Chats.get_chat_list_by_user_id( + user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db + ) ############################ @@ -548,10 +557,10 @@ async def get_user_chat_list_by_user_id( async def create_new_chat( form_data: ChatForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - chat = Chats.insert_new_chat(user.id, form_data, db=db) + chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) return ChatResponse(**chat.model_dump()) except Exception as e: log.exception(e) @@ -567,10 +576,10 @@ async def create_new_chat( async def import_chats( form_data: ChatsImportForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - chats = Chats.import_chats(user.id, form_data.chats, db=db) + chats = await Chats.import_chats(user.id, form_data.chats, db=db) return chats except Exception as e: log.exception(e) @@ -583,11 +592,11 @@ async def import_chats( @router.get('/search', response_model=list[ChatTitleIdResponse]) -def search_user_chats( +async def search_user_chats( text: str, page: Optional[int] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if page is None: page = 1 @@ -597,7 +606,7 @@ def search_user_chats( chat_list = [ ChatTitleIdResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db) + for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db) ] # Delete tag if no chat is found @@ -605,9 +614,9 @@ def search_user_chats( if page == 1 and len(words) == 1 and words[0].startswith('tag:'): tag_id = words[0].replace('tag:', '') if len(chat_list) == 0: - if Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db): + if await Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db): log.debug(f'deleting tag: {tag_id}') - Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) + await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) return chat_list @@ -618,15 +627,17 @@ def search_user_chats( @router.get('/folder/{folder_id}', response_model=list[ChatResponse]) -async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_chats_by_folder_id( + folder_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): folder_ids = [folder_id] - children_folders = Folders.get_children_folders_by_id_and_user_id(folder_id, user.id, db=db) + children_folders = await Folders.get_children_folders_by_id_and_user_id(folder_id, user.id, db=db) if children_folders: folder_ids.extend([folder.id for folder in children_folders]) return [ ChatResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db) + for chat in await Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id, db=db) ] @@ -635,15 +646,16 @@ async def get_chat_list_by_folder_id( folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: limit = 10 skip = (page - 1) * limit + chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) return [ - {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at} - for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) + {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at} + for chat in chats ] except Exception as e: @@ -657,19 +669,54 @@ async def get_chat_list_by_folder_id( @router.get('/pinned', response_model=list[ChatTitleIdResponse]) -async def get_user_pinned_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return Chats.get_pinned_chats_by_user_id(user.id, db=db) +async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return await Chats.get_pinned_chats_by_user_id(user.id, db=db) ############################ # GetChats ############################ +CHAT_EXPORT_BATCH_SIZE = 100 -@router.get('/all', response_model=list[ChatResponse]) -async def get_user_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): - result = Chats.get_chats_by_user_id(user.id, db=db) - return [ChatResponse(**chat.model_dump()) for chat in result.items] + +async def generate_chat_export_ndjson(user_id: str): + """ + Async generator that streams all user chats as NDJSON (one JSON object per line). + + Uses short-lived DB sessions per batch to avoid holding locks for the + entire duration, which is critical for SQLite environments. + """ + skip = 0 + + while True: + result = await Chats.get_chats_by_user_id( + user_id, + skip=skip, + limit=CHAT_EXPORT_BATCH_SIZE, + db=None, + ) + if not result.items: + break + + for chat in result.items: + try: + yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n' + except Exception as e: + log.exception(f'Error serializing chat {chat.id}: {e}') + + if len(result.items) < CHAT_EXPORT_BATCH_SIZE: + break + + skip += CHAT_EXPORT_BATCH_SIZE + + +@router.get('/all') +async def get_user_chats(user=Depends(get_verified_user)): + return StreamingResponse( + generate_chat_export_ndjson(user.id), + media_type='application/x-ndjson', + ) ############################ @@ -678,8 +725,8 @@ async def get_user_chats(user=Depends(get_verified_user), db: Session = Depends( @router.get('/all/archived', response_model=list[ChatResponse]) -async def get_user_archived_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return [ChatResponse(**chat.model_dump()) for chat in Chats.get_archived_chats_by_user_id(user.id, db=db)] +async def get_user_archived_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_archived_chats_by_user_id(user.id, db=db)] ############################ @@ -688,9 +735,9 @@ async def get_user_archived_chats(user=Depends(get_verified_user), db: Session = @router.get('/all/tags', response_model=list[TagModel]) -async def get_all_user_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_all_user_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): try: - tags = Tags.get_tags_by_user_id(user.id, db=db) + tags = await Tags.get_tags_by_user_id(user.id, db=db) return tags except Exception as e: log.exception(e) @@ -703,13 +750,13 @@ async def get_all_user_tags(user=Depends(get_verified_user), db: Session = Depen @router.get('/all/db', response_model=list[ChatResponse]) -async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): if not ENABLE_ADMIN_EXPORT: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - return [ChatResponse(**chat.model_dump()) for chat in Chats.get_chats(db=db)] + return [ChatResponse(**chat.model_dump()) for chat in await Chats.get_chats(db=db)] ############################ @@ -724,7 +771,7 @@ async def get_archived_session_user_chat_list( order_by: Optional[str] = None, direction: Optional[str] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if page is None: page = 1 @@ -740,7 +787,7 @@ async def get_archived_session_user_chat_list( if direction: filter['direction'] = direction - return Chats.get_archived_chat_list_by_user_id( + return await Chats.get_archived_chat_list_by_user_id( user.id, filter=filter, skip=skip, @@ -755,8 +802,8 @@ async def get_archived_session_user_chat_list( @router.post('/archive/all', response_model=bool) -async def archive_all_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return Chats.archive_all_chats_by_user_id(user.id, db=db) +async def archive_all_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return await Chats.archive_all_chats_by_user_id(user.id, db=db) ############################ @@ -765,8 +812,8 @@ async def archive_all_chats(user=Depends(get_verified_user), db: Session = Depen @router.post('/unarchive/all', response_model=bool) -async def unarchive_all_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return Chats.unarchive_all_chats_by_user_id(user.id, db=db) +async def unarchive_all_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return await Chats.unarchive_all_chats_by_user_id(user.id, db=db) ############################ @@ -781,7 +828,7 @@ async def get_shared_session_user_chat_list( order_by: Optional[str] = None, direction: Optional[str] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if page is None: page = 1 @@ -797,7 +844,7 @@ async def get_shared_session_user_chat_list( if direction: filter['direction'] = direction - return Chats.get_shared_chat_list_by_user_id( + return await SharedChats.get_by_user_id( user.id, filter=filter, skip=skip, @@ -812,21 +859,40 @@ async def get_shared_session_user_chat_list( @router.get('/share/{share_id}', response_model=Optional[ChatResponse]) -async def get_shared_chat_by_id(share_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_shared_chat_by_id( + share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): if user.role == 'pending': raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS): - chat = Chats.get_chat_by_share_id(share_id, db=db) - elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: - chat = Chats.get_chat_by_id(share_id, db=db) + chat = await Chats.get_chat_by_share_id(share_id, db=db) - if chat: - return ChatResponse(**chat.model_dump()) + # Fallback: admins can also access any chat directly by chat ID + if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(share_id, db=db) - else: + if not chat: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + # Look up the original chat_id to check access grants (admins bypass) + if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS: + shared = await SharedChats.get_by_id(share_id, db=db) + if shared and shared.user_id != user.id: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return ChatResponse(**chat.model_dump()) + ############################ # GetChatsByTags @@ -846,11 +912,13 @@ class TagFilterForm(TagForm): async def get_user_chat_list_by_tag_name( form_data: TagFilterForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chats = Chats.get_chat_list_by_user_id_and_tag_name(user.id, form_data.name, form_data.skip, form_data.limit, db=db) + chats = await Chats.get_chat_list_by_user_id_and_tag_name( + user.id, form_data.name, form_data.skip, form_data.limit, db=db + ) if len(chats) == 0: - Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) + await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) return chats @@ -861,14 +929,28 @@ async def get_user_chat_list_by_tag_name( @router.get('/{id}', response_model=Optional[ChatResponse]) -async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + + if not chat: + # Check if user has access via access grants (shared_chat grants) + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) + else: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=id, + permission='read', + db=db, + ) + if has_grant: + chat = await Chats.get_chat_by_id(id, db=db) if chat: return ChatResponse(**chat.model_dump()) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -881,12 +963,20 @@ async def update_chat_by_id( id: str, form_data: ChatForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = {**chat.chat, **form_data.chat} - chat = Chats.update_chat_by_id(id, updated_chat, db=db) + + # Re-derive content from output for assistant messages so that + # frontend edits to output items are always reflected in content. + # serialize_output() is the single source of truth for this conversion. + for msg in updated_chat.get('history', {}).get('messages', {}).values(): + if msg.get('role') == 'assistant' and msg.get('output'): + msg['content'] = serialize_output(msg['output']) + + chat = await Chats.update_chat_by_id(id, updated_chat, db=db) return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -908,9 +998,9 @@ async def update_chat_message_by_id( message_id: str, form_data: MessageForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id(id, db=db) + chat = await Chats.get_chat_by_id(id, db=db) if not chat: raise HTTPException( @@ -924,18 +1014,17 @@ async def update_chat_message_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.upsert_message_to_chat_by_id_and_message_id( + chat = await Chats.upsert_message_to_chat_by_id_and_message_id( id, message_id, { 'content': form_data.content, }, - db=db, ) - event_emitter = get_event_emitter( + event_emitter = await get_event_emitter( { - 'user_id': user.id, + 'user_id': chat.user_id, 'chat_id': id, 'message_id': message_id, }, @@ -971,9 +1060,9 @@ async def send_chat_message_event_by_id( message_id: str, form_data: EventForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id(id, db=db) + chat = await Chats.get_chat_by_id(id, db=db) if not chat: raise HTTPException( @@ -987,9 +1076,9 @@ async def send_chat_message_event_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - event_emitter = get_event_emitter( + event_emitter = await get_event_emitter( { - 'user_id': user.id, + 'user_id': chat.user_id, 'chat_id': id, 'message_id': message_id, } @@ -1015,36 +1104,36 @@ async def delete_chat_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role == 'admin': - chat = Chats.get_chat_by_id(id, db=db) + chat = await Chats.get_chat_by_id(id, db=db) if not chat: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) + await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) - result = Chats.delete_chat_by_id(id, db=db) + result = await Chats.delete_chat_by_id(id, db=db) return result else: - if not has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if not chat: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) + await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) - result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) + result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) return result @@ -1054,8 +1143,10 @@ async def delete_chat_by_id( @router.get('/{id}/pinned', response_model=Optional[bool]) -async def get_pinned_status_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def get_pinned_status_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: return chat.pinned else: @@ -1068,10 +1159,10 @@ async def get_pinned_status_by_id(id: str, user=Depends(get_verified_user), db: @router.post('/{id}/pin', response_model=Optional[ChatResponse]) -async def pin_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def pin_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.toggle_chat_pinned_by_id(id, db=db) + chat = await Chats.toggle_chat_pinned_by_id(id, db=db) return chat else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) @@ -1091,9 +1182,9 @@ async def clone_chat_by_id( form_data: CloneForm, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = { **chat.chat, @@ -1102,7 +1193,7 @@ async def clone_chat_by_id( 'title': form_data.title if form_data.title else f'Clone of {chat.title}', } - chats = Chats.import_chats( + chats = await Chats.import_chats( user.id, [ ChatImportForm( @@ -1135,45 +1226,67 @@ async def clone_chat_by_id( @router.post('/{id}/clone/shared', response_model=Optional[ChatResponse]) -async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - if user.role == 'admin': - chat = Chats.get_chat_by_id(id, db=db) - else: - chat = Chats.get_chat_by_share_id(id, db=db) +async def clone_shared_chat_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + chat = await Chats.get_chat_by_share_id(id, db=db) - if chat: - updated_chat = { - **chat.chat, - 'originalChatId': chat.id, - 'branchPointMessageId': chat.chat['history']['currentId'], - 'title': f'Clone of {chat.title}', - } + # Fallback: admins can also access any chat directly by chat ID + if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_id(id, db=db) - chats = Chats.import_chats( - user.id, - [ - ChatImportForm( - **{ - 'chat': updated_chat, - 'meta': chat.meta, - 'pinned': chat.pinned, - 'folder_id': chat.folder_id, - } - ) - ], - db=db, + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, ) - if chats: - chat = chats[0] - return ChatResponse(**chat.model_dump()) - else: + # Enforce access grants (owner and admins bypass) + shared = await SharedChats.get_by_id(id, db=db) + if shared and user.role != 'admin' and shared.user_id != user.id: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + if not has_grant: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(), + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + + updated_chat = { + **chat.chat, + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': f'Clone of {chat.title}', + } + + chats = await Chats.import_chats( + user.id, + [ + ChatImportForm( + **{ + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, + } + ) + ], + db=db, + ) + + if chats: + chat = chats[0] + return ChatResponse(**chat.model_dump()) else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) ############################ @@ -1182,18 +1295,18 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: @router.post('/{id}/archive', response_model=Optional[ChatResponse]) -async def archive_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def archive_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.toggle_chat_archive_by_id(id, db=db) + chat = await Chats.toggle_chat_archive_by_id(id, db=db) tag_ids = chat.meta.get('tags', []) if chat.archived: # Archived chats are excluded from count — clean up orphans - Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db) + await Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db) else: # Unarchived — ensure tag rows exist - Tags.ensure_tags_exist(tag_ids, user.id, db=db) + await Tags.ensure_tags_exist(tag_ids, user.id, db=db) return ChatResponse(**chat.model_dump()) else: @@ -1210,30 +1323,42 @@ async def share_chat_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if (user.role != 'admin') and ( - not has_permission(user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS) + not await has_permission(user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: if chat.share_id: - shared_chat = Chats.update_shared_chat_by_chat_id(chat.id, db=db) - return ChatResponse(**shared_chat.model_dump()) - - shared_chat = Chats.insert_shared_chat_by_chat_id(chat.id, db=db) - if not shared_chat: + # Re-snapshot existing share + shared = await SharedChats.update(chat.share_id, db=db) + if shared: + # Re-fetch the original chat to return + chat = await Chats.get_chat_by_id(id, db=db) + return ChatResponse(**chat.model_dump()) + + # Create new share + shared = await SharedChats.create(id, user.id, db=db) + if not shared: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(), ) - return ChatResponse(**shared_chat.model_dump()) + # Set share_id on the original chat + chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(), + ) + return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -1243,21 +1368,26 @@ async def share_chat_by_id( ############################ -# DeletedSharedChatById +# DeleteSharedChatById ############################ @router.delete('/{id}/share', response_model=Optional[bool]) -async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def delete_shared_chat_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: if not chat.share_id: return False - result = Chats.delete_shared_chat_by_chat_id(id, db=db) - update_result = Chats.update_chat_share_id_by_id(id, None, db=db) + await SharedChats.delete_by_chat_id(id, db=db) + await Chats.update_chat_share_id_by_id(id, None, db=db) - return result and update_result != None + # Revoke all access grants for this shared chat + await AccessGrants.set_access_grants('shared_chat', id, [], db=db) + + return True else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1265,6 +1395,79 @@ async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: ) +############################ +# UpdateSharedChatAccessById +############################ + + +class ChatAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse]) +async def update_shared_chat_access_by_id( + request: Request, + id: str, + form_data: ChatAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role == 'admin': + chat = await Chats.get_chat_by_id(id, db=db) + else: + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_chats', + ) + + await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) + + return ChatResponse(**chat.model_dump()) + + +############################ +# GetSharedChatAccessById +############################ + + +@router.get('/shared/{id}/access', response_model=list) +async def get_shared_chat_access_by_id( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role == 'admin': + chat = await Chats.get_chat_by_id(id, db=db) + else: + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db) + return [ + { + 'id': g.id, + 'principal_type': g.principal_type, + 'principal_id': g.principal_id, + 'permission': g.permission, + } + for g in grants + ] + + ############################ # UpdateChatFolderIdById ############################ @@ -1279,11 +1482,11 @@ async def update_chat_folder_id_by_id( id: str, form_data: ChatFolderIdForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) + chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) return ChatResponse(**chat.model_dump()) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) @@ -1295,11 +1498,11 @@ async def update_chat_folder_id_by_id( @router.get('/{id}/tags', response_model=list[TagModel]) -async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: tags = chat.meta.get('tags', []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) + return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1314,9 +1517,9 @@ async def add_tag_by_id_and_tag_name( id: str, form_data: TagForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: tags = chat.meta.get('tags', []) tag_id = form_data.name.replace(' ', '_').lower() @@ -1328,11 +1531,11 @@ async def add_tag_by_id_and_tag_name( ) if tag_id not in tags: - Chats.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) + await Chats.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) tags = chat.meta.get('tags', []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) + return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) @@ -1347,18 +1550,18 @@ async def delete_tag_by_id_and_tag_name( id: str, form_data: TagForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - Chats.delete_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) + await Chats.delete_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) - if Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) == 0: - Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) + if await Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) == 0: + await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) tags = chat.meta.get('tags', []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) + return await Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) @@ -1369,12 +1572,14 @@ async def delete_tag_by_id_and_tag_name( @router.delete('/{id}/tags/all', response_model=Optional[bool]) -async def delete_all_tags_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) +async def delete_all_tags_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: old_tags = chat.meta.get('tags', []) - Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db) - Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db) + await Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db) + await Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db) return True else: diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index e0fb4bb6100..1d55dba75e9 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -6,9 +6,10 @@ from typing import Optional -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.config import get_config, save_config +from open_webui.utils.headers import get_custom_headers +from open_webui.config import get_config, save_config, async_save_config from open_webui.config import BannerModel from open_webui.utils.tools import ( @@ -24,8 +25,10 @@ from open_webui.utils.oauth import ( get_discovery_urls, get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, + resolve_oauth_client_info, OAuthClientInformationFull, ) from mcp.shared.auth import OAuthMetadata @@ -37,6 +40,8 @@ ############################ # ImportConfig +# Thy configuration come, thy settings be done, +# in production as it is in development. ############################ @@ -45,8 +50,9 @@ class ImportConfigForm(BaseModel): @router.post('/import', response_model=dict) -async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)): - save_config(form_data.config) +async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)): + await async_save_config(form_data.config) + request.app.state.config._sync_to_redis() return get_config() @@ -97,6 +103,8 @@ class OAuthClientRegistrationForm(BaseModel): url: str client_id: str client_name: Optional[str] = None + client_secret: Optional[str] = None + oauth_server_url: Optional[str] = None @router.post('/oauth/clients/register') @@ -111,9 +119,21 @@ async def register_oauth_client( if type: oauth_client_id = f'{type}:{form_data.client_id}' - oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, oauth_client_id, form_data.url - ) + oauth_server_url = form_data.oauth_server_url if form_data.oauth_server_url else form_data.url + + if form_data.client_secret: + # Static credentials: skip dynamic registration, build from provided credentials + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + oauth_client_id, + oauth_server_url, + oauth_client_id=form_data.client_id, + oauth_client_secret=form_data.client_secret, + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( + request, oauth_client_id, oauth_server_url + ) return { 'status': True, 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), @@ -139,6 +159,7 @@ class ToolServerConnection(BaseModel): headers: Optional[dict | str] = None key: Optional[str] config: Optional[dict] + info: Optional[dict] = None model_config = ConfigDict(extra='allow') @@ -164,7 +185,7 @@ async def set_tool_servers_config( server_type = connection.get('type', 'openapi') auth_type = connection.get('auth_type', 'none') - if auth_type == 'oauth_2.1': + if auth_type in ('oauth_2.1', 'oauth_2.1_static'): # Remove existing OAuth clients for tool servers server_id = connection.get('info', {}).get('id') client_key = f'{server_type}:{server_id}' @@ -187,11 +208,9 @@ async def set_tool_servers_config( server_id = connection.get('info', {}).get('id') auth_type = connection.get('auth_type', 'none') - if auth_type == 'oauth_2.1' and server_id: + if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id: try: - oauth_client_info = connection.get('info', {}).get('oauth_client_info', '') - oauth_client_info = decrypt_data(oauth_client_info) - + oauth_client_info = resolve_oauth_client_info(connection) request.app.state.oauth_client_manager.add_client( f'{server_type}:{server_id}', OAuthClientInformationFull(**oauth_client_info), @@ -255,6 +274,98 @@ async def set_terminal_servers_config( } +@router.post('/terminal_servers/verify') +async def verify_terminal_server_connection( + request: Request, form_data: TerminalServerConnection, user=Depends(get_admin_user) +): + """ + Verify the connection to a terminal server by detecting its type. + + Tries GET {url}/api/v1/policies (orchestrator) then GET {url}/api/config + (plain terminal). Returns ``{status: true, type: "orchestrator"|"terminal"}``. + """ + base_url = (form_data.url or '').rstrip('/') + if not base_url: + raise HTTPException(status_code=400, detail='Terminal server URL is required') + + headers = {} + if form_data.auth_type == 'bearer' and form_data.key: + headers['Authorization'] = f'Bearer {form_data.key}' + + try: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: + # Orchestrators expose a policies API; plain terminals don't. + try: + async with session.get( + f'{base_url}/api/v1/policies', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.ok: + return {'status': True, 'type': 'orchestrator'} + except Exception: + pass + + # Fall back to open-terminal config endpoint. + try: + async with session.get( + f'{base_url}/api/config', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.ok: + return {'status': True, 'type': 'terminal'} + except Exception: + pass + + except Exception as e: + log.debug(f'Failed to connect to the terminal server: {e}') + + raise HTTPException(status_code=400, detail='Failed to connect to the terminal server') + + +class TerminalServerPolicyForm(BaseModel): + url: str + key: Optional[str] = '' + auth_type: Optional[str] = 'bearer' + policy_id: str + policy_data: dict + + +@router.post('/terminal_servers/policy') +async def put_terminal_server_policy( + request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user) +): + """ + Proxy a policy PUT to an orchestrator terminal server. + """ + base_url = (form_data.url or '').rstrip('/') + if not base_url: + raise HTTPException(status_code=400, detail='Terminal server URL is required') + + headers = {'Content-Type': 'application/json'} + if form_data.auth_type == 'bearer' and form_data.key: + headers['Authorization'] = f'Bearer {form_data.key}' + + try: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: + policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}' + async with session.put( + policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.ok: + return await resp.json() + detail = await resp.text() + raise HTTPException(status_code=resp.status, detail=detail) + except HTTPException: + raise + except Exception as e: + log.debug(f'Failed to save policy to terminal server: {e}') + raise HTTPException(status_code=400, detail='Failed to save policy to terminal server') + + @router.post('/tool_servers/verify') async def verify_tool_servers_config(request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)): """ @@ -262,15 +373,22 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn """ try: if form_data.type == 'mcp': - if form_data.auth_type == 'oauth_2.1': - discovery_urls = await get_discovery_urls(form_data.url) + if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'): + oauth_server_url = ( + form_data.info.get('oauth_server_url') + if form_data.info and form_data.info.get('oauth_server_url') + else form_data.url + ) + discovery_urls = await get_discovery_urls(oauth_server_url) for discovery_url in discovery_urls: log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as session: - async with session.get(discovery_url) as oauth_server_metadata_response: + async with session.get( + discovery_url, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as oauth_server_metadata_response: if oauth_server_metadata_response.status == 200: try: oauth_server_metadata = OAuthMetadata.model_validate( @@ -320,7 +438,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - headers.update(form_data.headers) + custom_headers = get_custom_headers(form_data.headers, user) + headers.update(custom_headers) await client.connect(form_data.url, headers=headers) specs = await client.list_tool_specs() @@ -364,7 +483,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - headers.update(form_data.headers) + custom_headers = get_custom_headers(form_data.headers, user) + headers.update(custom_headers) url = get_tool_server_url(form_data.url, form_data.path) return await get_tool_server_data(url, headers=headers) @@ -475,6 +595,13 @@ class ModelsConfigForm(BaseModel): DEFAULT_MODEL_PARAMS: Optional[dict] = None +@router.get('/models/defaults') +async def get_models_defaults(request: Request, user=Depends(get_verified_user)): + return { + 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, + } + + @router.get('/models', response_model=ModelsConfigForm) async def get_models_config(request: Request, user=Depends(get_admin_user)): return { diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index f301613286b..072c7fa7322 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -20,8 +20,8 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -30,6 +30,8 @@ # Leaderboard Elo Rating Computation +# The judgment has already been rendered with grace; +# the scales have been balanced by a hand that never errs. # # How it works: # 1. Each model starts with a rating of 1000 @@ -206,10 +208,10 @@ class LeaderboardResponse(BaseModel): async def get_leaderboard( query: Optional[str] = None, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get model leaderboard with Elo ratings. Query filters by tag similarity.""" - feedbacks = Feedbacks.get_feedbacks_for_leaderboard(db=db) + feedbacks = await Feedbacks.get_feedbacks_for_leaderboard(db=db) similarities = None if query and query.strip(): @@ -242,10 +244,10 @@ async def get_model_history( model_id: str, days: int = 30, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get daily win/loss history for a specific model.""" - history = Feedbacks.get_model_evaluation_history(model_id=model_id, days=days, db=db) + history = await Feedbacks.get_model_evaluation_history(model_id=model_id, days=days, db=db) return ModelHistoryResponse(model_id=model_id, history=history) @@ -289,38 +291,49 @@ async def update_config( } +@router.get('/feedbacks/models', response_model=list[str]) +async def get_feedback_model_ids(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + return await Feedbacks.get_distinct_model_ids(db=db) + + @router.get('/feedbacks/all', response_model=list[FeedbackResponse]) -async def get_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): - feedbacks = Feedbacks.get_all_feedbacks(db=db) +async def get_all_feedbacks(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + feedbacks = await Feedbacks.get_all_feedbacks(db=db) return feedbacks @router.get('/feedbacks/all/ids', response_model=list[FeedbackIdResponse]) -async def get_all_feedback_ids(user=Depends(get_admin_user), db: Session = Depends(get_session)): - return Feedbacks.get_all_feedback_ids(db=db) +async def get_all_feedback_ids(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + return await Feedbacks.get_all_feedback_ids(db=db) @router.delete('/feedbacks/all') -async def delete_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): - success = Feedbacks.delete_all_feedbacks(db=db) +async def delete_all_feedbacks(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + success = await Feedbacks.delete_all_feedbacks(db=db) return success @router.get('/feedbacks/all/export', response_model=list[FeedbackModel]) -async def export_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): - feedbacks = Feedbacks.get_all_feedbacks(db=db) +async def export_all_feedbacks( + model_id: Optional[str] = None, + user=Depends(get_admin_user), + db: AsyncSession = Depends(get_async_session), +): + feedbacks = await Feedbacks.get_all_feedbacks(db=db) + if model_id: + feedbacks = [f for f in feedbacks if f.data and f.data.get('model_id') == model_id] return feedbacks @router.get('/feedbacks/user', response_model=list[FeedbackUserResponse]) -async def get_feedbacks(user=Depends(get_verified_user), db: Session = Depends(get_session)): - feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db) +async def get_feedbacks(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + feedbacks = await Feedbacks.get_feedbacks_by_user_id(user.id, db=db) return feedbacks @router.delete('/feedbacks', response_model=bool) -async def delete_feedbacks(user=Depends(get_verified_user), db: Session = Depends(get_session)): - success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db) +async def delete_feedbacks(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + success = await Feedbacks.delete_feedbacks_by_user_id(user.id, db=db) return success @@ -332,8 +345,9 @@ async def get_feedbacks( order_by: Optional[str] = None, direction: Optional[str] = None, page: Optional[int] = 1, + model_id: Optional[str] = None, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -345,8 +359,10 @@ async def get_feedbacks( filter['order_by'] = order_by if direction: filter['direction'] = direction + if model_id: + filter['model_id'] = model_id - result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db) + result = await Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db) return result @@ -355,9 +371,9 @@ async def create_feedback( request: Request, form_data: FeedbackForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data, db=db) + feedback = await Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data, db=db) if not feedback: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -368,11 +384,11 @@ async def create_feedback( @router.get('/feedback/{id}', response_model=FeedbackModel) -async def get_feedback_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_feedback_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): if user.role == 'admin': - feedback = Feedbacks.get_feedback_by_id(id=id, db=db) + feedback = await Feedbacks.get_feedback_by_id(id=id, db=db) else: - feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) + feedback = await Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) if not feedback: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -385,12 +401,12 @@ async def update_feedback_by_id( id: str, form_data: FeedbackForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role == 'admin': - feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db) + feedback = await Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db) else: - feedback = Feedbacks.update_feedback_by_id_and_user_id(id=id, user_id=user.id, form_data=form_data, db=db) + feedback = await Feedbacks.update_feedback_by_id_and_user_id(id=id, user_id=user.id, form_data=form_data, db=db) if not feedback: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) @@ -399,11 +415,13 @@ async def update_feedback_by_id( @router.delete('/feedback/{id}') -async def delete_feedback_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def delete_feedback_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): if user.role == 'admin': - success = Feedbacks.delete_feedback_by_id(id=id, db=db) + success = await Feedbacks.delete_feedback_by_id(id=id, db=db) else: - success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) + success = await Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) if not success: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index b6d081085f6..86beeec1889 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -21,11 +21,11 @@ ) from fastapi.responses import FileResponse, StreamingResponse -from sqlalchemy.orm import Session -from open_webui.internal.db import get_session, SessionLocal +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import get_async_session, get_async_db_context from open_webui.constants import ERROR_MESSAGES -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.models.channels import Channels from open_webui.models.users import Users @@ -48,7 +48,7 @@ from open_webui.storage.provider import Storage -from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STORAGE_LOCAL_CACHE, STORAGE_PROVIDER, UPLOAD_DIR from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.misc import strict_match_mime_type from pydantic import BaseModel @@ -62,6 +62,8 @@ ############################ # Upload File +# What was entrusted here was given in good faith. Let it +# be returned the same way, whole and undiminished. ############################ @@ -86,16 +88,30 @@ def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool: return False -def process_uploaded_file( +def _cleanup_local_cache(file_path: str) -> None: + """Remove the local cached copy of a cloud-stored file after processing.""" + if STORAGE_LOCAL_CACHE or STORAGE_PROVIDER == 'local': + return + try: + local_filename = os.path.basename(file_path) + local_path = os.path.join(UPLOAD_DIR, local_filename) + if os.path.isfile(local_path): + os.remove(local_path) + log.debug(f'Cleaned up local cache: {local_path}') + except OSError as e: + log.warning(f'Failed to clean up local cache for {file_path}: {e}') + + +async def process_uploaded_file( request, file, file_path, file_item, file_metadata, user, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ): - def _process_handler(db_session): + async def _process_handler(db_session): try: content_type = file.content_type @@ -108,10 +124,16 @@ def _process_handler(db_session): stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) if strict_match_mime_type(stt_supported_content_types, content_type): - file_path_processed = Storage.get_file(file_path) - result = transcribe(request, file_path_processed, file_metadata, user) + file_path_processed = await asyncio.to_thread(Storage.get_file, file_path) + result = await asyncio.to_thread( + transcribe, + request, + file_path_processed, + file_metadata, + user, + ) - process_file( + await process_file( request, ProcessFileForm(file_id=file_item.id, content=result.get('text', '')), user=user, @@ -120,7 +142,7 @@ def _process_handler(db_session): elif (not content_type.startswith(('image/', 'video/'))) or ( request.app.state.config.CONTENT_EXTRACTION_ENGINE == 'external' ): - process_file( + await process_file( request, ProcessFileForm(file_id=file_item.id), user=user, @@ -130,7 +152,7 @@ def _process_handler(db_session): raise Exception(f'File type {content_type} is not supported for processing') else: log.info(f'File type {file.content_type} is not provided, but trying to process anyway') - process_file( + await process_file( request, ProcessFileForm(file_id=file_item.id), user=user, @@ -139,7 +161,7 @@ def _process_handler(db_session): except Exception as e: log.error(f'Error processing file: {file_item.id}') - Files.update_file_data_by_id( + await Files.update_file_data_by_id( file_item.id, { 'status': 'failed', @@ -148,15 +170,18 @@ def _process_handler(db_session): db=db_session, ) - if db: - _process_handler(db) - else: - with SessionLocal() as db_session: - _process_handler(db_session) + try: + if db: + await _process_handler(db) + else: + async with get_async_db_context() as db_session: + await _process_handler(db_session) + finally: + _cleanup_local_cache(file_path) @router.post('/', response_model=FileModelResponse) -def upload_file( +async def upload_file( request: Request, background_tasks: BackgroundTasks, file: UploadFile = File(...), @@ -164,9 +189,9 @@ def upload_file( process: bool = Query(True), process_in_background: bool = Query(True), user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - return upload_file_handler( + return await upload_file_handler( request, file=file, metadata=metadata, @@ -178,7 +203,7 @@ def upload_file( ) -def upload_file_handler( +async def upload_file_handler( request: Request, file: UploadFile = File(...), metadata: Optional[dict | str] = Form(None), @@ -186,7 +211,7 @@ def upload_file_handler( process_in_background: bool = Query(True), user=Depends(get_verified_user), background_tasks: Optional[BackgroundTasks] = None, - db: Optional[Session] = None, + db: Optional[AsyncSession] = None, ): log.info(f'file.content_type: {file.content_type} {process}') @@ -205,8 +230,8 @@ def upload_file_handler( filename = os.path.basename(unsanitized_filename) file_extension = os.path.splitext(filename)[1] - # Remove the leading dot from the file extension - file_extension = file_extension[1:] if file_extension else '' + # Remove the leading dot from the file extension and lowercase it + file_extension = file_extension[1:].lower() if file_extension else '' if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS: request.app.state.config.ALLOWED_FILE_EXTENSIONS = [ @@ -223,7 +248,8 @@ def upload_file_handler( id = str(uuid.uuid4()) name = filename filename = f'{id}_{filename}' - contents, file_path = Storage.upload_file( + contents, file_path = await asyncio.to_thread( + Storage.upload_file, file.file, filename, { @@ -234,7 +260,7 @@ def upload_file_handler( }, ) - file_item = Files.insert_new_file( + file_item = await Files.insert_new_file( user.id, FileForm( **{ @@ -256,9 +282,9 @@ def upload_file_handler( ) if 'channel_id' in file_metadata: - channel = Channels.get_channel_by_id_and_user_id(file_metadata['channel_id'], user.id, db=db) + channel = await Channels.get_channel_by_id_and_user_id(file_metadata['channel_id'], user.id, db=db) if channel: - Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id, db=db) + await Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id, db=db) if process: if background_tasks and process_in_background: @@ -273,7 +299,7 @@ def upload_file_handler( ) return {'status': True, **file_item.model_dump()} else: - process_uploaded_file( + await process_uploaded_file( request, file, file_path, @@ -315,12 +341,12 @@ async def list_files( user=Depends(get_verified_user), page: int = Query(1, ge=1, description='Page number (1-indexed)'), content: bool = Query(True), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): skip = (page - 1) * PAGE_SIZE user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id - result = Files.get_file_list(user_id=user_id, skip=skip, limit=PAGE_SIZE, db=db) + result = await Files.get_file_list(user_id=user_id, skip=skip, limit=PAGE_SIZE, db=db) if not content: for file in result.items: @@ -345,7 +371,7 @@ async def search_files( skip: int = Query(0, ge=0, description='Number of files to skip'), limit: int = Query(100, ge=1, le=1000, description='Maximum number of files to return'), user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """ Search for files by filename with support for wildcard patterns. @@ -355,7 +381,7 @@ async def search_files( user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id # Use optimized database query with pagination - files = Files.search_files( + files = await Files.search_files( user_id=user_id, filename=filename, skip=skip, @@ -383,12 +409,12 @@ async def search_files( @router.delete('/all') -async def delete_all_files(user=Depends(get_admin_user), db: Session = Depends(get_session)): - result = Files.delete_all_files(db=db) +async def delete_all_files(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + result = await Files.delete_all_files(db=db) if result: try: - Storage.delete_all_files() - VECTOR_DB_CLIENT.reset() + await asyncio.to_thread(Storage.delete_all_files) + await ASYNC_VECTOR_DB_CLIENT.reset() except Exception as e: log.exception(e) log.error('Error deleting files') @@ -410,8 +436,8 @@ async def delete_all_files(user=Depends(get_admin_user), db: Session = Depends(g @router.get('/{id}', response_model=Optional[FileModel]) -async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - file = Files.get_file_by_id(id, db=db) +async def get_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -419,7 +445,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): return file else: raise HTTPException( @@ -433,9 +459,9 @@ async def get_file_process_status( id: str, stream: bool = Query(False), user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - file = Files.get_file_by_id(id, db=db) + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -443,7 +469,7 @@ async def get_file_process_status( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 @@ -452,7 +478,7 @@ async def event_stream(file_id): # Each poll creates its own short-lived session to avoid holding a # connection for hours. A WebSocket push would be more efficient. for _ in range(MAX_FILE_PROCESSING_DURATION): - file_item = Files.get_file_by_id(file_id) # Creates own session + file_item = await Files.get_file_by_id(file_id) # Creates own session if file_item: data = file_item.model_dump().get('data', {}) status = data.get('status') @@ -493,8 +519,10 @@ async def event_stream(file_id): @router.get('/{id}/data/content') -async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - file = Files.get_file_by_id(id, db=db) +async def get_file_data_content_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -502,7 +530,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): return {'content': file.data.get('content', '')} else: raise HTTPException( @@ -521,14 +549,14 @@ class ContentForm(BaseModel): @router.post('/{id}/data/content/update') -def update_file_data_content_by_id( +async def update_file_data_content_by_id( request: Request, id: str, form_data: ContentForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - file = Files.get_file_by_id(id, db=db) + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -536,15 +564,15 @@ def update_file_data_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'write', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db): try: - process_file( + await process_file( request, ProcessFileForm(file_id=id, content=form_data.content), user=user, db=db, ) - file = Files.get_file_by_id(id=id, db=db) + file = await Files.get_file_by_id(id=id, db=db) except Exception as e: log.exception(e) log.error(f'Error processing file: {file.id}') @@ -552,13 +580,13 @@ def update_file_data_content_by_id( # Propagate content change to all knowledge collections referencing # this file. Without this the old embeddings remain in the knowledge # collection and RAG returns both stale and current data (#20558). - knowledges = Knowledges.get_knowledges_by_file_id(id, db=db) + knowledges = await Knowledges.get_knowledges_by_file_id(id, db=db) for knowledge in knowledges: try: # Remove old embeddings for this file from the KB collection - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) # Re-add from the now-updated file-{file_id} collection - process_file( + await process_file( request, ProcessFileForm(file_id=id, collection_name=knowledge.id), user=user, @@ -585,9 +613,9 @@ async def get_file_content_by_id( id: str, user=Depends(get_verified_user), attachment: bool = Query(False), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - file = Files.get_file_by_id(id, db=db) + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -595,9 +623,9 @@ async def get_file_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -644,8 +672,10 @@ async def get_file_content_by_id( @router.get('/{id}/content/html') -async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - file = Files.get_file_by_id(id, db=db) +async def get_html_file_content_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -653,16 +683,16 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), detail=ERROR_MESSAGES.NOT_FOUND, ) - file_user = Users.get_user_by_id(file.user_id, db=db) - if not file_user.role == 'admin': + file_user = await Users.get_user_by_id(file.user_id, db=db) + if not file_user or file_user.role != 'admin': raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -691,8 +721,10 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), @router.get('/{id}/content/{file_name}') -async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - file = Files.get_file_by_id(id, db=db) +async def get_file_content_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -700,7 +732,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): file_path = file.path # Handle Unicode filenames @@ -709,7 +741,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S headers = {'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"} if file_path: - file_path = Storage.get_file(file_path) + file_path = await asyncio.to_thread(Storage.get_file, file_path) file_path = Path(file_path) # Check if the file already exists in the cache @@ -722,7 +754,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: S ) else: # File path doesn’t exist, return the content as .txt if possible - file_content = file.content.get('content', '') + file_content = file.data.get('content', '') file_name = file.filename # Create a generator that encodes the file content @@ -747,8 +779,8 @@ def generator(): @router.delete('/{id}') -async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - file = Files.get_file_by_id(id, db=db) +async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + file = await Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -756,25 +788,25 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: Sessio detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'write', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'write', user, db=db): # Clean up KB associations and embeddings before deleting - knowledges = Knowledges.get_knowledges_by_file_id(id, db=db) + knowledges = await Knowledges.get_knowledges_by_file_id(id, db=db) for knowledge in knowledges: # Remove KB-file relationship - Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db) + await Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db) # Clean KB embeddings (same logic as /knowledge/{id}/file/remove) try: - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) if file.hash: - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) except Exception as e: log.debug(f'KB embedding cleanup for {knowledge.id}: {e}') - result = Files.delete_file_by_id(id, db=db) + result = await Files.delete_file_by_id(id, db=db) if result: try: - Storage.delete_file(file.path) - VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') + await asyncio.to_thread(Storage.delete_file, file.path) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') except Exception as e: log.exception(e) log.error('Error deleting files') diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 0bf5a87f1ed..7dda918821c 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -16,14 +16,12 @@ Folders, ) from open_webui.models.chats import Chats -from open_webui.models.files import Files -from open_webui.models.knowledge import Knowledges from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request @@ -32,6 +30,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control.files import get_accessible_folder_files log = logging.getLogger(__name__) @@ -48,7 +47,7 @@ async def get_folders( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if request.app.state.config.ENABLE_FOLDERS is False: raise HTTPException( @@ -56,7 +55,7 @@ async def get_folders( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.folders', request.app.state.config.USER_PERMISSIONS, @@ -67,29 +66,21 @@ async def get_folders( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - folders = Folders.get_folders_by_user_id(user.id, db=db) + folders = await Folders.get_folders_by_user_id(user.id, db=db) # Verify folder data integrity folder_list = [] for folder in folders: - if folder.parent_id and not Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db): - folder = Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) - - if folder.data: - if 'files' in folder.data: - valid_files = [] - for file in folder.data['files']: - if file.get('type') == 'file': - if Files.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): - valid_files.append(file) - elif file.get('type') == 'collection': - if Knowledges.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): - valid_files.append(file) - else: - valid_files.append(file) - - folder.data['files'] = valid_files - Folders.update_folder_by_id_and_user_id(folder.id, user.id, FolderUpdateForm(data=folder.data), db=db) + if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db): + folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) + + if folder.data and 'files' in folder.data: + accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db) + if len(accessible_files) != len(folder.data.get('files', [])): + folder.data['files'] = accessible_files + await Folders.update_folder_by_id_and_user_id( + folder.id, user.id, FolderUpdateForm(data=folder.data), db=db + ) folder_list.append(FolderNameIdResponse(**folder.model_dump())) @@ -102,12 +93,14 @@ async def get_folders( @router.post('/') -def create_folder( +async def create_folder( form_data: FolderForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - folder = Folders.get_folder_by_parent_id_and_user_id_and_name(form_data.parent_id, user.id, form_data.name, db=db) + folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( + form_data.parent_id, user.id, form_data.name, db=db + ) if folder: raise HTTPException( @@ -116,7 +109,7 @@ def create_folder( ) try: - folder = Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) + folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) return folder except Exception as e: log.exception(e) @@ -133,8 +126,8 @@ def create_folder( @router.get('/{id}', response_model=Optional[FolderModel]) -async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) +async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: return folder else: @@ -154,13 +147,13 @@ async def update_folder_name_by_id( id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: if form_data.name is not None: # Check if folder with same name exists - existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( folder.parent_id, user.id, form_data.name, db=db ) if existing_folder and existing_folder.id != id: @@ -169,8 +162,18 @@ async def update_folder_name_by_id( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) + # Validate read access to every file/collection being attached. + # Folder files are consumed by chat middleware as RAG context. + if form_data.data and isinstance(form_data.data.get('files'), list): + accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db) + if len(accessible_files) != len(form_data.data['files']): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + try: - folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) + folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) return folder except Exception as e: log.exception(e) @@ -200,11 +203,11 @@ async def update_folder_parent_id_by_id( id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: - existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( form_data.parent_id, user.id, folder.name, db=db ) @@ -215,7 +218,7 @@ async def update_folder_parent_id_by_id( ) try: - folder = Folders.update_folder_parent_id_by_id_and_user_id(id, user.id, form_data.parent_id, db=db) + folder = await Folders.update_folder_parent_id_by_id_and_user_id(id, user.id, form_data.parent_id, db=db) return folder except Exception as e: log.exception(e) @@ -245,12 +248,14 @@ async def update_folder_is_expanded_by_id( id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: try: - folder = Folders.update_folder_is_expanded_by_id_and_user_id(id, user.id, form_data.is_expanded, db=db) + folder = await Folders.update_folder_is_expanded_by_id_and_user_id( + id, user.id, form_data.is_expanded, db=db + ) return folder except Exception as e: log.exception(e) @@ -277,10 +282,10 @@ async def delete_folder_by_id( id: str, delete_contents: Optional[bool] = True, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): - chat_delete_permission = has_permission( + if await Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): + chat_delete_permission = await has_permission( user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db ) if user.role != 'admin' and not chat_delete_permission: @@ -290,18 +295,18 @@ async def delete_folder_by_id( ) folders = [] - folders.append(Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) + folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) while folders: folder = folders.pop() if folder: try: - folder_ids = Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db) + folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db) for folder_id in folder_ids: if delete_contents: - Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db) + await Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db) else: - Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db) + await Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db) return True except Exception as e: @@ -313,7 +318,7 @@ async def delete_folder_by_id( ) finally: # Get all subfolders - subfolders = Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db) + subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db) folders.extend(subfolders) else: diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 44f139dc07d..f40cd1ab828 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Optional -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.models.functions import ( FunctionForm, FunctionModel, @@ -26,8 +26,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel, HttpUrl -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -36,17 +36,19 @@ ############################ # GetFunctions +# Our daily functions give us, and forgive us +# our deprecated methods, as we refactor those who depend on us. ############################ @router.get('/', response_model=list[FunctionResponse]) -async def get_functions(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return Functions.get_functions(db=db) +async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return await Functions.get_functions(db=db) @router.get('/list', response_model=list[FunctionUserResponse]) -async def get_function_list(user=Depends(get_admin_user), db: Session = Depends(get_session)): - return Functions.get_function_list(db=db) +async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + return await Functions.get_function_list(db=db) ############################ @@ -58,9 +60,9 @@ async def get_function_list(user=Depends(get_admin_user), db: Session = Depends( async def get_functions( include_valves: bool = False, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - return Functions.get_functions(include_valves=include_valves, db=db) + return await Functions.get_functions(include_valves=include_valves, db=db) ############################ @@ -115,7 +117,9 @@ async def load_function_from_url(request: Request, form_data: LoadUrlForm, user= async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.get(url, headers={'Content-Type': 'application/json'}) as resp: + async with session.get( + url, headers={'Content-Type': 'application/json'}, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: if resp.status != 200: raise HTTPException(status_code=resp.status, detail='Failed to fetch the function') data = await resp.text() @@ -126,7 +130,7 @@ async def load_function_from_url(request: Request, form_data: LoadUrlForm, user= 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f'Error importing function: {e}') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e)) ############################ @@ -143,12 +147,12 @@ async def sync_functions( request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: for function in form_data.functions: function.content = replace_imports(function.content) - function_module, function_type, frontmatter = load_function_module_by_id( + function_module, function_type, frontmatter = await load_function_module_by_id( function.id, content=function.content, ) @@ -161,7 +165,7 @@ async def sync_functions( log.exception(f'Error validating valves for function {function.id}: {e}') raise e - return Functions.sync_functions(user.id, form_data.functions, db=db) + return await Functions.sync_functions(user.id, form_data.functions, db=db) except Exception as e: log.exception(f'Failed to load a function: {e}') raise HTTPException( @@ -180,7 +184,7 @@ async def create_new_function( request: Request, form_data: FunctionForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not form_data.id.isidentifier(): raise HTTPException( @@ -190,11 +194,11 @@ async def create_new_function( form_data.id = form_data.id.lower() - function = Functions.get_function_by_id(form_data.id, db=db) + function = await Functions.get_function_by_id(form_data.id, db=db) if function is None: try: form_data.content = replace_imports(form_data.content) - function_module, function_type, frontmatter = load_function_module_by_id( + function_module, function_type, frontmatter = await load_function_module_by_id( form_data.id, content=form_data.content, ) @@ -203,13 +207,13 @@ async def create_new_function( FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS[form_data.id] = function_module - function = Functions.insert_new_function(user.id, function_type, form_data, db=db) + function = await Functions.insert_new_function(user.id, function_type, form_data, db=db) function_cache_dir = CACHE_DIR / 'functions' / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) if function_type == 'filter' and getattr(function_module, 'toggle', None): - Functions.update_function_metadata_by_id(form_data.id, {'toggle': True}, db=db) + await Functions.update_function_metadata_by_id(form_data.id, {'toggle': True}, db=db) if function: return function @@ -237,8 +241,8 @@ async def create_new_function( @router.get('/id/{id}', response_model=Optional[FunctionModel]) -async def get_function_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - function = Functions.get_function_by_id(id, db=db) +async def get_function_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + function = await Functions.get_function_by_id(id, db=db) if function: return function @@ -255,10 +259,10 @@ async def get_function_by_id(id: str, user=Depends(get_admin_user), db: Session @router.post('/id/{id}/toggle', response_model=Optional[FunctionModel]) -async def toggle_function_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - function = Functions.get_function_by_id(id, db=db) +async def toggle_function_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + function = await Functions.get_function_by_id(id, db=db) if function: - function = Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db) + function = await Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db) if function: return function @@ -280,10 +284,10 @@ async def toggle_function_by_id(id: str, user=Depends(get_admin_user), db: Sessi @router.post('/id/{id}/toggle/global', response_model=Optional[FunctionModel]) -async def toggle_global_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - function = Functions.get_function_by_id(id, db=db) +async def toggle_global_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + function = await Functions.get_function_by_id(id, db=db) if function: - function = Functions.update_function_by_id(id, {'is_global': not function.is_global}, db=db) + function = await Functions.update_function_by_id(id, {'is_global': not function.is_global}, db=db) if function: return function @@ -310,11 +314,11 @@ async def update_function_by_id( id: str, form_data: FunctionForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: form_data.content = replace_imports(form_data.content) - function_module, function_type, frontmatter = load_function_module_by_id(id, content=form_data.content) + function_module, function_type, frontmatter = await load_function_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter FUNCTIONS = request.app.state.FUNCTIONS @@ -323,10 +327,10 @@ async def update_function_by_id( updated = {**form_data.model_dump(exclude={'id'}), 'type': function_type} log.debug(updated) - function = Functions.update_function_by_id(id, updated, db=db) + function = await Functions.update_function_by_id(id, updated, db=db) if function_type == 'filter' and getattr(function_module, 'toggle', None): - Functions.update_function_metadata_by_id(id, {'toggle': True}, db=db) + await Functions.update_function_metadata_by_id(id, {'toggle': True}, db=db) if function: return function @@ -353,9 +357,9 @@ async def delete_function_by_id( request: Request, id: str, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - result = Functions.delete_function_by_id(id, db=db) + result = await Functions.delete_function_by_id(id, db=db) if result: FUNCTIONS = request.app.state.FUNCTIONS @@ -371,11 +375,13 @@ async def delete_function_by_id( @router.get('/id/{id}/valves', response_model=Optional[dict]) -async def get_function_valves_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - function = Functions.get_function_by_id(id, db=db) +async def get_function_valves_by_id( + id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + function = await Functions.get_function_by_id(id, db=db) if function: try: - valves = Functions.get_function_valves_by_id(id, db=db) + valves = await Functions.get_function_valves_by_id(id, db=db) return valves except Exception as e: raise HTTPException( @@ -399,11 +405,11 @@ async def get_function_valves_spec_by_id( request: Request, id: str, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - function = Functions.get_function_by_id(id, db=db) + function = await Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache(request, id) + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'Valves'): Valves = function_module.Valves @@ -430,11 +436,11 @@ async def update_function_valves_by_id( id: str, form_data: dict, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - function = Functions.get_function_by_id(id, db=db) + function = await Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache(request, id) + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'Valves'): Valves = function_module.Valves @@ -444,7 +450,7 @@ async def update_function_valves_by_id( valves = Valves(**form_data) valves_dict = valves.model_dump(exclude_unset=True) - Functions.update_function_valves_by_id(id, valves_dict, db=db) + await Functions.update_function_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: log.exception(f'Error updating function values by id {id}: {e}') @@ -471,11 +477,13 @@ async def update_function_valves_by_id( @router.get('/id/{id}/valves/user', response_model=Optional[dict]) -async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - function = Functions.get_function_by_id(id, db=db) +async def get_function_user_valves_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + function = await Functions.get_function_by_id(id, db=db) if function: try: - user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id, db=db) + user_valves = await Functions.get_user_valves_by_id_and_user_id(id, user.id, db=db) return user_valves except Exception as e: raise HTTPException( @@ -494,11 +502,11 @@ async def get_function_user_valves_spec_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - function = Functions.get_function_by_id(id, db=db) + function = await Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache(request, id) + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'UserValves'): UserValves = function_module.UserValves @@ -520,12 +528,12 @@ async def update_function_user_valves_by_id( id: str, form_data: dict, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - function = Functions.get_function_by_id(id, db=db) + function = await Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache(request, id) + function_module, function_type, frontmatter = await get_function_module_from_cache(request, id) if hasattr(function_module, 'UserValves'): UserValves = function_module.UserValves @@ -534,7 +542,7 @@ async def update_function_user_valves_by_id( form_data = {k: v for k, v in form_data.items() if v is not None} user_valves = UserValves(**form_data) user_valves_dict = user_valves.model_dump(exclude_unset=True) - Functions.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) + await Functions.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) return user_valves_dict except Exception as e: log.exception(f'Error updating function user valves by id {id}: {e}') diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 4e9688c3d8b..c45690fc3ae 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -17,8 +17,8 @@ from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from open_webui.utils.auth import get_admin_user, get_verified_user @@ -35,7 +35,7 @@ async def get_groups( share: Optional[bool] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): filter = {} @@ -45,7 +45,7 @@ async def get_groups( if share is not None: filter['share'] = share - groups = Groups.get_groups(filter=filter, db=db) + groups = await Groups.get_groups(filter=filter, db=db) return groups @@ -59,14 +59,14 @@ async def get_groups( async def create_new_group( form_data: GroupForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - group = Groups.insert_new_group(user.id, form_data, db=db) + group = await Groups.insert_new_group(user.id, form_data, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -87,12 +87,12 @@ async def create_new_group( @router.get('/id/{id}', response_model=Optional[GroupResponse]) -async def get_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - group = Groups.get_group_by_id(id, db=db) +async def get_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + group = await Groups.get_group_by_id(id, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -102,12 +102,12 @@ async def get_group_by_id(id: str, user=Depends(get_admin_user), db: Session = D @router.get('/id/{id}/info', response_model=Optional[GroupInfoResponse]) -async def get_group_info_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - group = Groups.get_group_by_id(id, db=db) +async def get_group_info_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + group = await Groups.get_group_by_id(id, db=db) if group: return GroupInfoResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -127,13 +127,13 @@ class GroupExportResponse(GroupResponse): @router.get('/id/{id}/export', response_model=Optional[GroupExportResponse]) -async def export_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - group = Groups.get_group_by_id(id, db=db) +async def export_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + group = await Groups.get_group_by_id(id, db=db) if group: return GroupExportResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), - user_ids=Groups.get_group_user_ids_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), + user_ids=await Groups.get_group_user_ids_by_id(group.id, db=db), ) else: raise HTTPException( @@ -148,9 +148,9 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user), db: Session @router.post('/id/{id}/users', response_model=list[UserInfoResponse]) -async def get_users_in_group(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def get_users_in_group(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): try: - users = Users.get_users_by_group_id(id, db=db) + users = await Users.get_users_by_group_id(id, db=db) return users except Exception as e: log.exception(f'Error adding users to group {id}: {e}') @@ -170,14 +170,14 @@ async def update_group_by_id( id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - group = Groups.update_group_by_id(id, form_data, db=db) + group = await Groups.update_group_by_id(id, form_data, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -202,17 +202,17 @@ async def add_user_to_group( id: str, form_data: UserIdsForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: if form_data.user_ids: - form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids, db=db) + form_data.user_ids = await Users.get_valid_user_ids(form_data.user_ids, db=db) - group = Groups.add_users_to_group(id, form_data.user_ids, db=db) + group = await Groups.add_users_to_group(id, form_data.user_ids, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -232,14 +232,14 @@ async def remove_users_from_group( id: str, form_data: UserIdsForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - group = Groups.remove_users_from_group(id, form_data.user_ids, db=db) + group = await Groups.remove_users_from_group(id, form_data.user_ids, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id, db=db), + member_count=await Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -260,9 +260,9 @@ async def remove_users_from_group( @router.delete('/id/{id}/delete', response_model=bool) -async def delete_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def delete_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): try: - result = Groups.delete_group_by_id(id, db=db) + result = await Groups.delete_group_by_id(id, db=db) if result: return result else: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 060461f2b75..e55b7c5798e 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -10,7 +10,8 @@ from typing import Optional from urllib.parse import quote -import requests +import aiohttp + from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from fastapi.responses import FileResponse @@ -21,15 +22,16 @@ ) from open_webui.constants import ERROR_MESSAGES from open_webui.retrieval.web.utils import validate_url -from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS, ENABLE_FORWARD_USER_INFO_HEADERS +from open_webui.utils.session_pool import get_session from open_webui.models.chats import Chats from open_webui.routers.files import upload_file_handler, get_file_content_by_id from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission from open_webui.utils.headers import include_user_info_headers -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from open_webui.utils.images.comfyui import ( ComfyUICreateImageForm, ComfyUIEditImageForm, @@ -42,38 +44,44 @@ log = logging.getLogger(__name__) +# An image can lie as easily as it can illuminate. Let what +# is generated here be honest about what it shows. IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations' IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) router = APIRouter() -def set_image_model(request: Request, model: str): +async def set_image_model(request: Request, model: str): log.info(f'Setting image model to {model}') request.app.state.config.IMAGE_GENERATION_MODEL = model if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: api_auth = get_automatic1111_api_auth(request) try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': api_auth}, - ) - options = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + options = await r.json() if model != options['sd_model_checkpoint']: options['sd_model_checkpoint'] = model - r = requests.post( + async with session.post( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', json=options, headers={'authorization': api_auth}, - ) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() except Exception as e: log.debug(f'{e}') return request.app.state.config.IMAGE_GENERATION_MODEL -def get_image_model(request): +async def get_image_model(request): if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': return ( request.app.state.config.IMAGE_GENERATION_MODEL @@ -95,14 +103,15 @@ def get_image_model(request): or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - options = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + options = await r.json() return options['sd_model_checkpoint'] except Exception as e: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -196,7 +205,7 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends( request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE - set_image_model(request, form_data.IMAGE_GENERATION_MODEL) + await set_image_model(request, form_data.IMAGE_GENERATION_MODEL) if form_data.IMAGE_SIZE == 'auto' and not re.match( IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL ): @@ -311,35 +320,37 @@ def get_automatic1111_api_auth(request: Request): async def verify_url(request: Request, user=Depends(get_admin_user)): if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111': try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - r.raise_for_status() - return True + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return True except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': headers = None if request.app.state.config.COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} try: - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, - ) - r.raise_for_status() - return True + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return True except Exception: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) else: return True @router.get('/models') -def get_models(request: Request, user=Depends(get_verified_user)): +async def get_models(request: Request, user=Depends(get_verified_user)): try: if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': return [ @@ -355,11 +366,13 @@ def get_models(request: Request, user=Depends(get_verified_user)): elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': # TODO - get models from comfyui headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, - ) - info = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + info = await r.json() workflow = json.loads(request.app.state.config.COMFYUI_WORKFLOW) model_node_id = None @@ -397,11 +410,13 @@ def get_models(request: Request, user=Depends(get_verified_user)): request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): - r = requests.get( + session = await get_session() + async with session.get( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models', headers={'authorization': get_automatic1111_api_auth(request)}, - ) - models = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + models = await r.json() return list( map( lambda model: {'id': model['title'], 'name': model['model_name']}, @@ -409,7 +424,6 @@ def get_models(request: Request, user=Depends(get_verified_user)): ) ) except Exception as e: - request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -425,21 +439,24 @@ class CreateImageForm(BaseModel): GenerateImageForm = CreateImageForm # Alias for backward compatibility -def get_image_data(data: str, headers=None): +async def get_image_data(data: str, headers=None): try: if data.startswith('http://') or data.startswith('https://'): - if headers: - r = requests.get(data, headers=headers) - else: - r = requests.get(data) - - r.raise_for_status() - if r.headers['content-type'].split('/')[0] == 'image': - mime_type = r.headers['content-type'] - return r.content, mime_type - else: - log.error('Url does not point to an image.') - return None + # Defense-in-depth: gate before fetch (mirrors load_url_image). + validate_url(data) + session = await get_session() + async with session.get( + data, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + content_type = r.headers.get('content-type', '') + if content_type.split('/')[0] == 'image': + return await r.read(), content_type + else: + log.error('Url does not point to an image.') + return None, None else: if ',' in data: header, encoded = data.split(',', 1) @@ -454,7 +471,7 @@ def get_image_data(data: str, headers=None): return None, None -def upload_image(request, image_data, content_type, metadata, user, db=None): +async def upload_image(request, image_data, content_type, metadata, user, db=None): image_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(image_data), @@ -463,7 +480,7 @@ def upload_image(request, image_data, content_type, metadata, user, db=None): 'content-type': content_type, }, ) - file_item = upload_file_handler( + file_item = await upload_file_handler( request, file=file, metadata=metadata, @@ -477,7 +494,7 @@ def upload_image(request, image_data, content_type, metadata, user, db=None): message_id = metadata.get('message_id') if chat_id and message_id: - Chats.insert_chat_files( + await Chats.insert_chat_files( chat_id=chat_id, message_id=message_id, file_ids=[file_item.id], @@ -497,7 +514,7 @@ async def generate_images(request: Request, form_data: CreateImageForm, user=Dep detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( @@ -529,9 +546,8 @@ async def image_generations( metadata = metadata or {} - model = get_image_model(request) + model = await get_image_model(request) - r = None try: if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': headers = { @@ -550,7 +566,11 @@ async def image_generations( 'model': model, 'prompt': form_data.prompt, 'n': form_data.n, - 'size': (form_data.size if form_data.size else request.app.state.config.IMAGE_SIZE), + **( + {'size': form_data.size or request.app.state.config.IMAGE_SIZE} + if (form_data.size or request.app.state.config.IMAGE_SIZE) + else {} + ), **( {} if re.match( @@ -566,29 +586,28 @@ async def image_generations( ), } - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=url, json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['data']: if image_url := image.get('url', None): - image_data, content_type = get_image_data( + image_data, content_type = await get_image_data( image_url, {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image['b64_json']) + image_data, content_type = await get_image_data(image['b64_json']) - _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) return images @@ -617,30 +636,29 @@ async def image_generations( model = f'{model}:generateContent' data = {'contents': [{'parts': [{'text': form_data.prompt}]}]} - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] if model.endswith(':predict'): for image in res['predictions']: - image_data, content_type = get_image_data(image['bytesBase64Encoded']) - _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + image_data, content_type = await get_image_data(image['bytesBase64Encoded']) + _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) elif model.endswith(':generateContent'): for image in res['candidates']: for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): - image_data, content_type = get_image_data(part['inlineData']['data']) - _, url = upload_image( + image_data, content_type = await get_image_data(part['inlineData']['data']) + _, url = await upload_image( request, image_data, content_type, @@ -679,7 +697,7 @@ async def image_generations( res = await comfyui_create_image( model, form_data, - user.id, + str(uuid.uuid4()), request.app.state.config.COMFYUI_BASE_URL, request.app.state.config.COMFYUI_API_KEY, ) @@ -692,8 +710,8 @@ async def image_generations( if request.app.state.config.COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} - image_data, content_type = get_image_data(image['url'], headers) - _, url = upload_image( + image_data, content_type = await get_image_data(image['url'], headers) + _, url = await upload_image( request, image_data, content_type, @@ -707,7 +725,7 @@ async def image_generations( or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): if form_data.model: - set_image_model(request, form_data.model) + await set_image_model(request, form_data.model) data = { 'prompt': form_data.prompt, @@ -725,22 +743,21 @@ async def image_generations( if request.app.state.config.AUTOMATIC1111_PARAMS: data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS} - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img', json=data, headers={'authorization': get_automatic1111_api_auth(request)}, - ) - - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + res = await r.json() log.debug(f'res: {res}') images = [] for image in res['images']: - image_data, content_type = get_image_data(image) - _, url = upload_image( + image_data, content_type = await get_image_data(image) + _, url = await upload_image( request, image_data, content_type, @@ -751,10 +768,8 @@ async def image_generations( return images except Exception as e: error = e - if r != None: - data = r.json() - if 'error' in data: - error = data['error']['message'] + if isinstance(e, aiohttp.ClientResponseError): + error = e.message raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) @@ -794,13 +809,20 @@ async def load_url_image(data): return data if data.startswith('http://') or data.startswith('https://'): - # Validate URL to prevent SSRF attacks against local/private networks + # Validate URL to prevent SSRF attacks against local/private networks. + # allow_redirects=False prevents redirect-based SSRF: validate_url() is + # called only on the originally-submitted URL; following 3xx redirects + # without re-validation would let an attacker reach private IPs via a + # public host that redirects internally (e.g. cloud-metadata exfil). validate_url(data) - r = await asyncio.to_thread(requests.get, data) - r.raise_for_status() + session = await get_session() + async with session.get( + data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as r: + r.raise_for_status() - image_data = base64.b64encode(r.content).decode('utf-8') - return f'data:{r.headers["content-type"]};base64,{image_data}' + image_data = base64.b64encode(await r.read()).decode('utf-8') + return f'data:{r.headers["content-type"]};base64,{image_data}' else: file_id = None @@ -844,7 +866,6 @@ def get_image_file_item(base64_string, param_name='image'): ), ) - r = None try: if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai': headers = { @@ -881,29 +902,42 @@ def get_image_file_item(base64_string, param_name='image'): if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}' - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + # Build multipart form data for aiohttp + form = aiohttp.FormData() + for key, value in data.items(): + if isinstance(value, dict): + form.add_field(key, json.dumps(value)) + else: + form.add_field(key, str(value)) + for param_name, (filename, file_obj, content_type_val) in files: + form.add_field( + param_name, + file_obj, + filename=filename, + content_type=content_type_val, + ) + + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}', headers=headers, - files=files, - data=data, - ) - - r.raise_for_status() - res = r.json() + data=form, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['data']: if image_url := image.get('url', None): - image_data, content_type = get_image_data( + image_data, content_type = await get_image_data( image_url, {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image['b64_json']) + image_data, content_type = await get_image_data(image['b64_json']) - _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + _, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user) images.append({'url': url}) return images @@ -938,23 +972,22 @@ def get_image_file_item(base64_string, param_name='image'): ] ) - # Use asyncio.to_thread for the requests.post call - r = await asyncio.to_thread( - requests.post, + session = await get_session() + async with session.post( url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, - ) - - r.raise_for_status() - res = r.json() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + res = await r.json() images = [] for image in res['candidates']: for part in image['content']['parts']: if part.get('inlineData', {}).get('data'): - image_data, content_type = get_image_data(part['inlineData']['data']) - _, url = upload_image( + image_data, content_type = await get_image_data(part['inlineData']['data']) + _, url = await upload_image( request, image_data, content_type, @@ -1009,7 +1042,7 @@ def get_image_file_item(base64_string, param_name='image'): res = await comfyui_edit_image( model, form_data, - user.id, + str(uuid.uuid4()), request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, ) @@ -1033,8 +1066,8 @@ def get_image_file_item(base64_string, param_name='image'): if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY: headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'} - image_data, content_type = get_image_data(image_url, headers) - _, url = upload_image( + image_data, content_type = await get_image_data(image_url, headers) + _, url = await upload_image( request, image_data, content_type, @@ -1046,13 +1079,7 @@ def get_image_file_item(base64_string, param_name='image'): return images except Exception as e: error = e - if r != None: - data = r.text - try: - data = json.loads(data) - if 'error' in data: - error = data['error']['message'] - except Exception: - error = data + if isinstance(e, aiohttp.ClientResponseError): + error = e.message raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 199ea110e7d..8ff987b6103 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -2,13 +2,14 @@ from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from fastapi.responses import StreamingResponse -from fastapi.concurrency import run_in_threadpool + import logging import io import zipfile +from urllib.parse import quote -from sqlalchemy.orm import Session -from open_webui.internal.db import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import get_async_session from open_webui.models.groups import Groups from open_webui.models.knowledge import ( KnowledgeFileListResponse, @@ -18,7 +19,7 @@ KnowledgeUserResponse, ) from open_webui.models.files import Files, FileModel, FileMetadataResponse -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.routers.retrieval import ( process_file, ProcessFileForm, @@ -30,6 +31,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_verified_user, get_admin_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.utils.access_control.files import has_access_to_file from open_webui.models.access_grants import AccessGrants @@ -50,6 +52,8 @@ # Knowledge Base Embedding ############################ +# Knowledge that sits unread serves no one. Let what is +# stored here find the ones who need it. KNOWLEDGE_BASES_COLLECTION = 'knowledge-bases' @@ -63,7 +67,7 @@ async def embed_knowledge_base_metadata( try: content = f'{name}\n\n{description}' if description else name embedding = await request.app.state.EMBEDDING_FUNCTION(content) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=KNOWLEDGE_BASES_COLLECTION, items=[ { @@ -82,10 +86,10 @@ async def embed_knowledge_base_metadata( return False -def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: +async def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: """Remove knowledge base embedding.""" try: - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=KNOWLEDGE_BASES_COLLECTION, ids=[knowledge_base_id], ) @@ -108,14 +112,14 @@ class KnowledgeAccessListResponse(BaseModel): async def get_knowledge_bases( page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): page = max(page, 1) limit = PAGE_ITEM_COUNT skip = (page - 1) * limit filter = {} - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: @@ -124,11 +128,11 @@ async def get_knowledge_bases( filter['user_id'] = user.id - result = Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) + result = await Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] - writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + writable_knowledge_base_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='knowledge', resource_ids=knowledge_base_ids, @@ -159,7 +163,7 @@ async def search_knowledge_bases( view_option: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): page = max(page, 1) limit = PAGE_ITEM_COUNT @@ -171,7 +175,7 @@ async def search_knowledge_bases( if view_option: filter['view_option'] = view_option - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: @@ -180,11 +184,11 @@ async def search_knowledge_bases( filter['user_id'] = user.id - result = Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) + result = await Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] - writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + writable_knowledge_base_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='knowledge', resource_ids=knowledge_base_ids, @@ -214,7 +218,7 @@ async def search_knowledge_files( query: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): page = max(page, 1) limit = PAGE_ITEM_COUNT @@ -224,13 +228,13 @@ async def search_knowledge_files( if query: filter['query'] = query - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) if groups: filter['group_ids'] = [group.id for group in groups] filter['user_id'] = user.id - return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit, db=db) + return await Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -244,11 +248,11 @@ async def create_new_knowledge( form_data: KnowledgeForm, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions. # This prevents holding a connection during embed_knowledge_base_metadata() # which makes external embedding API calls (1-5+ seconds). - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.knowledge', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( @@ -256,7 +260,7 @@ async def create_new_knowledge( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -264,7 +268,7 @@ async def create_new_knowledge( 'sharing.public_knowledge', ) - knowledge = Knowledges.insert_new_knowledge(user.id, form_data) + knowledge = await Knowledges.insert_new_knowledge(user.id, form_data) if knowledge: # Embed knowledge base for semantic search @@ -291,7 +295,7 @@ async def create_new_knowledge( async def reindex_knowledge_files( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin': raise HTTPException( @@ -299,16 +303,16 @@ async def reindex_knowledge_files( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - knowledge_bases = Knowledges.get_knowledge_bases(db=db) + knowledge_bases = await Knowledges.get_knowledge_bases(db=db) log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases') for knowledge_base in knowledge_bases: try: - files = Knowledges.get_files_by_id(knowledge_base.id, db=db) + files = await Knowledges.get_files_by_id(knowledge_base.id, db=db) try: - if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): - VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) except Exception as e: log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}') continue # Skip, don't raise @@ -316,8 +320,7 @@ async def reindex_knowledge_files( failed_files = [] for file in files: try: - await run_in_threadpool( - process_file, + await process_file( request, ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id), user=user, @@ -354,12 +357,12 @@ async def reindex_knowledge_base_metadata_embeddings( ): """Batch embed all existing knowledge bases. Admin only. - NOTE: We intentionally do NOT use Depends(get_session) here. + NOTE: We intentionally do NOT use Depends(get_async_session) here. This endpoint loops through ALL knowledge bases and calls embed_knowledge_base_metadata() for each one, making N external embedding API calls. Holding a session during this entire operation would exhaust the connection pool. """ - knowledge_bases = Knowledges.get_knowledge_bases() + knowledge_bases = await Knowledges.get_knowledge_bases() log.info(f'Reindexing embeddings for {len(knowledge_bases)} knowledge bases') success_count = 0 @@ -382,14 +385,14 @@ class KnowledgeFilesResponse(KnowledgeResponse): @router.get('/{id}', response_model=Optional[KnowledgeFilesResponse]) -async def get_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) +async def get_knowledge_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if knowledge: if ( user.role == 'admin' or knowledge.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -402,7 +405,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Sess write_access=( user.id == knowledge.user_id or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -435,11 +438,11 @@ async def update_knowledge_by_id( form_data: KnowledgeForm, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations manage their own short-lived sessions internally. # This prevents holding a connection during embed_knowledge_base_metadata() # which makes external embedding API calls (1-5+ seconds). - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = await Knowledges.get_knowledge_by_id(id=id) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -448,7 +451,7 @@ async def update_knowledge_by_id( # Is the user the original creator, in a group with write access, or an admin if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -461,7 +464,7 @@ async def update_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -469,7 +472,7 @@ async def update_knowledge_by_id( 'sharing.public_knowledge', ) - knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) + knowledge = await Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: # Re-embed knowledge base for semantic search await embed_knowledge_base_metadata( @@ -480,7 +483,7 @@ async def update_knowledge_by_id( ) return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id), ) else: raise HTTPException( @@ -504,9 +507,9 @@ async def update_knowledge_access_by_id( id: str, form_data: KnowledgeAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -515,7 +518,7 @@ async def update_knowledge_access_by_id( if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -529,7 +532,7 @@ async def update_knowledge_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -537,11 +540,11 @@ async def update_knowledge_access_by_id( 'sharing.public_knowledge', ) - AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) + knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) return KnowledgeFilesResponse( - **Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(), - files=Knowledges.get_file_metadatas_by_id(id, db=db), + **knowledge.model_dump(), + files=await Knowledges.get_file_metadatas_by_id(id, db=db), ) @@ -559,9 +562,9 @@ async def get_knowledge_files_by_id( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -571,7 +574,7 @@ async def get_knowledge_files_by_id( if not ( user.role == 'admin' or knowledge.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -599,7 +602,7 @@ async def get_knowledge_files_by_id( if direction: filter['direction'] = direction - return Knowledges.search_files_by_id(id, user.id, filter=filter, skip=skip, limit=limit, db=db) + return await Knowledges.search_files_by_id(id, user.id, filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -612,14 +615,14 @@ class KnowledgeFileIdForm(BaseModel): @router.post('/{id}/file/add', response_model=Optional[KnowledgeFilesResponse]) -def add_file_to_knowledge_by_id( +async def add_file_to_knowledge_by_id( request: Request, id: str, form_data: KnowledgeFileIdForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -628,7 +631,7 @@ def add_file_to_knowledge_by_id( if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -642,7 +645,7 @@ def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id, db=db) + file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -654,9 +657,17 @@ def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.FILE_NOT_PROCESSED, ) + # KB write-access alone is not enough — caller must also be able to read the file. + if file.user_id != user.id and user.role != 'admin': + if not await has_access_to_file(file.id, 'read', user, db=db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Add content to the vector database try: - process_file( + await process_file( request, ProcessFileForm(file_id=form_data.file_id, collection_name=id), user=user, @@ -664,7 +675,7 @@ def add_file_to_knowledge_by_id( ) # Add file to knowledge base - Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db) + await Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db) except Exception as e: log.debug(e) raise HTTPException( @@ -675,7 +686,7 @@ def add_file_to_knowledge_by_id( if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -685,14 +696,14 @@ def add_file_to_knowledge_by_id( @router.post('/{id}/file/update', response_model=Optional[KnowledgeFilesResponse]) -def update_file_from_knowledge_by_id( +async def update_file_from_knowledge_by_id( request: Request, id: str, form_data: KnowledgeFileIdForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -701,7 +712,7 @@ def update_file_from_knowledge_by_id( if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -715,7 +726,7 @@ def update_file_from_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id, db=db) + file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -723,18 +734,18 @@ def update_file_from_knowledge_by_id( ) # Validate the file actually belongs to this knowledge base - if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): + if not await Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) # Remove content from the vector database - VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id}) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id}) # Add content to the vector database try: - process_file( + await process_file( request, ProcessFileForm(file_id=form_data.file_id, collection_name=id), user=user, @@ -749,7 +760,7 @@ def update_file_from_knowledge_by_id( if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -764,14 +775,14 @@ def update_file_from_knowledge_by_id( @router.post('/{id}/file/remove', response_model=Optional[KnowledgeFilesResponse]) -def remove_file_from_knowledge_by_id( +async def remove_file_from_knowledge_by_id( id: str, form_data: KnowledgeFileIdForm, delete_file: bool = Query(True), user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -780,7 +791,7 @@ def remove_file_from_knowledge_by_id( if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -794,7 +805,7 @@ def remove_file_from_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id, db=db) + file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -802,21 +813,21 @@ def remove_file_from_knowledge_by_id( ) # Validate the file actually belongs to this knowledge base - if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): + if not await Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.NOT_FOUND, ) - Knowledges.remove_file_from_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, db=db) + await Knowledges.remove_file_from_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, db=db) # Remove content from the vector database try: - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={'file_id': form_data.file_id} ) # Remove by file_id first - VECTOR_DB_CLIENT.delete( + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={'hash': file.hash} ) # Remove by hash as well in case of duplicates except Exception as e: @@ -824,24 +835,28 @@ def remove_file_from_knowledge_by_id( log.debug(e) pass - if delete_file: + # Only the file owner or an admin may permanently delete the underlying + # file. Collaborators with KB write access can unlink a file from the + # knowledge base but must not be able to destroy files they do not own, + # as the same file may be referenced by other KBs and chats. + if delete_file and (file.user_id == user.id or user.role == 'admin'): try: # Remove the file's collection from vector database file_collection = f'file-{form_data.file_id}' - if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) except Exception as e: log.debug('This was most likely caused by bypassing embedding processing') log.debug(e) pass # Delete file from database - Files.delete_file_by_id(form_data.file_id, db=db) + await Files.delete_file_by_id(form_data.file_id, db=db) if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -856,8 +871,10 @@ def remove_file_from_knowledge_by_id( @router.delete('/{id}/delete', response_model=bool) -async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) +async def delete_knowledge_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -866,7 +883,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: S if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -883,7 +900,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: S log.info(f'Deleting knowledge base: {id} (name: {knowledge.name})') # Get all models - models = Models.get_all_models(db=db) + models = await Models.get_all_models(db=db) log.info(f'Found {len(models)} models to check for knowledge base {id}') # Update models that reference this knowledge base @@ -897,29 +914,20 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: S if len(updated_knowledge) != len(knowledge_list): log.info(f'Updating model {model.id} to remove knowledge base {id}') model.meta.knowledge = updated_knowledge - # Create a ModelForm for the update - model_form = ModelForm( - id=model.id, - name=model.name, - base_model_id=model.base_model_id, - meta=model.meta, - params=model.params, - access_grants=model.access_grants, - is_active=model.is_active, - ) - Models.update_model_by_id(model.id, model_form, db=db) + model_form = ModelForm(**model.model_dump()) + await Models.update_model_by_id(model.id, model_form, db=db) # Clean up vector DB try: - VECTOR_DB_CLIENT.delete_collection(collection_name=id) + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) except Exception as e: log.debug(e) pass # Remove knowledge base embedding - remove_knowledge_base_metadata_embedding(id) + await remove_knowledge_base_metadata_embedding(id) - result = Knowledges.delete_knowledge_by_id(id=id, db=db) + result = await Knowledges.delete_knowledge_by_id(id=id, db=db) return result @@ -929,8 +937,10 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: S @router.post('/{id}/reset', response_model=Optional[KnowledgeResponse]) -async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) +async def reset_knowledge_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -939,7 +949,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Se if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -954,12 +964,12 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Se ) try: - VECTOR_DB_CLIENT.delete_collection(collection_name=id) + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=id) except Exception as e: log.debug(e) pass - knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.reset_knowledge_by_id(id=id, db=db) return knowledge @@ -974,12 +984,12 @@ async def add_files_to_knowledge_batch( id: str, form_data: list[KnowledgeFileIdForm], user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """ Add multiple files to a knowledge base """ - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -988,7 +998,7 @@ async def add_files_to_knowledge_batch( if ( knowledge.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge.id, @@ -1005,7 +1015,7 @@ async def add_files_to_knowledge_batch( # Batch-fetch all files to avoid N+1 queries log.info(f'files/batch/add - {len(form_data)} files') file_ids = [form.file_id for form in form_data] - files = Files.get_files_by_ids(file_ids, db=db) + files = await Files.get_files_by_ids(file_ids, db=db) # Verify all requested files were found found_ids = {file.id for file in files} @@ -1016,6 +1026,15 @@ async def add_files_to_knowledge_batch( detail=f'File {missing_ids[0]} not found', ) + # Per-file read-access check — same gate as the single-file endpoint. + if user.role != 'admin': + for file in files: + if file.user_id != user.id and not await has_access_to_file(file.id, 'read', user, db=db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Process files try: result = await process_files_batch( @@ -1031,14 +1050,14 @@ async def add_files_to_knowledge_batch( # Only add files that were successfully processed successful_file_ids = [r.file_id for r in result.results if r.status == 'completed'] for file_id in successful_file_ids: - Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=file_id, user_id=user.id, db=db) + await Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=file_id, user_id=user.id, db=db) # If there were any errors, include them in the response if result.errors: error_details = [f'{err.file_id}: {err.error}' for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), warnings={ 'message': 'Some files failed to process', 'errors': error_details, @@ -1047,7 +1066,7 @@ async def add_files_to_knowledge_batch( return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) @@ -1057,20 +1076,20 @@ async def add_files_to_knowledge_batch( @router.get('/{id}/export') -async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): """ Export a knowledge base as a zip file containing .txt files. Admin only. """ - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - files = Knowledges.get_files_by_id(id, db=db) + files = await Knowledges.get_files_by_id(id, db=db) # Create zip file in memory zip_buffer = io.BytesIO() @@ -1087,11 +1106,16 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Sess zip_buffer.seek(0) # Sanitize knowledge name for filename - safe_name = ''.join(c if c.isalnum() or c in ' -_' else '_' for c in knowledge.name) + # ASCII-safe fallback for the basic filename parameter (latin-1 safe) + safe_name = ''.join(c if c.isascii() and (c.isalnum() or c in ' -_') else '_' for c in knowledge.name) zip_filename = f'{safe_name}.zip' + # Use RFC 5987 filename* for non-ASCII names so the browser gets the real name + quoted_name = quote(f'{knowledge.name}.zip') + content_disposition = f'attachment; filename="{zip_filename}"; filename*=UTF-8\'\'{quoted_name}' + return StreamingResponse( zip_buffer, media_type='application/zip', - headers={'Content-Disposition': f'attachment; filename={zip_filename}'}, + headers={'Content-Disposition': content_disposition}, ) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 82af3a580c8..6522118258a 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -5,10 +5,10 @@ from typing import Optional from open_webui.models.memories import Memories, MemoryModel -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.utils.auth import get_verified_user -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from open_webui.utils.access_control import has_permission from open_webui.constants import ERROR_MESSAGES @@ -20,6 +20,8 @@ ############################ # GetMemories +# Let what is remembered here spare someone the cost +# of learning it twice. ############################ @@ -27,7 +29,7 @@ async def get_memories( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not request.app.state.config.ENABLE_MEMORIES: raise HTTPException( @@ -35,13 +37,13 @@ async def get_memories( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - return Memories.get_memories_by_user_id(user.id, db=db) + return await Memories.get_memories_by_user_id(user.id, db=db) ############################ @@ -63,7 +65,7 @@ async def add_memory( form_data: AddMemoryForm, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (insert_new_memory) manage their own short-lived sessions. # This prevents holding a connection during EMBEDDING_FUNCTION() # which makes external embedding API calls (1-5+ seconds). @@ -73,17 +75,17 @@ async def add_memory( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - memory = Memories.insert_new_memory(user.id, form_data.content) + memory = await Memories.insert_new_memory(user.id, form_data.content) vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -114,7 +116,7 @@ async def query_memory( form_data: QueryMemoryForm, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_memories_by_user_id) manage their own short-lived sessions. # This prevents holding a connection during EMBEDDING_FUNCTION() # which makes external embedding API calls (1-5+ seconds). @@ -124,24 +126,56 @@ async def query_memory( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - memories = Memories.get_memories_by_user_id(user.id) + memories = await Memories.get_memories_by_user_id(user.id) if not memories: raise HTTPException(status_code=404, detail='No memories found for user') vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user) - results = VECTOR_DB_CLIENT.search( + results = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=f'user-memory-{user.id}', vectors=[vector], limit=form_data.k, ) + # Filter results by relevance threshold to avoid returning unrelated + # memories. Vector similarity search always returns the top-K nearest + # neighbours even when they are completely irrelevant; applying the + # same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching + # memories are surfaced (distances are normalised to 0→1, higher is + # better). + relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0) + if results and relevance_threshold > 0.0 and results.distances and results.distances[0]: + from open_webui.retrieval.vector.main import SearchResult + + filtered_ids = [] + filtered_docs = [] + filtered_metas = [] + filtered_dists = [] + + for idx, score in enumerate(results.distances[0]): + if score >= relevance_threshold: + if results.ids and results.ids[0]: + filtered_ids.append(results.ids[0][idx]) + if results.documents and results.documents[0]: + filtered_docs.append(results.documents[0][idx]) + if results.metadatas and results.metadatas[0]: + filtered_metas.append(results.metadatas[0][idx]) + filtered_dists.append(score) + + results = SearchResult( + ids=[filtered_ids] if filtered_ids else [[]], + documents=[filtered_docs] if filtered_docs else [[]], + metadatas=[filtered_metas] if filtered_metas else [[]], + distances=[filtered_dists] if filtered_dists else [[]], + ) + return results @@ -155,7 +189,7 @@ async def reset_memory_from_vector_db( ): """Reset user's memory vector embeddings. - CRITICAL: We intentionally do NOT use Depends(get_session) here. + CRITICAL: We intentionally do NOT use Depends(get_async_session) here. This endpoint generates embeddings for ALL user memories in parallel using asyncio.gather(). A user with 100 memories would trigger 100 embedding API calls simultaneously. With a session held, this could block a connection @@ -167,22 +201,22 @@ async def reset_memory_from_vector_db( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') - memories = Memories.get_memories_by_user_id(user.id) + memories = await Memories.get_memories_by_user_id(user.id) # Generate vectors in parallel vectors = await asyncio.gather( *[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories] ) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -210,7 +244,7 @@ async def reset_memory_from_vector_db( async def delete_memory_by_user_id( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not request.app.state.config.ENABLE_MEMORIES: raise HTTPException( @@ -218,17 +252,17 @@ async def delete_memory_by_user_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Memories.delete_memories_by_user_id(user.id, db=db) + result = await Memories.delete_memories_by_user_id(user.id, db=db) if result: try: - VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') except Exception as e: log.error(e) return True @@ -248,7 +282,7 @@ async def update_memory_by_id( form_data: MemoryUpdateModel, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (update_memory_by_id_and_user_id) manage their own # short-lived sessions. This prevents holding a connection during # EMBEDDING_FUNCTION() which makes external API calls (1-5+ seconds). @@ -258,20 +292,20 @@ async def update_memory_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - memory = Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content) + memory = await Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content) if memory is None: - raise HTTPException(status_code=404, detail='Memory not found') + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) if form_data.content is not None: vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) - VECTOR_DB_CLIENT.upsert( + await ASYNC_VECTOR_DB_CLIENT.upsert( collection_name=f'user-memory-{user.id}', items=[ { @@ -299,7 +333,7 @@ async def delete_memory_by_id( memory_id: str, request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not request.app.state.config.ENABLE_MEMORIES: raise HTTPException( @@ -307,16 +341,16 @@ async def delete_memory_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): + if not await has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) + result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) if result: - VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return True return False diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 9dc602dc0e0..a4ede54618d 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -1,9 +1,12 @@ from typing import Optional import io +import os import base64 import json import asyncio import logging +import posixpath +from urllib.parse import unquote from open_webui.models.groups import Groups from open_webui.models.models import ( @@ -26,22 +29,118 @@ Depends, HTTPException, Request, - status, Response, + status, ) -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) router = APIRouter() +# Provider icon configuration is loaded from providers.json for easier maintenance +# See static/static/providers/providers.json for the configuration +_PROVIDER_CONFIG = None +_PROVIDER_CONFIG_PATH = f"{STATIC_DIR}/providers/providers.json" + + +def _load_provider_config() -> dict: + """Load provider icon configuration from JSON file.""" + global _PROVIDER_CONFIG + if _PROVIDER_CONFIG is None: + try: + with open(_PROVIDER_CONFIG_PATH, "r") as f: + _PROVIDER_CONFIG = json.load(f) + except Exception as e: + log.warning(f"Failed to load provider config: {e}") + _PROVIDER_CONFIG = { + "providers": {}, + "region_prefixes": [], + "model_prefix_aliases": {}, + } + return _PROVIDER_CONFIG + + +def get_provider_from_model_id(model_id: str) -> Optional[str]: + """ + Extract the provider name from a model ID. + + Examples: + 'anthropic.claude-3-sonnet' → 'anthropic' + 'us.anthropic.claude-3-sonnet' → 'anthropic' + 'gpt-4o' → 'openai' (via alias) + """ + config = _load_provider_config() + model_id_lower = model_id.lower() + + # Check for special aliases first (e.g., 'gpt-' → 'openai') + for alias, provider in config.get("model_prefix_aliases", {}).items(): + if model_id_lower.startswith(alias.lower()): + return provider + + # Strip known region prefixes (us., eu., global., apac.) + for prefix in config.get("region_prefixes", []): + if model_id_lower.startswith(prefix.lower()): + model_id_lower = model_id_lower[len(prefix):] + break + + # Extract provider from first segment (before the first dot) + if "." in model_id_lower: + return model_id_lower.split(".")[0] + + return None + + +def get_provider_icon_path(model_id: str) -> Optional[str]: + """ + Returns the path to a provider-specific icon based on model ID. + Returns None if no matching provider icon is found. + """ + config = _load_provider_config() + provider = get_provider_from_model_id(model_id) + + if provider and provider in config.get("providers", {}): + icon_filename = config["providers"][provider] + return f"{STATIC_DIR}/providers/{icon_filename}" + + return None + + +def _safe_static_redirect_path(url: str) -> Optional[str]: + """ + If url is a same-origin static asset path, return a normalized path safe for + RedirectResponse Location. Otherwise None (caller should fall back to default). + Rejects traversal (..), encoded dots, query/fragment, and non-/static targets. + """ + if not url or not isinstance(url, str): + return None + path = url.split('?', 1)[0].split('#', 1)[0].strip() + for _ in range(2): + decoded = unquote(path) + if decoded == path: + break + path = decoded + if '\x00' in path or '\\' in path: + return None + if not path.startswith('/'): + return None + normalized = posixpath.normpath(path) + if normalized in ('.', '/'): + return None + if not (normalized == '/static' or normalized.startswith('/static/')): + return None + if normalized == '/static': + return '/static/' + return normalized + def is_valid_model_id(model_id: str) -> bool: return model_id and len(model_id) <= 256 @@ -49,6 +148,8 @@ def is_valid_model_id(model_id: str) -> bool: ########################### # GetModels +# Let each model here be judged by what it does and not +# by what it claims. The house deserves honest servants. ########################### @@ -64,7 +165,7 @@ async def get_models( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -84,7 +185,7 @@ async def get_models( filter['direction'] = direction # Pre-fetch user group IDs once - used for both filter and write_access check - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: @@ -93,11 +194,11 @@ async def get_models( filter['user_id'] = user.id - result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) + result = await Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable model IDs in a single query instead of N has_access calls model_ids = [model.id for model in result.items] - writable_model_ids = AccessGrants.get_accessible_resource_ids( + writable_model_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='model', resource_ids=model_ids, @@ -106,18 +207,25 @@ async def get_models( db=db, ) - return ModelAccessListResponse( - items=[ + # Strip profile_image_url from meta — images are served via /model/profile/image. + items = [] + for model in result.items: + data = model.model_dump() + if data.get('meta'): + data['meta'].pop('profile_image_url', None) + items.append( ModelAccessResponse( - **model.model_dump(), + **data, write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model.user_id or model.id in writable_model_ids ), ) - for model in result.items - ], + ) + + return ModelAccessListResponse( + items=items, total=result.total, ) @@ -128,8 +236,8 @@ async def get_models( @router.get('/base', response_model=list[ModelResponse]) -async def get_base_models(user=Depends(get_admin_user), db: Session = Depends(get_session)): - return Models.get_base_models(db=db) +async def get_base_models(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + return await Models.get_base_models(db=db) ########################### @@ -138,22 +246,13 @@ async def get_base_models(user=Depends(get_admin_user), db: Session = Depends(ge @router.get('/tags', response_model=list[str]) -async def get_model_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): - if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - models = Models.get_models(db=db) - else: - models = Models.get_models_by_user_id(user.id, db=db) - - tags_set = set() - for model in models: - if model.meta: - meta = model.meta.model_dump() - for tag in meta.get('tags', []): - tags_set.add((tag.get('name'))) - - tags = [tag for tag in tags_set] - tags.sort() - return tags +async def get_model_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + tags = await Models.get_all_tags( + user_id=user.id, + is_admin=(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL), + db=db, + ) + return sorted(tags) ############################ @@ -166,9 +265,9 @@ async def create_new_model( request: Request, form_data: ModelForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -176,7 +275,7 @@ async def create_new_model( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - model = Models.get_model_by_id(form_data.id, db=db) + model = await Models.get_model_by_id(form_data.id, db=db) if model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -190,7 +289,15 @@ async def create_new_model( ) else: - model = Models.insert_new_model(form_data, user.id, db=db) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_models', + ) + + model = await Models.insert_new_model(form_data, user.id, db=db) if model: return model else: @@ -209,9 +316,9 @@ async def create_new_model( async def export_models( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.models_export', request.app.state.config.USER_PERMISSIONS, @@ -223,9 +330,9 @@ async def export_models( ) if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return Models.get_models(db=db) + return await Models.get_models(db=db) else: - return Models.get_models_by_user_id(user.id, db=db) + return await Models.get_models_by_user_id(user.id, db=db) ############################ @@ -242,9 +349,9 @@ async def import_models( request: Request, user=Depends(get_verified_user), form_data: ModelsImportForm = (...), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.models_import', request.app.state.config.USER_PERMISSIONS, @@ -264,28 +371,75 @@ async def import_models( if model_data.get('id') and is_valid_model_id(model_data.get('id')) ] existing_models = { - model.id: model for model in (Models.get_models_by_ids(model_ids, db=db) if model_ids else []) + model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else []) } + # Batch-resolve write permissions in one query instead of + # per-model has_access calls (N+1 avoidance). + existing_model_ids = list(existing_models.keys()) + if user.role != 'admin' and existing_model_ids: + groups = await Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + writable_model_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='model', + resource_ids=existing_model_ids, + permission='write', + user_group_ids=user_group_ids, + db=db, + ) + else: + writable_model_ids = set(existing_model_ids) + for model_data in data: - # Here, you can add logic to validate model_data if needed model_id = model_data.get('id') if model_id and is_valid_model_id(model_id): existing_model = existing_models.get(model_id) if existing_model: + # Enforce ownership/write-access before allowing overwrite + if ( + user.role != 'admin' + and existing_model.user_id != user.id + and model_id not in writable_model_ids + ): + log.warning( + 'import_models: user %s skipped model %s (no write access)', + user.id, + model_id, + ) + continue + # Update existing model model_data['meta'] = model_data.get('meta', {}) model_data['params'] = model_data.get('params', {}) updated_model = ModelForm(**{**existing_model.model_dump(), **model_data}) - Models.update_model_by_id(model_id, updated_model, db=db) + # Only filter access_grants when explicitly provided + # in the payload to avoid altering existing ACLs on + # metadata-only imports. + if 'access_grants' in model_data: + updated_model.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + updated_model.access_grants, + 'sharing.public_models', + ) + await Models.update_model_by_id(model_id, updated_model, db=db) else: # Insert new model model_data['meta'] = model_data.get('meta', {}) model_data['params'] = model_data.get('params', {}) new_model = ModelForm(**model_data) - Models.insert_new_model(user_id=user.id, form_data=new_model, db=db) + new_model.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + new_model.access_grants, + 'sharing.public_models', + ) + await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db) return True else: raise HTTPException(status_code=400, detail='Invalid JSON format') @@ -308,9 +462,9 @@ async def sync_models( request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - return Models.sync_models(user.id, form_data.models, db=db) + return await Models.sync_models(user.id, form_data.models, db=db) ########################### @@ -324,33 +478,40 @@ class ModelIdForm(BaseModel): # Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id @router.get('/model', response_model=Optional[ModelAccessResponse]) -async def get_model_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - model = Models.get_model_by_id(id, db=db) +async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + model = await Models.get_model_by_id(id, db=db) if model: - if ( + write_access = ( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or model.user_id == user.id - or AccessGrants.has_access( + or user.id == model.user_id + or await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, - permission='read', + permission='write', db=db, ) + ) + + if write_access or await AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=model.id, + permission='read', + db=db, ): + model_dict = model.model_dump() + # Strip params (system prompt and other admin-curated config) + # for read-only callers — matches the params strip already + # enforced on /api/models in utils/models.py. Owners, admins + # under BYPASS_ADMIN_ACCESS_CONTROL, and write-grant holders + # still receive the full object so the workspace edit UI keeps + # working for users who legitimately curate the model. + if not write_access: + model_dict['params'] = {} return ModelAccessResponse( - **model.model_dump(), - write_access=( - (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == model.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model.id, - permission='write', - db=db, - ) - ), + **model_dict, + write_access=write_access, ) else: raise HTTPException( @@ -370,40 +531,77 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: Session @router.get('/model/profile/image') -def get_model_profile_image(id: str, user=Depends(get_verified_user)): - model = Models.get_model_by_id(id) - - if model: - etag = f'"{model.updated_at}"' if model.updated_at else None - - if model.meta.profile_image_url: - if model.meta.profile_image_url.startswith('http'): +async def get_model_profile_image( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + profile_image_url = None + updated_at = None + + # First, check the database for regular models + model_meta = await Models.get_model_meta_by_id(id, db=db) + if model_meta: + meta, updated_at = model_meta + profile_image_url = (meta or {}).get('profile_image_url') + + # Fallback: check arena models stored in config (not in the DB) + if not profile_image_url: + arena_models = getattr( + getattr(request.app.state, 'config', None), + 'EVALUATION_ARENA_MODELS', + [], + ) + for arena_model in arena_models: + if arena_model.get('id') == id: + profile_image_url = arena_model.get('meta', {}).get('profile_image_url') + break + + if profile_image_url: + if profile_image_url.startswith('http'): + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: return Response( status_code=status.HTTP_302_FOUND, - headers={'Location': model.meta.profile_image_url}, + headers={'Location': profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. + elif profile_image_url.startswith('data:image'): + try: + header, base64_data = profile_image_url.split(',', 1) + image_data = base64.b64decode(base64_data) + image_buffer = io.BytesIO(image_data) + media_type = header.split(';')[0].lstrip('data:') + + headers = {'Content-Disposition': 'inline'} + if updated_at: + headers['ETag'] = f'"{updated_at}"' + + return StreamingResponse( + image_buffer, + media_type=media_type, + headers=headers, + ) + except Exception: + pass + else: + safe_static = _safe_static_redirect_path(profile_image_url) + if safe_static: + return RedirectResponse( + url=safe_static, + status_code=status.HTTP_302_FOUND, ) - elif model.meta.profile_image_url.startswith('data:image'): - try: - header, base64_data = model.meta.profile_image_url.split(',', 1) - image_data = base64.b64decode(base64_data) - image_buffer = io.BytesIO(image_data) - media_type = header.split(';')[0].lstrip('data:') - - headers = {'Content-Disposition': 'inline'} - if etag: - headers['ETag'] = etag - - return StreamingResponse( - image_buffer, - media_type=media_type, - headers=headers, - ) - except Exception as e: - pass - - return FileResponse(f'{STATIC_DIR}/favicon.png') - else: - return FileResponse(f'{STATIC_DIR}/favicon.png') + + provider_icon_path = get_provider_icon_path(id) + if provider_icon_path and os.path.exists(provider_icon_path): + return FileResponse(provider_icon_path, media_type="image/png") + + return RedirectResponse( + url='/static/favicon.png', + status_code=status.HTTP_302_FOUND, + ) ############################ @@ -412,13 +610,13 @@ def get_model_profile_image(id: str, user=Depends(get_verified_user)): @router.post('/model/toggle', response_model=Optional[ModelResponse]) -async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - model = Models.get_model_by_id(id, db=db) +async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + model = await Models.get_model_by_id(id, db=db) if model: if ( user.role == 'admin' or model.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, @@ -426,7 +624,7 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Sessi db=db, ) ): - model = Models.toggle_model_by_id(id, db=db) + model = await Models.toggle_model_by_id(id, db=db) if model: return model @@ -454,11 +652,12 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Sessi @router.post('/model/update', response_model=Optional[ModelModel]) async def update_model_by_id( + request: Request, form_data: ModelForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - model = Models.get_model_by_id(form_data.id, db=db) + model = await Models.get_model_by_id(form_data.id, db=db) if not model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -467,7 +666,7 @@ async def update_model_by_id( if ( model.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, @@ -481,7 +680,15 @@ async def update_model_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_models', + ) + + model = await Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) return model @@ -501,9 +708,9 @@ async def update_model_access_by_id( request: Request, form_data: ModelAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - model = Models.get_model_by_id(form_data.id, db=db) + model = await Models.get_model_by_id(form_data.id, db=db) # Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB # entry yet. Create a minimal one so access grants can be stored. @@ -513,7 +720,7 @@ async def update_model_access_by_id( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - model = Models.insert_new_model( + model = await Models.insert_new_model( ModelForm( id=form_data.id, name=form_data.name or form_data.id, @@ -531,7 +738,7 @@ async def update_model_access_by_id( if ( model.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, @@ -545,7 +752,7 @@ async def update_model_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -553,9 +760,11 @@ async def update_model_access_by_id( 'sharing.public_models', ) - AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db) + + await Models.update_model_updated_at_by_id(form_data.id, db=db) - return Models.get_model_by_id(form_data.id, db=db) + return await Models.get_model_by_id(form_data.id, db=db) ############################ @@ -567,9 +776,9 @@ async def update_model_access_by_id( async def delete_model_by_id( form_data: ModelIdForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - model = Models.get_model_by_id(form_data.id, db=db) + model = await Models.get_model_by_id(form_data.id, db=db) if not model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -579,7 +788,7 @@ async def delete_model_by_id( if ( user.role != 'admin' and model.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model.id, @@ -592,11 +801,11 @@ async def delete_model_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - result = Models.delete_model_by_id(form_data.id, db=db) + result = await Models.delete_model_by_id(form_data.id, db=db) return result @router.delete('/delete/all', response_model=bool) -async def delete_all_models(user=Depends(get_admin_user), db: Session = Depends(get_session)): - result = Models.delete_all_models(db=db) +async def delete_all_models(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + result = await Models.delete_all_models(db=db) return result diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index dd826053c8a..5ed46b5d616 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -30,11 +30,12 @@ from open_webui.utils.access_control import ( has_permission, has_public_read_access_grant, + has_public_write_access_grant, filter_allowed_access_grants, ) from open_webui.models.access_grants import AccessGrants -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -57,6 +58,7 @@ class NoteItemResponse(BaseModel): id: str title: str data: Optional[dict] + is_pinned: Optional[bool] = False updated_at: int created_at: int user: Optional[UserResponse] = None @@ -67,9 +69,9 @@ async def get_notes( request: Request, page: Optional[int] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -83,17 +85,60 @@ async def get_notes( limit = 60 skip = (page - 1) * limit - notes = Notes.get_notes_by_user_id(user.id, 'read', skip=skip, limit=limit, db=db) + notes = await Notes.get_notes_by_user_id(user.id, 'read', skip=skip, limit=limit, db=db) if not notes: return [] user_ids = list(set(note.user_id for note in notes)) - users = {user.id: user for user in Users.get_users_by_user_ids(user_ids, db=db)} + users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} + + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) return [ NoteUserResponse( **{ **note.model_dump(), + 'is_pinned': note.id in pinned_note_ids, + 'data': _truncate_note_data(note.data), + 'user': UserResponse(**users[note.user_id].model_dump()), + } + ) + for note in notes + if note.user_id in users + ] + + +############################ +# GetPinnedNotes +############################ + + +@router.get('/pinned', response_model=list[NoteItemResponse]) +async def get_pinned_notes( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = await Notes.get_pinned_notes_by_user_id(user.id, 'read', db=db) + if not notes: + return [] + + user_ids = list(set(note.user_id for note in notes)) + users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)} + + return [ + NoteUserResponse( + **{ + **note.model_dump(), + 'is_pinned': True, 'data': _truncate_note_data(note.data), 'user': UserResponse(**users[note.user_id].model_dump()), } @@ -113,9 +158,9 @@ async def search_notes( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -142,14 +187,16 @@ async def search_notes( filter['direction'] = direction if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) if groups: filter['group_ids'] = [group.id for group in groups] filter['user_id'] = user.id - result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) + result = await Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) for note in result.items: + note.is_pinned = note.id in pinned_note_ids note.data = _truncate_note_data(note.data) return result @@ -164,9 +211,9 @@ async def create_new_note( request: Request, form_data: NoteForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -174,8 +221,17 @@ async def create_new_note( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_notes', + db=db, + ) + try: - note = Notes.insert_new_note(user.id, form_data, db=db) + note = await Notes.insert_new_note(user.id, form_data, db=db) return note except Exception as e: log.exception(e) @@ -196,9 +252,9 @@ async def get_note_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -206,14 +262,14 @@ async def get_note_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id, db=db) + note = await Notes.get_note_by_id(id, db=db) if not note: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if user.role != 'admin' and ( user.id != note.user_id and ( - not AccessGrants.has_access( + not await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -227,17 +283,21 @@ async def get_note_by_id( write_access = ( user.role == 'admin' or (user.id == note.user_id) - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, permission='write', db=db, ) - or has_public_read_access_grant(note.access_grants) + or has_public_write_access_grant(note.access_grants) ) - return NoteResponse(**note.model_dump(), write_access=write_access) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + return NoteResponse( + **{**note.model_dump(), 'is_pinned': note.id in pinned_note_ids}, + write_access=write_access, + ) ############################ @@ -251,9 +311,9 @@ async def update_note_by_id( id: str, form_data: NoteForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -261,13 +321,13 @@ async def update_note_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id, db=db) + note = await Notes.get_note_by_id(id, db=db) if not note: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if user.role != 'admin' and ( user.id != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -277,7 +337,7 @@ async def update_note_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -287,7 +347,10 @@ async def update_note_by_id( ) try: - note = Notes.update_note_by_id(id, form_data, db=db) + note = await Notes.update_note_by_id(id, form_data, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids + await sio.emit( 'note-events', note.model_dump(), @@ -315,9 +378,9 @@ async def update_note_access_by_id( id: str, form_data: NoteAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -325,13 +388,13 @@ async def update_note_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id, db=db) + note = await Notes.get_note_by_id(id, db=db) if not note: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if user.role != 'admin' and ( user.id != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -341,7 +404,7 @@ async def update_note_access_by_id( ): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -349,9 +412,54 @@ async def update_note_access_by_id( 'sharing.public_notes', ) - AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) + + note = await Notes.get_note_by_id(id, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids + return note + + +############################ +# PinNoteById +############################ + + +@router.post('/{id}/pin', response_model=Optional[NoteModel]) +async def pin_note_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - return Notes.get_note_by_id(id, db=db) + note = await Notes.toggle_note_pinned_by_id(id, user.id, db=db) + pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) + note.is_pinned = note.id in pinned_note_ids + return note ############################ @@ -364,9 +472,9 @@ async def delete_note_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -374,13 +482,13 @@ async def delete_note_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id, db=db) + note = await Notes.get_note_by_id(id, db=db) if not note: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if user.role != 'admin' and ( user.id != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -391,7 +499,7 @@ async def delete_note_by_id( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - note = Notes.delete_note_by_id(id, db=db) + note = await Notes.delete_note_by_id(id, db=db) return True except Exception as e: log.exception(e) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 25a18e3e179..01fbf10f4ff 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -15,7 +15,7 @@ from urllib.parse import urlparse import aiohttp from aiocache import cached -import requests + from open_webui.utils.headers import include_user_info_headers from open_webui.models.chats import Chats @@ -39,17 +39,21 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, validator -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession -from open_webui.internal.db import get_session +from open_webui.internal.db import get_async_session from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups +from open_webui.utils.access_control import check_model_access from open_webui.utils.misc import ( calculate_sha256, +) +from open_webui.utils.session_pool import ( cleanup_response, + get_session, stream_wrapper, ) from open_webui.utils.payload import ( @@ -77,9 +81,22 @@ ########################################## # # Utility functions +# Let what runs locally be trusted, and let no weight +# be loaded without serving the one who waits for the answer. # ########################################## +# Headers that become stale after aiohttp auto-decompresses the upstream +# response body. Forwarding them verbatim causes desktop / programmatic +# clients to attempt decompression of an already-decoded payload, resulting +# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462. +_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'}) + + +def _clean_proxy_headers(raw_headers) -> dict: + """Return a copy of *raw_headers* with stale encoding headers removed.""" + return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS} + async def send_get_request(url, key=None, user: UserModel = None): timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) @@ -105,19 +122,21 @@ async def send_get_request(url, key=None, user: UserModel = None): return None -async def send_post_request( +async def send_request( url: str, - payload: Union[str, bytes], - stream: bool = True, + method: str = 'POST', + *, + payload: Optional[Union[str, bytes]] = None, key: Optional[str] = None, - content_type: Optional[str] = None, user: UserModel = None, + stream: bool = False, + content_type: Optional[str] = None, metadata: Optional[dict] = None, ): r = None streaming = False try: - session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)) + session = await get_session() headers = { 'Content-Type': 'application/json', @@ -129,57 +148,58 @@ async def send_post_request( if metadata and metadata.get('chat_id'): headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') - r = await session.post( + r = await session.request( + method, url, data=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) - if r.ok is False: + if not r.ok: try: res = await r.json() - await cleanup_response(r, session) if 'error' in res: raise HTTPException(status_code=r.status, detail=res['error']) - except HTTPException as e: - raise e # Re-raise HTTPException to be handled by FastAPI + except HTTPException: + raise except Exception as e: log.error(f'Failed to parse error response: {e}') - raise HTTPException( - status_code=r.status, - detail=f'Open WebUI: Server Connection Error', - ) + raise HTTPException( + status_code=r.status, + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, + ) - r.raise_for_status() # Raises an error for bad responses (4xx, 5xx) - if stream: - response_headers = dict(r.headers) + r.raise_for_status() + if stream: + response_headers = _clean_proxy_headers(r.headers) if content_type: response_headers['Content-Type'] = content_type streaming = True return StreamingResponse( - stream_wrapper(r, session), + stream_wrapper(r), status_code=r.status, headers=response_headers, ) else: - res = await r.json() - return res + try: + return await r.json() + except Exception: + return None - except HTTPException as e: - raise e # Re-raise HTTPException to be handled by FastAPI + except HTTPException: + raise except Exception as e: - detail = f'Ollama: {e}' - raise HTTPException( status_code=r.status if r else 500, - detail=detail if e else 'Open WebUI: Server Connection Error', + detail=f'Ollama: {e}' if str(e) else ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: - await cleanup_response(r, session) + await cleanup_response(r) def get_api_key(idx, url, configs): @@ -242,7 +262,7 @@ async def verify_connection(form_data: ConnectionVerificationForm, user=Depends( return data except aiohttp.ClientError as e: log.exception(f'Client error: {str(e)}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) except Exception as e: log.exception(f'Unexpected error: {e}') error_detail = f'Unexpected error: {str(e)}' @@ -393,11 +413,11 @@ async def get_all_models(request: Request, user: UserModel = None): async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model['model'] for model in models.get('models', [])] - model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in await Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls - accessible_model_ids = AccessGrants.get_accessible_resource_ids( + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='model', resource_ids=list(model_infos.keys()), @@ -419,7 +439,7 @@ async def get_filtered_models(models, user, db=None): @router.get('/api/tags/{url_idx}') async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) models = [] @@ -428,40 +448,7 @@ async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user= else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - - r = None - try: - headers = { - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='GET', - url=f'{url}/api/tags', - headers=headers, - ) - r.raise_for_status() - - models = r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + models = await send_request(f'{url}/api/tags', 'GET', key=key, user=user) if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: models['models'] = await get_filtered_models(models, user) @@ -567,29 +554,7 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): ) else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - - r = None - try: - r = requests.request(method='GET', url=f'{url}/api/version') - r.raise_for_status() - - return r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request(f'{url}/api/version', 'GET') else: return {'version': False} @@ -638,10 +603,9 @@ async def unload_model( payload = {'model': model_name, 'keep_alive': 0, 'prompt': ''} try: - res = await send_post_request( - url=f'{url}/api/generate', + res = await send_request( + f'{url}/api/generate', payload=json.dumps(payload), - stream=False, key=key, user=user, ) @@ -668,7 +632,7 @@ async def pull_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) @@ -679,11 +643,12 @@ async def pull_model( # Admin should be able to pull models from any source payload = {**form_data, 'insecure': True} - return await send_post_request( - url=f'{url}/api/pull', + return await send_request( + f'{url}/api/pull', payload=json.dumps(payload), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -702,7 +667,7 @@ async def push_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: await get_all_models(request, user=user) @@ -719,11 +684,12 @@ async def push_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] log.debug(f'url: {url}') - return await send_post_request( - url=f'{url}/api/push', + return await send_request( + f'{url}/api/push', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -744,16 +710,17 @@ async def create_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.debug(f'form_data: {form_data}') url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - return await send_post_request( - url=f'{url}/api/create', + return await send_request( + f'{url}/api/create', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -771,7 +738,7 @@ async def copy_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) if url_idx is None: await get_all_models(request, user=user) @@ -788,41 +755,13 @@ async def copy_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - try: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='POST', - url=f'{url}/api/copy', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - log.debug(f'r.text: {r.text}') - return True - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + await send_request( + f'{url}/api/copy', + payload=form_data.model_dump_json(exclude_none=True).encode(), + key=key, + user=user, + ) + return True @router.delete('/api/delete') @@ -834,7 +773,7 @@ async def delete_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) @@ -856,57 +795,32 @@ async def delete_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - r = None - try: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='DELETE', - url=f'{url}/api/delete', - headers=headers, - json=form_data, - ) - r.raise_for_status() - - log.debug(f'r.text: {r.text}') - return True - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + await send_request( + f'{url}/api/delete', + 'DELETE', + payload=json.dumps(form_data), + key=key, + user=user, + ) + return True @router.post('/api/show') async def show_model_info(request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) form_data = form_data.model_dump(exclude_none=True) form_data['model'] = form_data.get('model', form_data.get('name')) + model = form_data.get('model') + + # Enforce per-model access control + await check_model_access(user, await Models.get_model_by_id(model), BYPASS_MODEL_ACCESS_CONTROL) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS - model = form_data.get('model') - if model not in models: raise HTTPException( status_code=400, @@ -918,35 +832,12 @@ async def show_model_info(request: Request, form_data: ModelNameForm, user=Depen url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - try: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request(method='POST', url=f'{url}/api/show', headers=headers, json=form_data) - r.raise_for_status() - - return r.json() - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request( + f'{url}/api/show', + payload=json.dumps(form_data), + key=key, + user=user, + ) class GenerateEmbedForm(BaseModel): @@ -970,10 +861,13 @@ async def embed( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_batch_embeddings {form_data}') + # Enforce per-model access control + await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) + if url_idx is None: model = form_data.model @@ -1002,41 +896,12 @@ async def embed( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - try: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='POST', - url=f'{url}/api/embed', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - data = r.json() - return data - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request( + f'{url}/api/embed', + payload=form_data.model_dump_json(exclude_none=True).encode(), + key=key, + user=user, + ) class GenerateEmbeddingsForm(BaseModel): @@ -1055,10 +920,13 @@ async def embeddings( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) log.info(f'generate_ollama_embeddings {form_data}') + # Enforce per-model access control + await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) + if url_idx is None: model = form_data.model @@ -1087,41 +955,12 @@ async def embeddings( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - try: - headers = { - 'Content-Type': 'application/json', - **({'Authorization': f'Bearer {key}'} if key else {}), - } - - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) - - r = requests.request( - method='POST', - url=f'{url}/api/embeddings', - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - r.raise_for_status() - - data = r.json() - return data - except Exception as e: - log.exception(e) - - detail = None - if r is not None: - try: - res = r.json() - if 'error' in res: - detail = f'Ollama: {res["error"]}' - except Exception: - detail = f'Ollama: {e}' - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=detail if detail else 'Open WebUI: Server Connection Error', - ) + return await send_request( + f'{url}/api/embeddings', + payload=form_data.model_dump_json(exclude_none=True).encode(), + key=key, + user=user, + ) class GenerateCompletionForm(BaseModel): @@ -1148,13 +987,17 @@ async def generate_completion( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + + # Enforce per-model access control + await check_model_access(user, await Models.get_model_by_id(form_data.model), BYPASS_MODEL_ACCESS_CONTROL) if url_idx is None: await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model + if model in models: url_idx = random.choice(models[model]['urls']) else: @@ -1173,11 +1016,12 @@ async def generate_completion( if prefix_id: form_data.model = form_data.model.replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/api/generate', + return await send_request( + f'{url}/api/generate', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=True, ) @@ -1187,6 +1031,8 @@ class ChatMessage(BaseModel): tool_calls: Optional[list[dict]] = None images: Optional[list[str]] = None + model_config = ConfigDict(extra='allow') + @validator('content', pre=True) @classmethod def check_at_least_one_field(cls, field_value, values, **kwargs): @@ -1234,9 +1080,9 @@ async def generate_chat_completion( bypass_system_prompt: bool = False, ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail='Ollama API is disabled') + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. # This prevents holding a connection during the entire LLM call (30-60+ seconds), # which would exhaust the connection pool under concurrent load. @@ -1265,7 +1111,7 @@ async def generate_chat_completion( del payload['metadata'] model_id = payload['model'] - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: @@ -1281,31 +1127,11 @@ async def generate_chat_completion( payload = apply_model_params_to_body_ollama(params, payload) if not bypass_system_prompt: - payload = apply_system_prompt_to_body(system, payload, metadata, user) - - # Check if user has access to the model - if not bypass_filter and user.role == 'user': - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not ( - user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model_info.id, - permission='read', - user_group_ids=user_group_ids, - ) - ): - raise HTTPException( - status_code=403, - detail='Model not found', - ) - elif not bypass_filter: - if user.role != 'admin': - raise HTTPException( - status_code=403, - detail='Model not found', - ) + payload = await apply_system_prompt_to_body(system, payload, metadata, user) + + await check_model_access(user, model_info, bypass_filter) + else: + await check_model_access(user, None, bypass_filter) url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( @@ -1317,13 +1143,13 @@ async def generate_chat_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/api/chat', + return await send_request( + f'{url}/api/chat', payload=json.dumps(payload), - stream=form_data.stream, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), - content_type='application/x-ndjson', user=user, + stream=form_data.stream, + content_type='application/x-ndjson', metadata=metadata, ) @@ -1363,7 +1189,7 @@ async def generate_openai_completion( url_idx: Optional[int] = None, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. # This prevents holding a connection during the entire LLM call (30-60+ seconds), # which would exhaust the connection pool under concurrent load. @@ -1383,7 +1209,7 @@ async def generate_openai_completion( del payload['metadata'] model_id = form_data.model - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: payload['model'] = model_info.base_model_id @@ -1392,29 +1218,9 @@ async def generate_openai_completion( if params: payload = apply_model_params_to_body_openai(params, payload) - # Check if user has access to the model - if user.role == 'user': - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not ( - user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model_info.id, - permission='read', - user_group_ids=user_group_ids, - ) - ): - raise HTTPException( - status_code=403, - detail='Model not found', - ) + await check_model_access(user, model_info) else: - if user.role != 'admin': - raise HTTPException( - status_code=403, - detail='Model not found', - ) + await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( @@ -1427,12 +1233,12 @@ async def generate_openai_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/v1/completions', + return await send_request( + f'{url}/v1/completions', payload=json.dumps(payload), - stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=payload.get('stream', False), metadata=metadata, ) @@ -1445,7 +1251,7 @@ async def generate_openai_chat_completion( url_idx: Optional[int] = None, user=Depends(get_verified_user), ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. # This prevents holding a connection during the entire LLM call (30-60+ seconds), # which would exhaust the connection pool under concurrent load. @@ -1465,7 +1271,7 @@ async def generate_openai_chat_completion( del payload['metadata'] model_id = completion_form.model - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: payload['model'] = model_info.base_model_id @@ -1476,31 +1282,11 @@ async def generate_openai_chat_completion( system = params.pop('system', None) payload = apply_model_params_to_body_openai(params, payload) - payload = apply_system_prompt_to_body(system, payload, metadata, user) - - # Check if user has access to the model - if user.role == 'user': - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not ( - user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model_info.id, - permission='read', - user_group_ids=user_group_ids, - ) - ): - raise HTTPException( - status_code=403, - detail='Model not found', - ) + payload = await apply_system_prompt_to_body(system, payload, metadata, user) + + await check_model_access(user, model_info) else: - if user.role != 'admin': - raise HTTPException( - status_code=403, - detail='Model not found', - ) + await check_model_access(user, None) url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( @@ -1512,23 +1298,133 @@ async def generate_openai_chat_completion( if prefix_id: payload['model'] = payload['model'].replace(f'{prefix_id}.', '') - return await send_post_request( - url=f'{url}/v1/chat/completions', + return await send_request( + f'{url}/v1/chat/completions', payload=json.dumps(payload), - stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, + stream=payload.get('stream', False), metadata=metadata, ) +@router.post('/v1/messages') +@router.post('/v1/messages/{url_idx}') +async def generate_anthropic_messages( + request: Request, + form_data: dict, + url_idx: Optional[int] = None, + user=Depends(get_verified_user), +): + """ + Proxy for Ollama's Anthropic-compatible /v1/messages endpoint. + + Forwards the request as-is to the Ollama backend, applying the same + model resolution, access control, and prefix_id handling used by + the OpenAI-compatible /v1/chat/completions proxy. + + See https://docs.ollama.com/api/anthropic-compatibility + """ + if not request.app.state.config.ENABLE_OLLAMA_API: + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + + payload = {**form_data} + model_id = payload.get('model', '') + + model_info = await Models.get_model_by_id(model_id) + if model_info: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + + await check_model_access(user, model_info) + else: + await check_model_access(user, None) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) + + prefix_id = api_config.get('prefix_id', None) + if prefix_id: + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + + return await send_request( + f'{url}/v1/messages', + payload=json.dumps(payload), + key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, + stream=payload.get('stream', False), + content_type='text/event-stream' if payload.get('stream', False) else None, + ) + + +class ResponsesForm(BaseModel): + model: str + + model_config = ConfigDict(extra='allow') + + +@router.post('/v1/responses') +@router.post('/v1/responses/{url_idx}') +async def generate_responses( + request: Request, + form_data: ResponsesForm, + url_idx: Optional[int] = None, + user=Depends(get_verified_user), +): + """ + Proxy for Ollama's OpenAI-compatible /v1/responses endpoint. + + Forwards the request as-is to the Ollama backend, applying the same + model resolution, access control, and prefix_id handling used by + the OpenAI-compatible /v1/chat/completions proxy. + + See https://ollama.com/blog/responses-api + """ + if not request.app.state.config.ENABLE_OLLAMA_API: + raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED) + + payload = form_data.model_dump() + model_id = form_data.model + + model_info = await Models.get_model_by_id(model_id) + if model_info: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + + await check_model_access(user, model_info) + else: + await check_model_access(user, None) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) + + prefix_id = api_config.get('prefix_id', None) + if prefix_id: + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') + + return await send_request( + f'{url}/v1/responses', + payload=json.dumps(payload), + key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, + stream=payload.get('stream', False), + content_type='text/event-stream' if payload.get('stream', False) else None, + ) + + @router.get('/v1/models') @router.get('/v1/models/{url_idx}') async def get_openai_models( request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): models = [] if url_idx is None: @@ -1545,45 +1441,26 @@ async def get_openai_models( else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - try: - r = requests.request(method='GET', url=f'{url}/api/tags') - r.raise_for_status() - - model_list = r.json() - - models = [ - { - 'id': model['model'], - 'object': 'model', - 'created': int(time.time()), - 'owned_by': 'openai', - } - for model in models['models'] - ] - except Exception as e: - log.exception(e) - error_detail = 'Open WebUI: Server Connection Error' - if r is not None: - try: - res = r.json() - if 'error' in res: - error_detail = f'Ollama: {res["error"]}' - except Exception: - error_detail = f'Ollama: {e}' + model_list = await send_request(f'{url}/api/tags', 'GET') - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + models = [ + { + 'id': model['model'], + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'openai', + } + for model in model_list.get('models', []) + ] if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: # Filter models based on user access control model_ids = [model['id'] for model in models] - model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in await Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls - accessible_model_ids = AccessGrants.get_accessible_resource_ids( + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='model', resource_ids=list(model_infos.keys()), @@ -1660,13 +1537,16 @@ async def download_file_stream(ollama_url, file_url, file_path, file_name, chunk file.close() hashed = calculate_sha256(file_path, chunk_size) - with open(file_path, 'rb') as file: - chunk_size = 1024 * 1024 * 2 - url = f'{ollama_url}/api/blobs/sha256:{hashed}' - with requests.Session() as session: - response = session.post(url, data=file, timeout=30) + with open(file_path, 'rb') as f: + blob_data = f.read() - if response.ok: + url = f'{ollama_url}/api/blobs/sha256:{hashed}' + blob_timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=blob_timeout, trust_env=True) as blob_session: + async with blob_session.post( + url, data=blob_data, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as blob_response: + if blob_response.ok: res = { 'done': done, 'blob': f'sha256:{hashed}', @@ -1703,7 +1583,7 @@ async def download_model( file_name = parse_huggingface_url(form_data.url) if file_name: - file_path = f'{UPLOAD_DIR}/{file_name}' + file_path = os.path.join(UPLOAD_DIR, file_name) return StreamingResponse( download_file_stream(url, form_data.url, file_path, file_name), @@ -1762,47 +1642,51 @@ async def file_process_stream(): # --- P3: Upload to ollama /api/blobs --- with open(file_path, 'rb') as f: - url = f'{ollama_url}/api/blobs/sha256:{file_hash}' - response = requests.post(url, data=f) - - if response.ok: - log.info(f'Uploaded to /api/blobs') # DEBUG - # Remove local file - os.remove(file_path) - - # Create model in ollama - model_name, ext = os.path.splitext(filename) - log.info(f'Created Model: {model_name}') # DEBUG - - create_payload = { - 'model': model_name, - # Reference the file by its original name => the uploaded blob's digest - 'files': {filename: f'sha256:{file_hash}'}, - } - log.info(f'Model Payload: {create_payload}') # DEBUG - - # Call ollama /api/create - # https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model - create_resp = requests.post( - url=f'{ollama_url}/api/create', + blob_data = f.read() + + url = f'{ollama_url}/api/blobs/sha256:{file_hash}' + upload_timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession(timeout=upload_timeout, trust_env=True) as upload_session: + async with upload_session.post(url, data=blob_data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response: + if not response.ok: + raise Exception('Ollama: Could not create blob, Please try again.') + + log.info(f'Uploaded to /api/blobs') # DEBUG + # Remove local file + os.remove(file_path) + + # Create model in ollama + model_name, ext = os.path.splitext(filename) + log.info(f'Created Model: {model_name}') # DEBUG + + create_payload = { + 'model': model_name, + # Reference the file by its original name => the uploaded blob's digest + 'files': {filename: f'sha256:{file_hash}'}, + } + log.info(f'Model Payload: {create_payload}') # DEBUG + + # Call ollama /api/create + # https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model + async with aiohttp.ClientSession(timeout=upload_timeout, trust_env=True) as create_session: + async with create_session.post( + f'{ollama_url}/api/create', headers={'Content-Type': 'application/json'}, data=json.dumps(create_payload), - ) - - if create_resp.ok: - log.info(f'API SUCCESS!') # DEBUG - done_msg = { - 'done': True, - 'blob': f'sha256:{file_hash}', - 'name': filename, - 'model_created': model_name, - } - yield f'data: {json.dumps(done_msg)}\n\n' - else: - raise Exception(f'Failed to create model in Ollama. {create_resp.text}') - - else: - raise Exception('Ollama: Could not create blob, Please try again.') + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as create_resp: + if create_resp.ok: + log.info(f'API SUCCESS!') # DEBUG + done_msg = { + 'done': True, + 'blob': f'sha256:{file_hash}', + 'name': filename, + 'model_created': model_name, + } + yield f'data: {json.dumps(done_msg)}\n\n' + else: + resp_text = await create_resp.text() + raise Exception(f'Failed to create model in Ollama. {resp_text}') except Exception as e: res = {'error': str(e)} diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 89fbf4852ca..3cf3cd9f4a5 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -2,16 +2,17 @@ import hashlib import json import logging +import re from typing import Optional -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import aiohttp from aiocache import cached -import requests + from azure.identity import DefaultAzureCredential, get_bearer_token_provider -from fastapi import Depends, HTTPException, Request, APIRouter +from fastapi import Depends, HTTPException, Request, APIRouter, status from fastapi.responses import ( FileResponse, StreamingResponse, @@ -20,13 +21,14 @@ ) from pydantic import BaseModel, ConfigDict -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession -from open_webui.internal.db import get_session +from open_webui.internal.db import get_async_session from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups +from open_webui.utils.access_control import has_connection_access, check_model_access from open_webui.config import ( CACHE_DIR, ) @@ -38,6 +40,7 @@ ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, BYPASS_MODEL_ACCESS_CONTROL, + ENABLE_OPENAI_API_PASSTHROUGH, ) from open_webui.models.users import UserModel @@ -49,14 +52,17 @@ apply_system_prompt_to_body, ) from open_webui.utils.misc import ( - cleanup_response, convert_logit_bias_input_to_json, stream_chunks_handler, +) +from open_webui.utils.session_pool import ( + cleanup_response, + get_session, stream_wrapper, ) from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, get_custom_headers from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models log = logging.getLogger(__name__) @@ -65,24 +71,48 @@ ########################################## # # Utility functions +# Let the responses returned through this gate be worth +# the question that summoned them. # ########################################## +# Headers that become stale after aiohttp auto-decompresses the upstream +# response body. Forwarding them verbatim causes desktop / programmatic +# clients to attempt decompression of an already-decoded payload, resulting +# in ZlibError. See https://github.com/aio-libs/aiohttp/issues/4462. +_STRIP_PROXY_HEADERS = frozenset({'Content-Encoding', 'Content-Length', 'Transfer-Encoding'}) -async def send_get_request(url, key=None, user: UserModel = None): + +def _clean_proxy_headers(raw_headers) -> dict: + """Return a copy of *raw_headers* with stale encoding headers removed.""" + return {k: v for k, v in raw_headers.items() if k not in _STRIP_PROXY_HEADERS} + + +async def send_get_request( + request: Request = None, + url=None, + key=None, + user: UserModel = None, + config=None, +): timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - headers = { - **({'Authorization': f'Bearer {key}'} if key else {}), - } + if request and config: + headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user) + else: + headers = { + **({'Authorization': f'Bearer {key}'} if key else {}), + } + cookies = None - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) async with session.get( url, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() @@ -92,10 +122,16 @@ async def send_get_request(url, key=None, user: UserModel = None): return None -async def get_models_request(url, key=None, user: UserModel = None): +async def get_models_request( + request: Request = None, + url=None, + key=None, + user: UserModel = None, + config=None, +): if is_anthropic_url(url): return await get_anthropic_models(url, key, user=user) - return await send_get_request(f'{url}/models', key, user=user) + return await send_get_request(request, f'{url}/models', key, user=user, config=config) def openai_reasoning_model_handler(payload): @@ -179,7 +215,8 @@ async def get_headers_and_cookies( headers['Authorization'] = f'Bearer {token}' if config.get('headers') and isinstance(config.get('headers'), dict): - headers = {**headers, **config.get('headers')} + custom_headers = get_custom_headers(config.get('headers'), user, metadata) + headers.update(custom_headers) return headers, cookies @@ -287,19 +324,20 @@ async def speech(request: Request, user=Depends(get_verified_user)): r = None try: - r = requests.post( + session = await get_session() + r = await session.post( url=f'{url}/audio/speech', data=body, headers=headers, cookies=cookies, - stream=True, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() # Save the streaming content to a file with open(file_path, 'wb') as f: - for chunk in r.iter_content(chunk_size=8192): + async for chunk in r.content.iter_chunked(8192): f.write(chunk) with open(file_body_path, 'w') as f: @@ -314,14 +352,14 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = None if r is not None: try: - res = r.json() + res = await r.json() if 'error' in res: detail = f'External: {res["error"]}' except Exception: detail = f'External: {e}' raise HTTPException( - status_code=r.status_code if r else 500, + status_code=r.status if r else 500, detail=detail if detail else 'Open WebUI: Server Connection Error', ) @@ -357,7 +395,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: request_tasks = [] for idx, url in enumerate(api_base_urls): if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support - request_tasks.append(get_models_request(url, api_keys[idx], user=user)) + request_tasks.append(get_models_request(request, url, api_keys[idx], user=user)) else: api_config = api_configs.get( str(idx), @@ -369,7 +407,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if enable: if len(model_ids) == 0: - request_tasks.append(get_models_request(url, api_keys[idx], user=user)) + request_tasks.append(get_models_request(request, url, api_keys[idx], user=user, config=api_config)) else: model_list = { 'object': 'list', @@ -402,6 +440,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: connection_type = api_config.get('connection_type', 'external') prefix_id = api_config.get('prefix_id', None) tags = api_config.get('tags', []) + provider = api_config.get('provider', '') model_list = response if isinstance(response, list) else response.get('data', []) if not isinstance(model_list, list): @@ -422,6 +461,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if connection_type: model['connection_type'] = connection_type + if provider: + model['provider'] = provider + log.debug(f'get_all_models:responses() {responses}') return responses @@ -429,11 +471,11 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model['id'] for model in models.get('data', [])] - model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = {model_info.id: model_info for model_info in await Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls - accessible_model_ids = AccessGrants.get_accessible_resource_ids( + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='model', resource_ids=list(model_infos.keys()), @@ -451,6 +493,39 @@ async def get_filtered_models(models, user, db=None): return filtered_models +async def get_openai_loaded_models(request: Request, models: dict, api_base_urls: list): + """ + Fetch loaded-model state from providers that expose it and annotate + each model dict with a ``loaded`` boolean. + + Currently supports: + - **llama.cpp** – queries ``GET /slots`` and matches slot model IDs. + """ + api_configs = request.app.state.config.OPENAI_API_CONFIGS + api_keys = request.app.state.config.OPENAI_API_KEYS + + for idx, url in enumerate(api_base_urls): + api_config = api_configs.get( + str(idx), + api_configs.get(url, {}), + ) + provider = api_config.get('provider', '') + + if provider == 'llama.cpp': + try: + root_url = url.rstrip('/').removesuffix('/v1') + key = api_keys[idx] if idx < len(api_keys) else None + slots = await send_get_request(url=f'{root_url}/slots', key=key) + loaded_model_ids = ( + {s.get('model') for s in slots if s.get('model')} if isinstance(slots, list) else set() + ) + for model_id, model in models.items(): + if model.get('urlIdx') == idx: + model['loaded'] = model_id in loaded_model_ids + except Exception as e: + log.debug(f'Failed to fetch llama.cpp slots for idx {idx}: {e}') + + @cached( ttl=MODELS_CACHE_TTL, key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models', @@ -511,6 +586,7 @@ def get_merged_models(model_lists): 'owned_by': 'openai', 'openai': model, 'connection_type': model.get('connection_type', 'external'), + 'provider': model.get('provider', ''), 'urlIdx': idx, } @@ -519,6 +595,9 @@ def get_merged_models(model_lists): models = get_merged_models(map(extract_data, responses)) log.debug(f'models: {models}') + # Fetch loaded state for providers that support it (e.g. llama.cpp /slots) + await get_openai_loaded_models(request, models, api_base_urls) + request.app.state.OPENAI_MODELS = models return {'data': list(models.values())} @@ -666,7 +745,7 @@ async def verify_connection( elif is_anthropic_url(url): result = await get_anthropic_models(url, key) if result is None: - raise HTTPException(status_code=500, detail='Failed to connect to Anthropic API') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) if 'error' in result: raise HTTPException(status_code=500, detail=result['error']) return result @@ -693,10 +772,10 @@ async def verify_connection( except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues log.exception(f'Client error: {str(e)}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) except Exception as e: log.exception(f'Unexpected error: {e}') - raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR) def get_azure_allowed_params(api_version: str) -> set[str]: @@ -739,8 +818,31 @@ def get_azure_allowed_params(api_version: str) -> set[str]: return allowed_params -def is_openai_reasoning_model(model: str) -> bool: - return model.lower().startswith(('o1', 'o3', 'o4', 'gpt-5')) +def is_openai_new_model(model: str) -> bool: + model_lower = model.lower() + # o-series models (o1, o3, o4, o5, ...) + if re.match(r'^o\d+', model_lower): + return True + # gpt-N where N >= 5 (gpt-5, gpt-5.2, gpt-6, ...) + m = re.match(r'^gpt-(\d+)', model_lower) + if m and int(m.group(1)) >= 5: + return True + return False + + +def _sanitize_model_for_url(model: str) -> str: + """Sanitize a model name before interpolating it into a URL path. + + Rejects path traversal attempts (../, /, \\) and percent-encodes + the name so it is safe to use as a single URL path segment + (e.g. Azure deployment name). + """ + if not model or '..' in model or '/' in model or '\\' in model: + raise HTTPException( + status_code=400, + detail='Invalid model name: must not be empty or contain path separators or traversal sequences', + ) + return quote(model, safe='') def convert_to_azure_payload(url, payload: dict, api_version: str): @@ -750,7 +852,7 @@ def convert_to_azure_payload(url, payload: dict, api_version: str): allowed_params = get_azure_allowed_params(api_version) # Special handling for o-series models - if is_openai_reasoning_model(model): + if is_openai_new_model(model): # Convert max_tokens to max_completion_tokens for o-series models if 'max_tokens' in payload: payload['max_completion_tokens'] = payload['max_tokens'] @@ -766,10 +868,38 @@ def convert_to_azure_payload(url, payload: dict, api_version: str): # Filter out unsupported parameters payload = {k: v for k, v in payload.items() if k in allowed_params} + # Sanitize model name to prevent path traversal in the deployment URL + model = _sanitize_model_for_url(model) + url = f'{url}/openai/deployments/{model}' return url, payload +# Fields accepted by the Responses API for each input item type. +RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = { + 'message': {'type', 'role', 'content'}, + 'function_call': {'type', 'call_id', 'name', 'arguments', 'id'}, + 'function_call_output': {'type', 'call_id', 'output'}, +} + + +def _normalize_stored_item(item: dict) -> dict: + """Strip local-only fields from a stored output item before replaying it. + + Open WebUI stores extra bookkeeping fields (``id``, ``status``, + ``started_at``, ``ended_at``, ``duration``, ``_tag_type``, + ``attributes``, ``summary``, etc.) that the Responses API does + not accept. This helper returns a copy containing only the + fields the API understands. + """ + item_type = item.get('type', '') + allowed = RESPONSES_ALLOWED_FIELDS.get(item_type) + if allowed is None: + # Unknown type — pass through as-is (e.g. reasoning, extension items). + return item + return {k: v for k, v in item.items() if k in allowed} + + def convert_to_responses_payload(payload: dict) -> dict: """ Convert Chat Completions payload to Responses API format. @@ -789,7 +919,7 @@ def convert_to_responses_payload(payload: dict) -> dict: # Check for stored output items (from previous Responses API turn) stored_output = msg.get('output') if stored_output and isinstance(stored_output, list): - input_items.extend(stored_output) + input_items.extend(_normalize_stored_item(item) for item in stored_output) continue if role == 'system': @@ -799,6 +929,47 @@ def convert_to_responses_payload(payload: dict) -> dict: system_content = '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text') continue + # Handle assistant messages with tool_calls (from convert_output_to_messages) + if role == 'assistant' and msg.get('tool_calls'): + # Add text content as message if present + if content: + text = ( + content + if isinstance(content, str) + else '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text') + ) + if text.strip(): + input_items.append( + { + 'type': 'message', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': text}], + } + ) + # Convert each tool_call to a function_call input item + for tool_call in msg['tool_calls']: + func = tool_call.get('function', {}) + input_items.append( + { + 'type': 'function_call', + 'call_id': tool_call.get('id', ''), + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + } + ) + continue + + # Handle tool result messages + if role == 'tool': + input_items.append( + { + 'type': 'function_call_output', + 'call_id': msg.get('tool_call_id', ''), + 'output': msg.get('content', ''), + } + ) + continue + # Convert content format text_type = 'output_text' if role == 'assistant' else 'input_text' @@ -820,12 +991,21 @@ def convert_to_responses_payload(payload: dict) -> dict: responses_payload = {**payload, 'input': input_items} + # Forward previous_response_id when the middleware has set it + # (only used when ENABLE_RESPONSES_API_STATEFUL is enabled). + previous_response_id = responses_payload.pop('previous_response_id', None) + if previous_response_id: + responses_payload['previous_response_id'] = previous_response_id + if system_content: responses_payload['instructions'] = system_content if 'max_tokens' in responses_payload: responses_payload['max_output_tokens'] = responses_payload.pop('max_tokens') + if 'max_completion_tokens' in responses_payload: + responses_payload['max_output_tokens'] = responses_payload.pop('max_completion_tokens') + # Remove Chat Completions-only parameters not supported by the Responses API for unsupported_key in ( 'stream_options', @@ -864,11 +1044,36 @@ def convert_to_responses_payload(payload: dict) -> dict: def convert_responses_result(response: dict) -> dict: """ - Convert non-streaming Responses API result. - Just add done flag - pass through raw response, frontend handles output. + Convert non-streaming Responses API result to Chat Completions format. + + Extracts text from message output items so all downstream consumers + (frontend tasks, get_content_from_response) work without modification. """ - response['done'] = True - return response + output_items = response.get('output', []) + + content = '' + for item in output_items: + if item.get('type') == 'message': + for part in item.get('content', []): + if part.get('type') == 'output_text': + content += part.get('text', '') + + return { + 'id': response.get('id', ''), + 'object': 'chat.completion', + 'model': response.get('model', ''), + 'choices': [ + { + 'index': 0, + 'message': { + 'role': 'assistant', + 'content': content, + }, + 'finish_reason': 'stop', + } + ], + 'usage': response.get('usage', {}), + } @router.post('/chat/completions') @@ -878,7 +1083,7 @@ async def generate_chat_completion( user=Depends(get_verified_user), bypass_system_prompt: bool = False, ): - # NOTE: We intentionally do NOT use Depends(get_session) here. + # NOTE: We intentionally do NOT use Depends(get_async_session) here. # Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions. # This prevents holding a connection during the entire LLM call (30-60+ seconds), # which would exhaust the connection pool under concurrent load. @@ -896,7 +1101,7 @@ async def generate_chat_completion( metadata = payload.pop('metadata', None) model_id = form_data.get('model') - model_info = Models.get_model_by_id(model_id) + model_info = await Models.get_model_by_id(model_id) # Check model info and override the payload if model_info: @@ -914,31 +1119,11 @@ async def generate_chat_completion( payload = apply_model_params_to_body_openai(params, payload) if not bypass_system_prompt: - payload = apply_system_prompt_to_body(system, payload, metadata, user) - - # Check if user has access to the model - if not bypass_filter and user.role == 'user': - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not ( - user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type='model', - resource_id=model_info.id, - permission='read', - user_group_ids=user_group_ids, - ) - ): - raise HTTPException( - status_code=403, - detail='Model not found', - ) - elif not bypass_filter: - if user.role != 'admin': - raise HTTPException( - status_code=403, - detail='Model not found', - ) + payload = await apply_system_prompt_to_body(system, payload, metadata, user) + + await check_model_access(user, model_info, bypass_filter) + else: + await check_model_access(user, None, bypass_filter) # Check if model is already in app state cache to avoid expensive get_all_models() call models = request.app.state.OPENAI_MODELS @@ -952,7 +1137,7 @@ async def generate_chat_completion( else: raise HTTPException( status_code=404, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Get the API config for the model @@ -980,7 +1165,7 @@ async def generate_chat_completion( key = request.app.state.config.OPENAI_API_KEYS[idx] # Check if model is a reasoning model that needs special handling - if is_openai_reasoning_model(payload['model']): + if is_openai_new_model(payload['model']): payload = openai_reasoning_model_handler(payload) elif 'api.openai.com' not in url: # Remove "max_completion_tokens" from the payload for backward compatibility @@ -1003,37 +1188,54 @@ async def generate_chat_completion( is_responses = api_config.get('api_type') == 'responses' if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - request_url, payload = convert_to_azure_payload(url, payload, api_version) - # Only set api-key header if not using Azure Entra ID authentication auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + # Azure v1 format: base URL already ends with /openai/v1, + # model stays in the payload, no deployment URL rewriting. + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - if is_responses: - payload = convert_to_responses_payload(payload) - request_url = f'{request_url}/responses?api-version={api_version}' + if is_azure_v1: + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f'{url.rstrip("/")}/responses' + else: + request_url = f'{url.rstrip("/")}/chat/completions' else: - request_url = f'{request_url}/chat/completions?api-version={api_version}' + api_version = api_config.get('api_version', '2023-03-15-preview') + request_url, payload = convert_to_azure_payload(url, payload, api_version) + headers['api-version'] = api_version + + if is_responses: + payload = convert_to_responses_payload(payload) + request_url = f'{request_url}/responses?api-version={api_version}' + else: + request_url = f'{request_url}/chat/completions?api-version={api_version}' else: if is_responses: payload = convert_to_responses_payload(payload) request_url = f'{url}/responses' else: request_url = f'{url}/chat/completions' + # For Chat Completions, strip image parts from multimodal tool messages + # (Chat Completions doesn't support images in tool content). + if not is_responses and 'messages' in payload: + for message in payload['messages']: + if message.get('role') == 'tool' and isinstance(message.get('content'), list): + message['content'] = ''.join( + part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text') + ) payload = json.dumps(payload) r = None - session = None streaming = False response = None try: - session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)) + session = await get_session() r = await session.request( method='POST', @@ -1042,15 +1244,35 @@ async def generate_chat_completion( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): + # If the provider returned an error status with SSE content-type, + # read the body and return a proper error response instead of + # streaming the error back (which hides the error from logs). + if r.status >= 400: + error_body = await r.text() + log.error( + 'Provider returned HTTP %d with SSE content-type: %s', + r.status, + error_body[:1000], + ) + try: + error_json = json.loads(error_body) + return JSONResponse(status_code=r.status, content=error_json) + except json.JSONDecodeError: + return JSONResponse( + status_code=r.status, + content={'error': {'message': error_body, 'code': r.status}}, + ) + streaming = True return StreamingResponse( - stream_wrapper(r, session, stream_chunks_handler), + stream_wrapper(r, content_handler=stream_chunks_handler), status_code=r.status, - headers=dict(r.headers), + headers=_clean_proxy_headers(r.headers), ) else: try: @@ -1075,11 +1297,11 @@ async def generate_chat_completion( raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: - await cleanup_response(r, session) + await cleanup_response(r) async def embeddings(request: Request, form_data: dict, user): @@ -1115,29 +1337,27 @@ async def embeddings(request: Request, form_data: dict, user): ) r = None - session = None streaming = False headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) try: - session = aiohttp.ClientSession( - trust_env=True, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), - ) + session = await get_session() r = await session.request( method='POST', url=f'{url}/embeddings', data=body, headers=headers, cookies=cookies, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r, session), + stream_wrapper(r), status_code=r.status, - headers=dict(r.headers), + headers=_clean_proxy_headers(r.headers), ) else: try: @@ -1156,11 +1376,11 @@ async def embeddings(request: Request, form_data: dict, user): log.exception(e) raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: - await cleanup_response(r, session) + await cleanup_response(r) class ResponsesForm(BaseModel): @@ -1194,10 +1414,15 @@ async def responses( Routes to the correct upstream backend based on the model field. """ payload = form_data.model_dump(exclude_none=True) - body = json.dumps(payload) idx = 0 model_id = form_data.model + + # Enforce per-model access control + await check_model_access(user, await Models.get_model_by_id(model_id), BYPASS_MODEL_ACCESS_CONTROL) + + body = json.dumps(payload) + if model_id: models = request.app.state.OPENAI_MODELS if not models or model_id not in models: @@ -1214,30 +1439,29 @@ async def responses( ) r = None - session = None streaming = False try: headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - model = payload.get('model', '') - request_url = f'{url}/openai/deployments/{model}/responses?api-version={api_version}' + if is_azure_v1: + request_url = f'{url.rstrip("/")}/responses' + else: + api_version = api_config.get('api_version', '2023-03-15-preview') + headers['api-version'] = api_version + model = _sanitize_model_for_url(payload.get('model', '')) + request_url = f'{url}/openai/deployments/{model}/responses?api-version={api_version}' else: request_url = f'{url}/responses' - session = aiohttp.ClientSession( - trust_env=True, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), - ) + session = await get_session() r = await session.request( method='POST', url=request_url, @@ -1245,15 +1469,16 @@ async def responses( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r, session), + stream_wrapper(r), status_code=r.status, - headers=dict(r.headers), + headers=_clean_proxy_headers(r.headers), ) else: try: @@ -1269,23 +1494,32 @@ async def responses( return response_data + except HTTPException: + raise except Exception as e: log.exception(e) raise HTTPException( status_code=r.status if r else 500, - detail='Open WebUI: Server Connection Error', + detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR, ) finally: if not streaming: - await cleanup_response(r, session) + await cleanup_response(r) @router.api_route('/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE']) async def proxy(path: str, request: Request, user=Depends(get_verified_user)): """ - Deprecated: proxy all requests to OpenAI API + Deprecated: proxy all requests to OpenAI API. + Disabled by default. Set ENABLE_OPENAI_API_PASSTHROUGH=True to enable. """ + if not ENABLE_OPENAI_API_PASSTHROUGH: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Direct API passthrough is disabled. Set ENABLE_OPENAI_API_PASSTHROUGH=True to enable.', + ) + body = await request.body() # Parse JSON body to resolve model-based routing @@ -1316,34 +1550,35 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): ) r = None - session = None streaming = False try: headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) if api_config.get('azure', False): - api_version = api_config.get('api_version', '2023-03-15-preview') - # Only set api-key header if not using Azure Entra ID authentication auth_type = api_config.get('auth_type', 'bearer') if auth_type not in ('azure_ad', 'microsoft_entra_id'): headers['api-key'] = key - headers['api-version'] = api_version + is_azure_v1 = bool(re.search(r'/openai/v1(?:/|$)', url)) - payload = json.loads(body) - url, payload = convert_to_azure_payload(url, payload, api_version) - body = json.dumps(payload).encode() + if is_azure_v1: + qs = request.url.query + request_url = f'{url.rstrip("/")}/{path}' + (f'?{qs}' if qs else '') + else: + api_version = api_config.get('api_version', '2023-03-15-preview') + headers['api-version'] = api_version - request_url = f'{url}/{path}?api-version={api_version}' + payload = json.loads(body) + url, payload = convert_to_azure_payload(url, payload, api_version) + body = json.dumps(payload).encode() + + request_url = f'{url}/{path}?api-version={api_version}' else: request_url = f'{url}/{path}' - session = aiohttp.ClientSession( - trust_env=True, - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), - ) + session = await get_session() r = await session.request( method=request.method, url=request_url, @@ -1351,15 +1586,16 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) # Check if response is SSE if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper(r, session), + stream_wrapper(r), status_code=r.status, - headers=dict(r.headers), + headers=_clean_proxy_headers(r.headers), ) else: try: @@ -1375,6 +1611,8 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): return response_data + except HTTPException: + raise except Exception as e: log.exception(e) raise HTTPException( @@ -1383,4 +1621,4 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): ) finally: if not streaming: - await cleanup_response(r, session) + await cleanup_response(r) diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 4f1022476bf..580fb42fb2d 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -32,6 +32,8 @@ ################################## # # Pipeline Middleware +# Every hand this passes through can corrupt it or +# improve it. Let each stage leave it better than it found. # ################################## @@ -92,9 +94,24 @@ async def process_pipeline_inlet_filter(request, payload, user, models): response.raise_for_status() payload = await response.json() except aiohttp.ClientResponseError as e: - res = await response.json() if response.content_type == 'application/json' else {} - if 'detail' in res: - raise Exception(response.status, res['detail']) + try: + res = await response.json() if 'application/json' in response.content_type else {} + if 'detail' in res: + raise HTTPException( + status_code=response.status, + detail=res['detail'], + ) + except HTTPException: + raise + except Exception: + pass + + raise HTTPException( + status_code=response.status, + detail=e.message, + ) + except HTTPException: + raise except Exception as e: log.exception(f'Connection error: {e}') @@ -144,9 +161,21 @@ async def process_pipeline_outlet_filter(request, payload, user, models): try: res = await response.json() if 'application/json' in response.content_type else {} if 'detail' in res: - raise Exception(response.status, res) + raise HTTPException( + status_code=response.status, + detail=res['detail'], + ) + except HTTPException: + raise except Exception: pass + + raise HTTPException( + status_code=response.status, + detail=e.message, + ) + except HTTPException: + raise except Exception as e: log.exception(f'Connection error: {e}') diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index df07c778c1d..755034f8809 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -20,8 +20,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL -from open_webui.internal.db import get_session -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel @@ -42,30 +42,26 @@ class PromptMetadataForm(BaseModel): ############################ # GetPrompts +# The hardest part is knowing what to ask. Let the right +# question already be here when it is needed. ############################ @router.get('/', response_model=list[PromptModel]) -async def get_prompts(user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_prompts(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - prompts = Prompts.get_prompts(db=db) + prompts = await Prompts.get_prompts(db=db) else: - prompts = Prompts.get_prompts_by_user_id(user.id, 'read', db=db) + prompts = await Prompts.get_prompts_by_user_id(user.id, 'read', db=db) return prompts @router.get('/tags', response_model=list[str]) -async def get_prompt_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_prompt_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return Prompts.get_tags(db=db) - else: - prompts = Prompts.get_prompts_by_user_id(user.id, 'read', db=db) - tags = set() - for prompt in prompts: - if prompt.tags: - tags.update(prompt.tags) - return sorted(list(tags)) + return await Prompts.get_tags(db=db) + return await Prompts.get_tags_by_user_id(user.id, db=db) @router.get('/list', response_model=PromptAccessListResponse) @@ -77,7 +73,7 @@ async def get_prompt_list( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -97,7 +93,7 @@ async def get_prompt_list( filter['direction'] = direction # Pre-fetch user group IDs once - used for both filter and write_access check - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): @@ -106,11 +102,11 @@ async def get_prompt_list( filter['user_id'] = user.id - result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db) + result = await Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable prompt IDs in a single query instead of N has_access calls prompt_ids = [prompt.id for prompt in result.items] - writable_prompt_ids = AccessGrants.get_accessible_resource_ids( + writable_prompt_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='prompt', resource_ids=prompt_ids, @@ -145,16 +141,16 @@ async def create_new_prompt( request: Request, form_data: PromptForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not ( - has_permission( + await has_permission( user.id, 'workspace.prompts', request.app.state.config.USER_PERMISSIONS, db=db, ) - or has_permission( + or await has_permission( user.id, 'workspace.prompts_import', request.app.state.config.USER_PERMISSIONS, @@ -166,9 +162,17 @@ async def create_new_prompt( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - prompt = Prompts.get_prompt_by_command(form_data.command, db=db) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_prompts', + ) + + prompt = await Prompts.get_prompt_by_command(form_data.command, db=db) if prompt is None: - prompt = Prompts.insert_new_prompt(user.id, form_data, db=db) + prompt = await Prompts.insert_new_prompt(user.id, form_data, db=db) if prompt: return prompt @@ -188,14 +192,16 @@ async def create_new_prompt( @router.get('/command/{command}', response_model=Optional[PromptAccessResponse]) -async def get_prompt_by_command(command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - prompt = Prompts.get_prompt_by_command(command, db=db) +async def get_prompt_by_command( + command: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + prompt = await Prompts.get_prompt_by_command(command, db=db) if prompt: if ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -208,7 +214,7 @@ async def get_prompt_by_command(command: str, user=Depends(get_verified_user), d write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -230,14 +236,16 @@ async def get_prompt_by_command(command: str, user=Depends(get_verified_user), d @router.get('/id/{prompt_id}', response_model=Optional[PromptAccessResponse]) -async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) +async def get_prompt_by_id( + prompt_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if prompt: if ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -250,7 +258,7 @@ async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -273,12 +281,13 @@ async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: @router.post('/id/{prompt_id}/update', response_model=Optional[PromptModel]) async def update_prompt_by_id( + request: Request, prompt_id: str, form_data: PromptForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -289,7 +298,7 @@ async def update_prompt_by_id( # Is the user the original creator, in a group with write access, or an admin if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -305,15 +314,23 @@ async def update_prompt_by_id( # Check for command collision if command is being changed if form_data.command != prompt.command: - existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db) + existing_prompt = await Prompts.get_prompt_by_command(form_data.command, db=db) if existing_prompt and existing_prompt.id != prompt.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Command '/{form_data.command}' is already in use by another prompt", + detail=ERROR_MESSAGES.COMMAND_TAKEN, ) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_prompts', + ) + # Use the ID from the found prompt - updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db) + updated_prompt = await Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db) if updated_prompt: return updated_prompt else: @@ -333,10 +350,10 @@ async def update_prompt_metadata( prompt_id: str, form_data: PromptMetadataForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Update prompt name and command only (no history created).""" - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -346,7 +363,7 @@ async def update_prompt_metadata( if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -362,14 +379,16 @@ async def update_prompt_metadata( # Check for command collision if command is being changed if form_data.command != prompt.command: - existing_prompt = Prompts.get_prompt_by_command(form_data.command, db=db) + existing_prompt = await Prompts.get_prompt_by_command(form_data.command, db=db) if existing_prompt and existing_prompt.id != prompt.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Command '/{form_data.command}' is already in use", + detail=ERROR_MESSAGES.COMMAND_TAKEN, ) - updated_prompt = Prompts.update_prompt_metadata(prompt.id, form_data.name, form_data.command, form_data.tags, db=db) + updated_prompt = await Prompts.update_prompt_metadata( + prompt.id, form_data.name, form_data.command, form_data.tags, db=db + ) if updated_prompt: return updated_prompt else: @@ -384,9 +403,9 @@ async def set_prompt_version( prompt_id: str, form_data: PromptVersionUpdateForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -395,7 +414,7 @@ async def set_prompt_version( if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -409,7 +428,7 @@ async def set_prompt_version( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - updated_prompt = Prompts.update_prompt_version(prompt.id, form_data.version_id, db=db) + updated_prompt = await Prompts.update_prompt_version(prompt.id, form_data.version_id, db=db) if updated_prompt: return updated_prompt else: @@ -434,9 +453,9 @@ async def update_prompt_access_by_id( prompt_id: str, form_data: PromptAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -445,7 +464,7 @@ async def update_prompt_access_by_id( if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -459,7 +478,7 @@ async def update_prompt_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -467,9 +486,9 @@ async def update_prompt_access_by_id( 'sharing.public_prompts', ) - AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) - return Prompts.get_prompt_by_id(prompt_id, db=db) + return await Prompts.get_prompt_by_id(prompt_id, db=db) ############################ @@ -478,8 +497,10 @@ async def update_prompt_access_by_id( @router.post('/id/{prompt_id}/toggle', response_model=Optional[PromptModel]) -async def toggle_prompt_active(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) +async def toggle_prompt_active( + prompt_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -489,7 +510,7 @@ async def toggle_prompt_active(prompt_id: str, user=Depends(get_verified_user), if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -503,7 +524,7 @@ async def toggle_prompt_active(prompt_id: str, user=Depends(get_verified_user), detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Prompts.toggle_prompt_active(prompt.id, db=db) + result = await Prompts.toggle_prompt_active(prompt.id, db=db) if result: return result raise HTTPException( @@ -518,8 +539,10 @@ async def toggle_prompt_active(prompt_id: str, user=Depends(get_verified_user), @router.delete('/id/{prompt_id}/delete', response_model=bool) -async def delete_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) +async def delete_prompt_by_id( + prompt_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -529,7 +552,7 @@ async def delete_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), d if ( prompt.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -543,7 +566,7 @@ async def delete_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), d detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Prompts.delete_prompt_by_id(prompt.id, db=db) + result = await Prompts.delete_prompt_by_id(prompt.id, db=db) return result @@ -557,12 +580,12 @@ async def get_prompt_history( prompt_id: str, page: int = 0, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get version history for a prompt.""" PAGE_SIZE = 20 - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -574,7 +597,7 @@ async def get_prompt_history( if not ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -587,7 +610,7 @@ async def get_prompt_history( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - history = PromptHistories.get_history_by_prompt_id(prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db) + history = await PromptHistories.get_history_by_prompt_id(prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db) return history @@ -596,10 +619,10 @@ async def get_prompt_history_entry( prompt_id: str, history_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get a specific version from history.""" - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -611,7 +634,7 @@ async def get_prompt_history_entry( if not ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -624,7 +647,7 @@ async def get_prompt_history_entry( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - history_entry = PromptHistories.get_history_entry_by_id(history_id, db=db) + history_entry = await PromptHistories.get_history_entry_by_id(history_id, db=db) if not history_entry or history_entry.prompt_id != prompt.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -639,10 +662,10 @@ async def delete_prompt_history_entry( prompt_id: str, history_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Delete a history entry. Cannot delete the active production version.""" - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -654,7 +677,7 @@ async def delete_prompt_history_entry( if not ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -674,7 +697,7 @@ async def delete_prompt_history_entry( detail='Cannot delete the active production version', ) - success = PromptHistories.delete_history_entry(history_id, db=db) + success = await PromptHistories.delete_history_entry(history_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -690,10 +713,10 @@ async def get_prompt_diff( from_id: str, to_id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get diff between two versions.""" - prompt = Prompts.get_prompt_by_id(prompt_id, db=db) + prompt = await Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: raise HTTPException( @@ -705,7 +728,7 @@ async def get_prompt_diff( if not ( user.role == 'admin' or prompt.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='prompt', resource_id=prompt.id, @@ -718,11 +741,11 @@ async def get_prompt_diff( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - diff = PromptHistories.compute_diff(from_id, to_id, db=db) + diff = await PromptHistories.compute_diff(from_id, to_id, db=db) if not diff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='One or both history entries not found', + detail=ERROR_MESSAGES.NOT_FOUND, ) return diff diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 30b69ee0417..201e6a63fba 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -40,14 +40,15 @@ from open_webui.utils.access_control.files import has_access_to_file from open_webui.models.knowledge import Knowledges from open_webui.storage.provider import Storage -from open_webui.internal.db import get_session, get_db -from sqlalchemy.orm import Session +from open_webui.internal.db import get_async_db, get_async_session +from sqlalchemy.ext.asyncio import AsyncSession from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT # Document loaders -from open_webui.retrieval.loaders.main import Loader + from open_webui.retrieval.loaders.youtube import YoutubeLoader # Web search engines @@ -56,6 +57,7 @@ from open_webui.retrieval.web.ollama import search_ollama_cloud from open_webui.retrieval.web.perplexity_search import search_perplexity_search from open_webui.retrieval.web.brave import search_brave +from open_webui.retrieval.web.brave_llm_context import search_brave_llm_context from open_webui.retrieval.web.kagi import search_kagi from open_webui.retrieval.web.mojeek import search_mojeek from open_webui.retrieval.web.bocha import search_bocha @@ -81,6 +83,8 @@ from open_webui.retrieval.web.ydc import search_youcom from open_webui.retrieval.utils import ( + build_loader_from_config, + filter_accessible_collections, get_content_from_url, get_embedding_function, get_reranking_function, @@ -127,6 +131,8 @@ ########################################## # # Utility functions +# Give us this day our relevant chunks, and lead us +# not into hallucination, but deliver us from noise. # ########################################## @@ -149,7 +155,7 @@ def get_ef( model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS, ) except Exception as e: - log.debug(f'Error loading SentenceTransformer: {e}') + log.error(f'Error loading SentenceTransformer: {e}') return ef @@ -254,22 +260,6 @@ class SearchForm(BaseModel): queries: List[str] -@router.get('/') -async def get_status(request: Request): - return { - 'status': True, - 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, - 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, - 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, - 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, - 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, - 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, - 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, - 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, - } - - @router.get('/embedding') async def get_embedding_config(request: Request, user=Depends(get_admin_user)): return { @@ -345,7 +335,7 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd unload_embedding_model(request) try: request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE - request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL + request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL.strip() request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE request.app.state.config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS @@ -475,6 +465,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, + 'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL, + 'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN, # MinerU settings 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, @@ -484,6 +476,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): # Reranking settings 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, + 'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE, 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, @@ -523,6 +516,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, + 'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, @@ -577,9 +571,9 @@ class WebConfig(BaseModel): WEB_SEARCH_TRUST_ENV: Optional[bool] = None WEB_SEARCH_RESULT_COUNT: Optional[int] = None WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None + WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] WEB_FETCH_MAX_CONTENT_LENGTH: Optional[int] = None WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None - WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None @@ -591,6 +585,7 @@ class WebConfig(BaseModel): GOOGLE_PSE_API_KEY: Optional[str] = None GOOGLE_PSE_ENGINE_ID: Optional[str] = None BRAVE_SEARCH_API_KEY: Optional[str] = None + BRAVE_SEARCH_CONTEXT_TOKENS: Optional[int] = None KAGI_SEARCH_API_KEY: Optional[str] = None MOJEEK_SEARCH_API_KEY: Optional[str] = None BOCHA_SEARCH_API_KEY: Optional[str] = None @@ -680,6 +675,8 @@ class ConfigForm(BaseModel): DOCUMENT_INTELLIGENCE_MODEL: Optional[str] = None MISTRAL_OCR_API_BASE_URL: Optional[str] = None MISTRAL_OCR_API_KEY: Optional[str] = None + PADDLEOCR_VL_BASE_URL: Optional[str] = None + PADDLEOCR_VL_TOKEN: Optional[str] = None # MinerU settings MINERU_API_MODE: Optional[str] = None @@ -691,6 +688,7 @@ class ConfigForm(BaseModel): # Reranking settings RAG_RERANKING_MODEL: Optional[str] = None RAG_RERANKING_ENGINE: Optional[str] = None + RAG_RERANKING_BATCH_SIZE: Optional[int] = None RAG_EXTERNAL_RERANKER_URL: Optional[str] = None RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None @@ -880,6 +878,16 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend if form_data.MISTRAL_OCR_API_KEY is not None else request.app.state.config.MISTRAL_OCR_API_KEY ) + request.app.state.config.PADDLEOCR_VL_BASE_URL = ( + form_data.PADDLEOCR_VL_BASE_URL + if form_data.PADDLEOCR_VL_BASE_URL is not None + else request.app.state.config.PADDLEOCR_VL_BASE_URL + ) + request.app.state.config.PADDLEOCR_VL_TOKEN = ( + form_data.PADDLEOCR_VL_TOKEN + if form_data.PADDLEOCR_VL_TOKEN is not None + else request.app.state.config.PADDLEOCR_VL_TOKEN + ) # MinerU settings request.app.state.config.MINERU_API_MODE = ( @@ -937,6 +945,12 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT ) + request.app.state.config.RAG_RERANKING_BATCH_SIZE = ( + form_data.RAG_RERANKING_BATCH_SIZE + if form_data.RAG_RERANKING_BATCH_SIZE is not None + else request.app.state.config.RAG_RERANKING_BATCH_SIZE + ) + log.info( f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}' ) @@ -964,6 +978,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend request.app.state.config.RAG_RERANKING_ENGINE, request.app.state.config.RAG_RERANKING_MODEL, request.app.state.rf, + reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE, ) except Exception as e: log.error(f'Error loading reranking model: {e}') @@ -1053,6 +1068,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY request.app.state.config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID request.app.state.config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY + if form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS is not None: + request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY request.app.state.config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY request.app.state.config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY @@ -1138,6 +1155,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, + 'PADDLEOCR_VL_BASE_URL': request.app.state.config.PADDLEOCR_VL_BASE_URL, + 'PADDLEOCR_VL_TOKEN': request.app.state.config.PADDLEOCR_VL_TOKEN, # MinerU settings 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, @@ -1172,7 +1191,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - 'FETCH_URL_MAX_CONTENT_LENGTH': request.app.state.config.FETCH_URL_MAX_CONTENT_LENGTH, + 'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH, 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, @@ -1186,6 +1205,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, + 'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, @@ -1524,11 +1544,11 @@ class ProcessFileForm(BaseModel): @router.post('/process/file') -def process_file( +async def process_file( request: Request, form_data: ProcessFileForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """ Process a file and save its content to the vector database. @@ -1537,9 +1557,9 @@ def process_file( The session is committed before external API calls, and updates use a fresh session. """ if user.role == 'admin': - file = Files.get_file_by_id(form_data.file_id, db=db) + file = await Files.get_file_by_id(form_data.file_id, db=db) else: - file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db) + file = await Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db) if file: try: @@ -1547,6 +1567,8 @@ def process_file( if collection_name is None: collection_name = f'file-{file.id}' + else: + await _validate_collection_access([collection_name], user, access_type='write') if form_data.content: # Update the content in the file @@ -1554,7 +1576,7 @@ def process_file( try: # /files/{file_id}/data/content/update - VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') + await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') except Exception: # Audio file upload pipeline pass @@ -1577,7 +1599,9 @@ def process_file( # Check if the file has already been processed and save the content # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update - result = VECTOR_DB_CLIENT.query(collection_name=f'file-{file.id}', filter={'file_id': file.id}) + result = await ASYNC_VECTOR_DB_CLIENT.query( + collection_name=f'file-{file.id}', filter={'file_id': file.id} + ) if result is not None and len(result.ids[0]) > 0: docs = [ @@ -1607,41 +1631,10 @@ def process_file( # Usage: /files/ file_path = file.path if file_path: - file_path = Storage.get_file(file_path) - loader = Loader( - engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, - user=user, - DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY, - DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL, - DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, - DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE, - DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR, - DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE, - DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, - DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES, - DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM, - DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, - EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, - EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, - DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, - DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY, - DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS, - PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, - PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE, - DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, - DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, - DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, - MISTRAL_OCR_API_BASE_URL=request.app.state.config.MISTRAL_OCR_API_BASE_URL, - MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY, - MINERU_API_MODE=request.app.state.config.MINERU_API_MODE, - MINERU_API_URL=request.app.state.config.MINERU_API_URL, - MINERU_API_KEY=request.app.state.config.MINERU_API_KEY, - MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT, - MINERU_PARAMS=request.app.state.config.MINERU_PARAMS, - ) - docs = loader.load(file.filename, file.meta.get('content_type'), file_path) + file_path = await asyncio.to_thread(Storage.get_file, file_path) + loader = build_loader_from_config(request) + loader.user = user + docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path) docs = [ Document( @@ -1672,7 +1665,7 @@ def process_file( text_content = ' '.join([doc.page_content for doc in docs]) log.debug(f'text_content: {text_content}') - Files.update_file_data_by_id( + await Files.update_file_data_by_id( file.id, {'content': text_content}, db=db, @@ -1680,8 +1673,8 @@ def process_file( hash = calculate_sha256_string(text_content) if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: - Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db) - Files.update_file_hash_by_id(file.id, hash, db=db) + await Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db) + await Files.update_file_hash_by_id(file.id, hash, db=db) return { 'status': True, 'collection_name': None, @@ -1692,11 +1685,16 @@ def process_file( try: # Commit any pending changes before the slow embedding step. # Note: file is already a Pydantic model (not ORM), so no expunge needed. - db.commit() + await db.commit() # External embedding API takes time (5-60s+). - # Subsequent updates use fresh sessions via get_db(). - result = save_docs_to_vector_db( + # Subsequent updates use fresh async sessions. + # NOTE: save_docs_to_vector_db is a sync function that + # calls asyncio.run_coroutine_threadsafe(..., main_loop).result() + # which blocks the calling thread. We MUST run it in a + # worker thread to avoid deadlocking the event loop. + result = await run_in_threadpool( + save_docs_to_vector_db, request, docs=docs, collection_name=collection_name, @@ -1712,8 +1710,8 @@ def process_file( if result: # Fresh session for the final update. - with get_db() as session: - Files.update_file_metadata_by_id( + async with get_async_db() as session: + await Files.update_file_metadata_by_id( file.id, { 'collection_name': collection_name, @@ -1721,12 +1719,12 @@ def process_file( db=session, ) - Files.update_file_data_by_id( + await Files.update_file_data_by_id( file.id, {'status': 'completed'}, db=session, ) - Files.update_file_hash_by_id(file.id, hash, db=session) + await Files.update_file_hash_by_id(file.id, hash, db=session) return { 'status': True, @@ -1742,14 +1740,14 @@ def process_file( except Exception as e: log.exception(e) # Fresh session for error status update. - with get_db() as session: - Files.update_file_data_by_id( + async with get_async_db() as session: + await Files.update_file_data_by_id( file.id, {'status': 'failed'}, db=session, ) # Clear the hash so the file can be re-uploaded after fixing the issue - Files.update_file_hash_by_id(file.id, None, db=session) + await Files.update_file_hash_by_id(file.id, None, db=session) if 'No pandoc was found' in str(e): raise HTTPException( @@ -1781,6 +1779,8 @@ async def process_text( collection_name = form_data.collection_name if collection_name is None: collection_name = calculate_sha256_string(form_data.content) + else: + await _validate_collection_access([collection_name], user, access_type='write') docs = [ Document( @@ -1822,6 +1822,8 @@ async def process_web( collection_name = form_data.collection_name if not collection_name: collection_name = calculate_sha256_string(form_data.url)[:63] + else: + await _validate_collection_access([collection_name], user, access_type='write') if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: await run_in_threadpool( @@ -1953,6 +1955,17 @@ def search_web(request: Request, engine: str, query: str, user=None) -> list[Sea ) else: raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables') + elif engine == 'brave_llm_context': + if request.app.state.config.BRAVE_SEARCH_API_KEY: + return search_brave_llm_context( + request.app.state.config.BRAVE_SEARCH_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS, + ) + else: + raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables') elif engine == 'kagi': if request.app.state.config.KAGI_SEARCH_API_KEY: return search_kagi( @@ -2173,7 +2186,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'features.web_search', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( @@ -2325,32 +2338,20 @@ async def search_query_with_semaphore(query): ) -def _validate_collection_access(collection_names: list[str], user) -> None: +async def _validate_collection_access(collection_names: list[str], user, access_type: str = 'read') -> None: """ - Prevent users from querying collections they don't own. - Enforces ownership on user-memory-* and file-* collections. - Admins bypass this check. + Raise 403 if the user lacks access to any of the requested collections. + Delegates to the shared filter_accessible_collections utility so the + access rules stay in one place. """ - if user.role == 'admin': - return - - for name in collection_names: - if name.startswith('user-memory-') and name != f'user-memory-{user.id}': - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - elif name.startswith('file-'): - file_id = name[len('file-') :] - if not has_access_to_file( - file_id=file_id, - access_type='read', - user=user, - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) + requested = set(collection_names) + allowed = await filter_accessible_collections(requested, user, access_type=access_type) + denied = requested - allowed + if denied: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) class QueryDocForm(BaseModel): @@ -2368,12 +2369,12 @@ async def query_doc_handler( form_data: QueryDocForm, user=Depends(get_verified_user), ): - _validate_collection_access([form_data.collection_name], user) + await _validate_collection_access([form_data.collection_name], user) try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): collection_results = {} - collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( + collection_results[form_data.collection_name] = await ASYNC_VECTOR_DB_CLIENT.get( collection_name=form_data.collection_name ) return await query_doc_with_hybrid_search( @@ -2402,7 +2403,10 @@ async def query_doc_handler( query_embedding = await request.app.state.EMBEDDING_FUNCTION( form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user ) - return query_doc( + # query_doc wraps a blocking VECTOR_DB_CLIENT.search call; + # offload so the request's event loop stays responsive. + return await asyncio.to_thread( + query_doc, collection_name=form_data.collection_name, query_embedding=query_embedding, k=form_data.k if form_data.k else request.app.state.config.TOP_K, @@ -2433,7 +2437,7 @@ async def query_collection_handler( form_data: QueryCollectionsForm, user=Depends(get_verified_user), ): - _validate_collection_access(form_data.collection_names, user) + await _validate_collection_access(form_data.collection_names, user) try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): @@ -2464,6 +2468,7 @@ async def query_collection_handler( ) else: return await query_collection( + request, collection_names=form_data.collection_names, queries=[form_data.query], embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( @@ -2493,14 +2498,14 @@ class DeleteForm(BaseModel): @router.post('/delete') -def delete_entries_from_collection( +async def delete_entries_from_collection( form_data: DeleteForm, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): try: - if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): - file = Files.get_file_by_id(form_data.file_id, db=db) + if await ASYNC_VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): + file = await Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -2508,26 +2513,50 @@ def delete_entries_from_collection( ) hash = file.hash - VECTOR_DB_CLIENT.delete( + # Refuse to issue a `filter={'hash': None}` query — the + # match semantics of a null filter value are + # backend-dependent (some backends ignore the key, some + # match every row whose metadata lacks `hash`) and risk + # deleting unrelated entries. Files without a hash are + # typically unprocessed / failed / legacy records that + # can't be targeted by hash anyway. + if hash is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('File has no hash; cannot delete vector entries by hash.'), + ) + + # Pre-existing bug: this used `metadata=` which is not a + # parameter on `VectorDBBase.delete` nor on any backend + # implementation, so the call always raised TypeError that + # was silently swallowed by the surrounding `except + # Exception` and the endpoint reported `{'status': False}` + # for every request. Use `filter` to actually do what the + # endpoint name promises. + await ASYNC_VECTOR_DB_CLIENT.delete( collection_name=form_data.collection_name, - metadata={'hash': hash}, + filter={'hash': hash}, ) return {'status': True} else: return {'status': False} + except HTTPException: + # Caller-meaningful errors (404/400 above) must not be + # swallowed and re-shaped as `{'status': False}`. + raise except Exception as e: log.exception(e) return {'status': False} @router.post('/reset/db') -def reset_vector_db(user=Depends(get_admin_user), db: Session = Depends(get_session)): - VECTOR_DB_CLIENT.reset() - Knowledges.delete_all_knowledge(db=db) +async def reset_vector_db(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): + await ASYNC_VECTOR_DB_CLIENT.reset() + await Knowledges.delete_all_knowledge(db=db) @router.post('/reset/uploads') -def reset_upload_dir(user=Depends(get_admin_user)) -> bool: +async def reset_upload_dir(user=Depends(get_admin_user)) -> bool: folder = f'{UPLOAD_DIR}' try: # Check if the directory exists @@ -2577,11 +2606,12 @@ async def process_files_batch( request: Request, form_data: BatchProcessFilesForm, user=Depends(get_verified_user), + db=None, ) -> BatchProcessFilesResponse: """ Process a batch of files and save them to the vector database. - NOTE: We intentionally do NOT use Depends(get_session) here. + NOTE: We intentionally do NOT use Depends(get_async_session) here. The save_docs_to_vector_db() call makes external embedding API calls which can take 5-60+ seconds for batch operations. Database operations after embedding (Files.update_file_by_id) manage their own short-lived sessions. @@ -2589,6 +2619,9 @@ async def process_files_batch( collection_name = form_data.collection_name + if collection_name: + await _validate_collection_access([collection_name], user, access_type='write') + file_results: List[BatchProcessFilesResult] = [] file_errors: List[BatchProcessFilesResult] = [] file_updates: List[FileUpdateForm] = [] @@ -2599,7 +2632,7 @@ async def process_files_batch( for file in form_data.files: try: # Ownership check: verify the requesting user owns the file or is an admin - db_file = Files.get_file_by_id(file.id) + db_file = await Files.get_file_by_id(file.id, db=db) if not db_file: file_errors.append( BatchProcessFilesResult( @@ -2661,7 +2694,7 @@ async def process_files_batch( # Update all files with collection name for file_update, file_result in zip(file_updates, file_results): - Files.update_file_by_id(id=file_result.file_id, form_data=file_update) + await Files.update_file_by_id(id=file_result.file_id, form_data=file_update, db=db) file_result.status = 'completed' except Exception as e: diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index ed721ce335d..75f45bcaf9a 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -5,6 +5,7 @@ NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change. """ +import hmac import logging import uuid import time @@ -29,8 +30,8 @@ from open_webui.env import SCIM_AUTH_PROVIDER -from sqlalchemy.orm import Session -from open_webui.internal.db import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import get_async_session log = logging.getLogger(__name__) @@ -278,7 +279,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) if hasattr(scim_token, 'value'): scim_token = scim_token.value log.debug(f'SCIM token configured: {bool(scim_token)}') - if not scim_token or token != scim_token: + if not scim_token or not hmac.compare_digest(token, scim_token): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid SCIM token', @@ -325,18 +326,18 @@ def get_scim_provider() -> str: return SCIM_AUTH_PROVIDER -def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]: +async def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]: """Find a user by SCIM externalId, falling back to OAuth sub match.""" provider = get_scim_provider() - user = Users.get_user_by_scim_external_id(provider, external_id, db=db) + user = await Users.get_user_by_scim_external_id(provider, external_id, db=db) if user: return user # Fallback: check if externalId matches an existing OAuth sub (account linking) - return Users.get_user_by_oauth_sub(provider, external_id, db=db) + return await Users.get_user_by_oauth_sub(provider, external_id, db=db) -def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: +async def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components name_parts = user.name.split(' ', 1) if user.name else ['', ''] @@ -344,7 +345,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: family_name = name_parts[1] if len(name_parts) > 1 else '' # Get user's groups - user_groups = Groups.get_groups_by_member_id(user.id, db=db) + user_groups = await Groups.get_groups_by_member_id(user.id, db=db) groups = [ { 'value': group.id, @@ -378,12 +379,12 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: ) -def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: +async def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: """Convert internal Group model to SCIM Group""" - member_ids = Groups.get_group_user_ids_by_id(group.id, db) or [] + member_ids = await Groups.get_group_user_ids_by_id(group.id, db) or [] # Batch-fetch all users to avoid N+1 queries - users = Users.get_users_by_user_ids(member_ids, db=db) if member_ids else [] + users = await Users.get_users_by_user_ids(member_ids, db=db) if member_ids else [] members = [ SCIMGroupMember( value=user.id, @@ -511,7 +512,7 @@ async def get_users( count: int = Query(20), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """List SCIM Users""" # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): @@ -526,25 +527,25 @@ async def get_users( # Simple filter parsing - supports userName eq, externalId eq if 'userName eq' in filter: email = filter.split('"')[1] - user = Users.get_user_by_email(email, db=db) + user = await Users.get_user_by_email(email, db=db) users_list = [user] if user else [] total = 1 if user else 0 elif 'externalId eq' in filter: external_id = filter.split('"')[1] - user = find_user_by_external_id(external_id, db=db) + user = await find_user_by_external_id(external_id, db=db) users_list = [user] if user else [] total = 1 if user else 0 else: - response = Users.get_users(skip=skip, limit=limit, db=db) + response = await Users.get_users(skip=skip, limit=limit, db=db) users_list = response['users'] total = response['total'] else: - response = Users.get_users(skip=skip, limit=limit, db=db) + response = await Users.get_users(skip=skip, limit=limit, db=db) users_list = response['users'] total = response['total'] # Convert to SCIM format - scim_users = [user_to_scim(user, request, db=db) for user in users_list] + scim_users = [await user_to_scim(user, request, db=db) for user in users_list] return SCIMListResponse( totalResults=total, @@ -559,14 +560,14 @@ async def get_user( user_id: str, request: Request, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get SCIM User by ID""" - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if not user: return scim_error(status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found') - return user_to_scim(user, request, db=db) + return await user_to_scim(user, request, db=db) @router.post('/Users', response_model=SCIMUser, status_code=status.HTTP_201_CREATED) @@ -574,12 +575,12 @@ async def create_user( request: Request, user_data: SCIMUserCreateRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Create SCIM User""" # Check for duplicate by externalId if user_data.externalId: - existing_user = find_user_by_external_id(user_data.externalId, db=db) + existing_user = await find_user_by_external_id(user_data.externalId, db=db) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -595,7 +596,7 @@ async def create_user( email = email.lower() # Check for duplicate by email - existing_user = Users.get_user_by_email(email, db=db) + existing_user = await Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -618,7 +619,7 @@ async def create_user( if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value - new_user = Users.insert_new_user( + new_user = await Users.insert_new_user( id=user_id, name=name, email=email, @@ -636,10 +637,10 @@ async def create_user( # Store externalId in the scim field if user_data.externalId: provider = get_scim_provider() - Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) - new_user = Users.get_user_by_id(user_id, db=db) + await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) + new_user = await Users.get_user_by_id(user_id, db=db) - return user_to_scim(new_user, request, db=db) + return await user_to_scim(new_user, request, db=db) @router.put('/Users/{user_id}', response_model=SCIMUser) @@ -648,10 +649,10 @@ async def update_user( request: Request, user_data: SCIMUserUpdateRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Update SCIM User (full update)""" - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -681,7 +682,7 @@ async def update_user( if user_data.photos and len(user_data.photos) > 0: update_data['profile_image_url'] = user_data.photos[0].value - updated_user = Users.update_user_by_id(user_id, update_data, db=db) + updated_user = await Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -691,10 +692,10 @@ async def update_user( # Update externalId in the scim field if user_data.externalId: provider = get_scim_provider() - Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) - updated_user = Users.get_user_by_id(user_id, db=db) + await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) + updated_user = await Users.get_user_by_id(user_id, db=db) - return user_to_scim(updated_user, request, db=db) + return await user_to_scim(updated_user, request, db=db) @router.patch('/Users/{user_id}', response_model=SCIMUser) @@ -703,10 +704,10 @@ async def patch_user( request: Request, patch_data: SCIMPatchRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Update SCIM User (partial update)""" - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -733,11 +734,11 @@ async def patch_user( update_data['name'] = value elif path == 'externalId': provider = get_scim_provider() - Users.update_user_scim_by_id(user_id, provider, value, db=db) + await Users.update_user_scim_by_id(user_id, provider, value, db=db) # Update user if update_data: - updated_user = Users.update_user_by_id(user_id, update_data, db=db) + updated_user = await Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -746,7 +747,7 @@ async def patch_user( else: updated_user = user - return user_to_scim(updated_user, request, db=db) + return await user_to_scim(updated_user, request, db=db) @router.delete('/Users/{user_id}', status_code=status.HTTP_204_NO_CONTENT) @@ -754,17 +755,17 @@ async def delete_user( user_id: str, request: Request, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Delete SCIM User""" - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found', ) - success = Users.delete_user_by_id(user_id, db=db) + success = await Users.delete_user_by_id(user_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -782,7 +783,7 @@ async def get_groups( count: int = Query(20), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """List SCIM Groups""" # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): @@ -790,8 +791,17 @@ async def get_groups( startIndex = max(1, startIndex) count = max(0, min(100, count)) - # Get all groups - groups_list = Groups.get_all_groups(db=db) + # Get groups, applying filter if provided + if filter: + if 'displayName eq' in filter: + display_name = filter.split('"')[1] + group = await Groups.get_group_by_name(display_name, db=db) + groups_list = [group] if group else [] + else: + # Unrecognized filter — fall back to all groups + groups_list = await Groups.get_all_groups(db=db) + else: + groups_list = await Groups.get_all_groups(db=db) # Apply pagination total = len(groups_list) @@ -800,7 +810,7 @@ async def get_groups( paginated_groups = groups_list[start:end] # Convert to SCIM format - scim_groups = [group_to_scim(group, request, db=db) for group in paginated_groups] + scim_groups = [await group_to_scim(group, request, db=db) for group in paginated_groups] return SCIMListResponse( totalResults=total, @@ -815,17 +825,17 @@ async def get_group( group_id: str, request: Request, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Get SCIM Group by ID""" - group = Groups.get_group_by_id(group_id, db=db) + group = await Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f'Group {group_id} not found', ) - return group_to_scim(group, request, db=db) + return await group_to_scim(group, request, db=db) @router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) @@ -833,7 +843,7 @@ async def create_group( request: Request, group_data: SCIMGroupCreateRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Create SCIM Group""" # Extract member IDs @@ -851,14 +861,14 @@ async def create_group( ) # Need to get the creating user's ID - we'll use the first admin - admin_user = Users.get_super_admin_user(db=db) + admin_user = await Users.get_super_admin_user(db=db) if not admin_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='No admin user found', ) - new_group = Groups.insert_new_group(admin_user.id, form, db=db) + new_group = await Groups.insert_new_group(admin_user.id, form, db=db) if not new_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -874,12 +884,12 @@ async def create_group( description=new_group.description, ) - Groups.update_group_by_id(new_group.id, update_form, db=db) - Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db) + await Groups.update_group_by_id(new_group.id, update_form, db=db) + await Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db) - new_group = Groups.get_group_by_id(new_group.id, db=db) + new_group = await Groups.get_group_by_id(new_group.id, db=db) - return group_to_scim(new_group, request, db=db) + return await group_to_scim(new_group, request, db=db) @router.put('/Groups/{group_id}', response_model=SCIMGroup) @@ -888,10 +898,10 @@ async def update_group( request: Request, group_data: SCIMGroupUpdateRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Update SCIM Group (full update)""" - group = Groups.get_group_by_id(group_id, db=db) + group = await Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -909,17 +919,17 @@ async def update_group( # Handle members if provided if group_data.members is not None: member_ids = [member.value for member in group_data.members] - Groups.set_group_user_ids_by_id(group_id, member_ids, db=db) + await Groups.set_group_user_ids_by_id(group_id, member_ids, db=db) # Update group - updated_group = Groups.update_group_by_id(group_id, update_form, db=db) + updated_group = await Groups.update_group_by_id(group_id, update_form, db=db) if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to update group', ) - return group_to_scim(updated_group, request, db=db) + return await group_to_scim(updated_group, request, db=db) @router.patch('/Groups/{group_id}', response_model=SCIMGroup) @@ -928,10 +938,10 @@ async def patch_group( request: Request, patch_data: SCIMPatchRequest, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Update SCIM Group (partial update)""" - group = Groups.get_group_by_id(group_id, db=db) + group = await Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -955,7 +965,7 @@ async def patch_group( update_form.name = value elif path == 'members': # Replace all members - Groups.set_group_user_ids_by_id(group_id, [member['value'] for member in value], db=db) + await Groups.set_group_user_ids_by_id(group_id, [member['value'] for member in value], db=db) elif op == 'add': if path == 'members': @@ -963,22 +973,22 @@ async def patch_group( if isinstance(value, list): for member in value: if isinstance(member, dict) and 'value' in member: - Groups.add_users_to_group(group_id, [member['value']], db=db) + await Groups.add_users_to_group(group_id, [member['value']], db=db) elif op == 'remove': if path and path.startswith('members[value eq'): # Remove specific member member_id = path.split('"')[1] - Groups.remove_users_from_group(group_id, [member_id], db=db) + await Groups.remove_users_from_group(group_id, [member_id], db=db) # Update group - updated_group = Groups.update_group_by_id(group_id, update_form, db=db) + updated_group = await Groups.update_group_by_id(group_id, update_form, db=db) if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to update group', ) - return group_to_scim(updated_group, request, db=db) + return await group_to_scim(updated_group, request, db=db) @router.delete('/Groups/{group_id}', status_code=status.HTTP_204_NO_CONTENT) @@ -986,17 +996,17 @@ async def delete_group( group_id: str, request: Request, _: bool = Depends(get_scim_auth), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): """Delete SCIM Group""" - group = Groups.get_group_by_id(group_id, db=db) + group = await Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f'Group {group_id} not found', ) - success = Groups.delete_group_by_id(group_id, db=db) + success = await Groups.delete_group_by_id(group_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 1838914e4a9..ede5afd8141 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -5,9 +5,9 @@ from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession -from open_webui.internal.db import get_session +from open_webui.internal.db import get_async_session from open_webui.models.skills import ( SkillForm, SkillModel, @@ -40,18 +40,18 @@ async def get_skills( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - skills = Skills.get_skills(db=db) + skills = await Skills.get_skills(db=db) else: - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} - all_skills = Skills.get_skills(db=db) + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + all_skills = await Skills.get_skills(db=db) skills = [ skill for skill in all_skills if skill.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -75,7 +75,7 @@ async def get_skill_list( view_option: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -89,13 +89,13 @@ async def get_skill_list( filter['view_option'] = view_option if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): - groups = Groups.get_groups_by_member_id(user.id, db=db) + groups = await Groups.get_groups_by_member_id(user.id, db=db) if groups: filter['group_ids'] = [group.id for group in groups] filter['user_id'] = user.id - result = Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db) + result = await Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db) return SkillAccessListResponse( items=[ @@ -104,7 +104,7 @@ async def get_skill_list( write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == skill.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -128,9 +128,9 @@ async def get_skill_list( async def export_skills( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, @@ -142,9 +142,9 @@ async def export_skills( ) if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return Skills.get_skills(db=db) + return await Skills.get_skills(db=db) else: - return Skills.get_skills_by_user_id(user.id, 'read', db=db) + return await Skills.get_skills_by_user_id(user.id, 'read', db=db) ############################ @@ -157,9 +157,9 @@ async def create_new_skill( request: Request, form_data: SkillForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( @@ -169,15 +169,28 @@ async def create_new_skill( form_data.id = form_data.id.lower().replace(' ', '-') - existing = Skills.get_skill_by_id(form_data.id, db=db) + existing = await Skills.get_skill_by_id(form_data.id, db=db) if existing is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ID_TAKEN, ) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/calendar pattern). Without this, a user with + # workspace.skills permission could attach principal_id='*' read/write + # grants in the create payload, bypassing the sharing.public_skills gate + # that the dedicated /access/update endpoint already enforces. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_skills', + ) + try: - skill = Skills.insert_new_skill(user.id, form_data, db=db) + skill = await Skills.insert_new_skill(user.id, form_data, db=db) if skill: return skill else: @@ -199,14 +212,14 @@ async def create_new_skill( @router.get('/id/{id}', response_model=Optional[SkillAccessResponse]) -async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - skill = Skills.get_skill_by_id(id, db=db) +async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + skill = await Skills.get_skill_by_id(id, db=db) if skill: if ( user.role == 'admin' or skill.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -219,7 +232,7 @@ async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: Session write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == skill.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -251,9 +264,9 @@ async def update_skill_by_id( id: str, form_data: SkillForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - skill = Skills.get_skill_by_id(id, db=db) + skill = await Skills.get_skill_by_id(id, db=db) if not skill: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -262,7 +275,7 @@ async def update_skill_by_id( if ( skill.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -276,12 +289,25 @@ async def update_skill_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public/user grants the requesting user is not permitted to assign + # (matches the channel/notes/calendar pattern). The access check above only + # restricts WHO can write to the skill; this filter restricts WHICH grants + # they may set, so a non-admin owner cannot make their own skill publicly + # readable/writable without sharing.public_skills permission. + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_skills', + ) + try: updated = { **form_data.model_dump(exclude={'id'}), } - skill = Skills.update_skill_by_id(id, updated, db=db) + skill = await Skills.update_skill_by_id(id, updated, db=db) if skill: return skill @@ -312,9 +338,9 @@ async def update_skill_access_by_id( id: str, form_data: SkillAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - skill = Skills.get_skill_by_id(id, db=db) + skill = await Skills.get_skill_by_id(id, db=db) if not skill: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -323,7 +349,7 @@ async def update_skill_access_by_id( if ( skill.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -337,7 +363,7 @@ async def update_skill_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -345,9 +371,9 @@ async def update_skill_access_by_id( 'sharing.public_skills', ) - AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db) - return Skills.get_skill_by_id(id, db=db) + return await Skills.get_skill_by_id(id, db=db) ############################ @@ -356,13 +382,13 @@ async def update_skill_access_by_id( @router.post('/id/{id}/toggle', response_model=Optional[SkillModel]) -async def toggle_skill_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - skill = Skills.get_skill_by_id(id, db=db) +async def toggle_skill_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + skill = await Skills.get_skill_by_id(id, db=db) if skill: if ( user.role == 'admin' or skill.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -370,7 +396,7 @@ async def toggle_skill_by_id(id: str, user=Depends(get_verified_user), db: Sessi db=db, ) ): - skill = Skills.toggle_skill_by_id(id, db=db) + skill = await Skills.toggle_skill_by_id(id, db=db) if skill: return skill @@ -401,9 +427,9 @@ async def delete_skill_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - skill = Skills.get_skill_by_id(id, db=db) + skill = await Skills.get_skill_by_id(id, db=db) if not skill: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -412,7 +438,7 @@ async def delete_skill_by_id( if ( skill.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='skill', resource_id=skill.id, @@ -426,5 +452,5 @@ async def delete_skill_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - result = Skills.delete_skill_by_id(id, db=db) + result = await Skills.delete_skill_by_id(id, db=db) return result diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 0bb5813e6f1..1c4c8c08c62 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -16,9 +16,10 @@ tags_generation_template, emoji_generation_template, moa_response_generation_template, + model_recommendation_template, ) from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.constants import TASKS +from open_webui.constants import ERROR_MESSAGES, TASKS from open_webui.routers.pipelines import process_pipeline_inlet_filter @@ -79,6 +80,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)): 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + 'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT, 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, } @@ -99,6 +101,7 @@ class TaskConfigForm(BaseModel): ENABLE_RETRIEVAL_QUERY_GENERATION: bool QUERY_GENERATION_PROMPT_TEMPLATE: str TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str + ENABLE_VOICE_MODE_PROMPT: bool VOICE_MODE_PROMPT_TEMPLATE: Optional[str] @@ -127,6 +130,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE return { @@ -145,6 +149,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + 'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT, 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, } @@ -159,6 +164,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -168,7 +174,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -187,7 +193,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE - content = title_generation_template(template, form_data['messages'], user) + content = await title_generation_template(template, form_data['messages'], user) max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) @@ -236,6 +242,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -245,7 +252,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -264,7 +271,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge else: template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE - content = follow_up_generation_template(template, form_data['messages'], user) + content = await follow_up_generation_template(template, form_data['messages'], user) payload = { 'model': task_model_id, @@ -304,6 +311,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -313,7 +321,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -332,7 +340,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get else: template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE - content = tags_generation_template(template, form_data['messages'], user) + content = await tags_generation_template(template, form_data['messages'], user) payload = { 'model': task_model_id, @@ -366,6 +374,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get async def generate_image_prompt(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -375,7 +384,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -394,7 +403,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends( else: template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE - content = image_prompt_generation_template(template, form_data['messages'], user) + content = await image_prompt_generation_template(template, form_data['messages'], user) payload = { 'model': task_model_id, @@ -431,13 +440,13 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Search query generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Search query generation'), ) elif type == 'retrieval': if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Query generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Query generation'), ) if getattr(request.state, 'cached_queries', None): @@ -446,6 +455,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -455,7 +465,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -474,7 +484,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v else: template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE - content = query_generation_template(template, form_data['messages'], user) + content = await query_generation_template(template, form_data['messages'], user) payload = { 'model': task_model_id, @@ -508,7 +518,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Autocompletion generation is disabled', + detail=ERROR_MESSAGES.FEATURE_DISABLED('Autocompletion generation'), ) type = form_data.get('type') @@ -519,11 +529,12 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}', + detail=ERROR_MESSAGES.INPUT_TOO_LONG(request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH), ) if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -533,7 +544,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -552,7 +563,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend else: template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE - content = autocomplete_generation_template(template, prompt, messages, type, user) + content = await autocomplete_generation_template(template, prompt, messages, type, user) payload = { 'model': task_model_id, @@ -586,6 +597,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend async def generate_emoji(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -595,7 +607,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) # Check if the user has a custom task model @@ -611,7 +623,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE - content = emoji_generation_template(template, form_data['prompt'], user) + content = await emoji_generation_template(template, form_data['prompt'], user) payload = { 'model': task_model_id, @@ -651,6 +663,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver async def generate_moa_response(request: Request, form_data: dict, user=Depends(get_verified_user)): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: @@ -661,7 +674,7 @@ async def generate_moa_response(request: Request, form_data: dict, user=Depends( if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail='Model not found', + detail=ERROR_MESSAGES.MODEL_NOT_FOUND(), ) template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE @@ -697,3 +710,93 @@ async def generate_moa_response(request: Request, form_data: dict, user=Depends( status_code=status.HTTP_400_BAD_REQUEST, content={'detail': str(e)}, ) + + +DEFAULT_MODEL_RECOMMENDATION_PROMPT_TEMPLATE = """Task: Given a user's description and a list of available models, return JSON recommending 1-3 models. + +User wants to: {{TASK_DESCRIPTION}} + +Available models: +{{MODELS_LIST}} + +Rules: +1. Models with type "custom_model_or_pipe" are pipes to external providers (e.g. Google Gemini, Anthropic Claude). Check their name and base_model_id to identify the provider. +2. Match task to model strengths. Coding -> code models. Images -> image generation models. Video -> video models. Writing -> large general models. +3. Prefer models whose name, description, or capabilities explicitly match the task. +4. Use EXACT model IDs from the list. Do not invent IDs. +5. Return 1-3 recommendations, best first. + +Return ONLY this JSON, no markdown, no explanation, no extra text before or after: +{"recommendations":[{"model_id":"exact_id","reason":"one sentence"}]} + +Example valid response: +{"recommendations":[{"model_id":"gpt-4o","reason":"Strong general-purpose model with vision capabilities"}]} + +BEGIN JSON RESPONSE:""" + + +@router.post("/model_recommendation/completions") +async def generate_model_recommendation( + request: Request, form_data: dict, user=Depends(get_verified_user) +): + models = request.app.state.MODELS + + model_id = form_data["model"] + if model_id not in models: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + task_model_id = get_task_model_id( + model_id, + request.app.state.config.TASK_MODEL, + request.app.state.config.TASK_MODEL_EXTERNAL, + models, + ) + + log.debug( + f"generating model recommendation using model {task_model_id} for user {user.email}" + ) + + task_description = form_data.get("task_description", "") + available_models = form_data.get("available_models", []) + + models_info_lines = [] + for m in available_models: + parts = [f"ID: {m['id']}"] + for key in ["name", "description", "owned_by", "type", "base_model_id", "capabilities", "system_prompt_hint"]: + if m.get(key): + parts.append(f"{key}: {m[key]}") + models_info_lines.append("- " + ", ".join(parts)) + models_info = "\n".join(models_info_lines) + + template = DEFAULT_MODEL_RECOMMENDATION_PROMPT_TEMPLATE + content = await model_recommendation_template( + template, task_description, models_info, user + ) + + payload = { + "model": task_model_id, + "messages": [{"role": "user", "content": content}], + "stream": False, + "metadata": { + **(request.state.metadata if hasattr(request.state, "metadata") else {}), + "task": str(TASKS.MODEL_RECOMMENDATION), + "task_body": form_data, + }, + } + + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + + try: + return await generate_chat_completion(request, form_data=payload, user=user) + except Exception as e: + log.error("Exception occurred", exc_info=True) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": "An internal error has occurred."}, + ) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 49c39c8bf76..c251b20d48d 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -16,6 +16,8 @@ from open_webui.utils.auth import get_verified_user from open_webui.utils.access_control import has_connection_access +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.config import TERMINAL_PROXY_HEADERS from open_webui.models.groups import Groups from open_webui.models.users import Users @@ -31,14 +33,20 @@ def _sanitize_proxy_path(path: str) -> str | None: """Sanitize a proxy path to prevent directory traversal / SSRF. Returns the cleaned path, or None if the path is invalid. + Trailing slashes are preserved — many upstream frameworks treat + ``/path`` and ``/path/`` differently. """ decoded = unquote(path) + had_trailing_slash = decoded.endswith('/') normalized = posixpath.normpath(decoded) # Remove any leading slashes that would reset the base cleaned = normalized.lstrip('/') # Reject if normpath resolved to parent traversal or current-dir only if cleaned.startswith('..') or cleaned == '.': return None + # Restore trailing slash if the original path had one + if had_trailing_slash and cleaned and not cleaned.endswith('/'): + cleaned += '/' return cleaned @@ -46,7 +54,7 @@ def _sanitize_proxy_path(path: str) -> str | None: async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): """Return terminal servers the authenticated user has access to.""" connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} return [ { @@ -55,7 +63,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user 'name': connection.get('name', ''), } for connection in connections - if connection.get('enabled', True) and has_connection_access(user, connection, user_group_ids) + if connection.get('enabled', True) and await has_connection_access(user, connection, user_group_ids) ] @@ -76,8 +84,8 @@ async def proxy_terminal( if connection is None: return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not has_connection_access(user, connection, user_group_ids): + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} + if not await has_connection_access(user, connection, user_group_ids): return JSONResponse({'error': 'Access denied'}, status_code=403) base_url = (connection.get('url') or '').rstrip('/') @@ -99,6 +107,10 @@ async def proxy_terminal( target_url += f'?{request.query_params}' headers = {'X-User-Id': user.id} + # Forward per-session cwd tracking header + session_id = request.headers.get('x-session-id') + if session_id: + headers['X-Session-Id'] = session_id cookies = {} auth_type = connection.get('auth_type', 'bearer') @@ -131,6 +143,7 @@ async def proxy_terminal( headers=headers, cookies=cookies, data=body or None, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) upstream_content_type = upstream_response.headers.get('content-type', '') @@ -139,6 +152,8 @@ async def proxy_terminal( for key, value in upstream_response.headers.items() if key.lower() not in STRIPPED_RESPONSE_HEADERS } + if TERMINAL_PROXY_HEADERS: + filtered_headers.update(TERMINAL_PROXY_HEADERS) # Stream binary responses directly if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): @@ -198,7 +213,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): if data is None or 'id' not in data: await ws.close(code=4001, reason='Invalid token') return None - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) if user is None: await ws.close(code=4001, reason='User not found') return None @@ -217,8 +232,8 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): await ws.close(code=4004, reason='Terminal server not found') return None - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not has_connection_access(user, connection, user_group_ids): + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} + if not await has_connection_access(user, connection, user_group_ids): await ws.close(code=4003, reason='Access denied') return None @@ -269,7 +284,7 @@ async def ws_terminal( session = aiohttp.ClientSession() try: - async with session.ws_connect(upstream_url) as upstream: + async with session.ws_connect(upstream_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as upstream: import asyncio import json as _json diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index dcf416a6064..cd11bcde5e1 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -4,12 +4,12 @@ import time import re import aiohttp -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.models.groups import Groups from pydantic import BaseModel, HttpUrl from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy.orm import Session -from open_webui.internal.db import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from open_webui.internal.db import get_async_session from open_webui.models.oauth_sessions import OAuthSessions @@ -46,16 +46,18 @@ router = APIRouter() -def get_tool_module(request, tool_id, load_from_db=True): +async def get_tool_module(request, tool_id, load_from_db=True): """ Get the tool module by its ID. """ - tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db) + tool_module, _ = await get_tool_module_from_cache(request, tool_id, load_from_db) return tool_module ############################ # GetTools +# The danger is not in having tools, but in reaching +# for the wrong one. Let the choice here be deliberate. ############################ @@ -63,12 +65,12 @@ def get_tool_module(request, tool_id, load_from_db=True): async def get_tools( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): tools = [] # Local Tools - for tool in Tools.get_tools(defer_content=True, db=db): + for tool in await Tools.get_tools(defer_content=True, db=db): tool_module = request.app.state.TOOLS.get(tool.id) if hasattr(request.app.state, 'TOOLS') else None tools.append( ToolUserResponse( @@ -118,7 +120,7 @@ async def get_tools( auth_type = server.get('auth_type', 'none') session_token = None - if auth_type == 'oauth_2.1': + if auth_type in ('oauth_2.1', 'oauth_2.1_static'): splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id @@ -146,7 +148,7 @@ async def get_tools( { 'authenticated': session_token is not None, } - if auth_type == 'oauth_2.1' + if auth_type in ('oauth_2.1', 'oauth_2.1_static') else {} ), } @@ -157,31 +159,30 @@ async def get_tools( # Admin can see all tools return tools else: - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} - tools = [ - tool - for tool in tools - if tool.user_id == user.id - or ( - has_access( + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} + filtered_tools = [] + for tool in tools: + if tool.user_id == user.id: + filtered_tools.append(tool) + elif str(tool.id).startswith('server:'): + if await has_access( user.id, 'read', server_access_grants.get(str(tool.id), []), user_group_ids, db=db, - ) - if str(tool.id).startswith('server:') - else AccessGrants.has_access( - user_id=user.id, - resource_type='tool', - resource_id=tool.id, - permission='read', - user_group_ids=user_group_ids, - db=db, - ) - ) - ] - return tools + ): + filtered_tools.append(tool) + elif await AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tool.id, + permission='read', + user_group_ids=user_group_ids, + db=db, + ): + filtered_tools.append(tool) + return filtered_tools ############################ @@ -190,13 +191,13 @@ async def get_tools( @router.get('/list', response_model=list[ToolAccessResponse]) -async def get_tool_list(user=Depends(get_verified_user), db: Session = Depends(get_session)): +async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - tools = Tools.get_tools(defer_content=True, db=db) + tools = await Tools.get_tools(defer_content=True, db=db) else: - tools = Tools.get_tools_by_user_id(user.id, 'read', defer_content=True, db=db) + tools = await Tools.get_tools_by_user_id(user.id, 'read', defer_content=True, db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} result = [] for tool in tools: @@ -273,7 +274,9 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.get(url, headers={'Content-Type': 'application/json'}) as resp: + async with session.get( + url, headers={'Content-Type': 'application/json'}, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: if resp.status != 200: raise HTTPException(status_code=resp.status, detail='Failed to fetch the tool') data = await resp.text() @@ -284,7 +287,7 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f'Error importing tool: {e}') + raise HTTPException(status_code=500, detail=ERROR_MESSAGES.DEFAULT(e)) ############################ @@ -296,9 +299,9 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe async def export_tools( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - if user.role != 'admin' and not has_permission( + if user.role != 'admin' and not await has_permission( user.id, 'workspace.tools_export', request.app.state.config.USER_PERMISSIONS, @@ -310,9 +313,9 @@ async def export_tools( ) if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: - return Tools.get_tools(db=db) + return await Tools.get_tools(db=db) else: - return Tools.get_tools_by_user_id(user.id, 'read', db=db) + return await Tools.get_tools_by_user_id(user.id, 'read', db=db) ############################ @@ -325,11 +328,11 @@ async def create_new_tools( request: Request, form_data: ToolForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if user.role != 'admin' and not ( - has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) - or has_permission( + await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) + or await has_permission( user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, @@ -349,18 +352,26 @@ async def create_new_tools( form_data.id = form_data.id.lower() - tools = Tools.get_tool_by_id(form_data.id, db=db) + tools = await Tools.get_tool_by_id(form_data.id, db=db) if tools is None: try: + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_tools', + ) + form_data.content = replace_imports(form_data.content) - tool_module, frontmatter = load_tool_module_by_id(form_data.id, content=form_data.content) + tool_module, frontmatter = await load_tool_module_by_id(form_data.id, content=form_data.content) form_data.meta.manifest = frontmatter TOOLS = request.app.state.TOOLS TOOLS[form_data.id] = tool_module specs = get_tool_specs(TOOLS[form_data.id]) - tools = Tools.insert_new_tool(user.id, form_data, specs, db=db) + tools = await Tools.insert_new_tool(user.id, form_data, specs, db=db) tool_cache_dir = CACHE_DIR / 'tools' / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) @@ -391,14 +402,14 @@ async def create_new_tools( @router.get('/id/{id}', response_model=Optional[ToolAccessResponse]) -async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - tools = Tools.get_tool_by_id(id, db=db) +async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + tools = await Tools.get_tool_by_id(id, db=db) if tools: if ( user.role == 'admin' or tools.user_id == user.id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -411,7 +422,7 @@ async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: Session write_access=( (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == tools.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -443,9 +454,9 @@ async def update_tools_by_id( id: str, form_data: ToolForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -455,7 +466,7 @@ async def update_tools_by_id( # Is the user the original creator, in a group with write access, or an admin if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -469,9 +480,20 @@ async def update_tools_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Content edits trigger exec on load — gate them behind workspace.tools (matches /create). + if form_data.content != tools.content: + if user.role != 'admin' and not ( + await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) + or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + try: form_data.content = replace_imports(form_data.content) - tool_module, frontmatter = load_tool_module_by_id(id, content=form_data.content) + tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter TOOLS = request.app.state.TOOLS @@ -479,13 +501,21 @@ async def update_tools_by_id( specs = get_tool_specs(TOOLS[id]) + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + 'sharing.public_tools', + ) + updated = { **form_data.model_dump(exclude={'id'}), 'specs': specs, } log.debug(updated) - tools = Tools.update_tool_by_id(id, updated, db=db) + tools = await Tools.update_tool_by_id(id, updated, db=db) if tools: return tools @@ -517,9 +547,9 @@ async def update_tool_access_by_id( id: str, form_data: ToolAccessGrantsForm, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -528,7 +558,7 @@ async def update_tool_access_by_id( if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -542,7 +572,7 @@ async def update_tool_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.access_grants = filter_allowed_access_grants( + form_data.access_grants = await filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, @@ -550,9 +580,9 @@ async def update_tool_access_by_id( 'sharing.public_tools', ) - AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db) + await AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db) - return Tools.get_tool_by_id(id, db=db) + return await Tools.get_tool_by_id(id, db=db) ############################ @@ -565,9 +595,9 @@ async def delete_tools_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -576,7 +606,7 @@ async def delete_tools_by_id( if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -590,7 +620,7 @@ async def delete_tools_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - result = Tools.delete_tool_by_id(id, db=db) + result = await Tools.delete_tool_by_id(id, db=db) if result: TOOLS = request.app.state.TOOLS if id in TOOLS: @@ -605,8 +635,10 @@ async def delete_tools_by_id( @router.get('/id/{id}/valves', response_model=Optional[dict]) -async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - tools = Tools.get_tool_by_id(id, db=db) +async def get_tools_valves_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -615,7 +647,7 @@ async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user), db: S if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -630,7 +662,7 @@ async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user), db: S ) try: - valves = Tools.get_tool_valves_by_id(id, db=db) + valves = await Tools.get_tool_valves_by_id(id, db=db) return valves except Exception as e: raise HTTPException( @@ -649,9 +681,9 @@ async def get_tools_valves_spec_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -660,7 +692,7 @@ async def get_tools_valves_spec_by_id( if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -677,7 +709,7 @@ async def get_tools_valves_spec_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tool_module_by_id(id) + tools_module, _ = await load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if hasattr(tools_module, 'Valves'): @@ -700,9 +732,9 @@ async def update_tools_valves_by_id( id: str, form_data: dict, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -711,7 +743,7 @@ async def update_tools_valves_by_id( if ( tools.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tools.id, @@ -728,7 +760,7 @@ async def update_tools_valves_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tool_module_by_id(id) + tools_module, _ = await load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if not hasattr(tools_module, 'Valves'): @@ -742,7 +774,7 @@ async def update_tools_valves_by_id( form_data = {k: v for k, v in form_data.items() if v is not None} valves = Valves(**form_data) valves_dict = valves.model_dump(exclude_unset=True) - Tools.update_tool_valves_by_id(id, valves_dict, db=db) + await Tools.update_tool_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: log.exception(f'Failed to update tool valves by id {id}: {e}') @@ -758,52 +790,86 @@ async def update_tools_valves_by_id( @router.get('/id/{id}/valves/user', response_model=Optional[dict]) -async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - tools = Tools.get_tool_by_id(id, db=db) - if tools: - try: - user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) - return user_valves - except Exception as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: +async def get_tools_user_valves_by_id( + id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + tools = await Tools.get_tool_by_id(id, db=db) + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + user_valves = await Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) + return user_valves + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + @router.get('/id/{id}/valves/user/spec', response_model=Optional[dict]) async def get_tools_user_valves_spec_by_id( request: Request, id: str, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, 'UserValves'): - UserValves = tools_module.UserValves - schema = UserValves.schema() - # Resolve dynamic options for select dropdowns - schema = resolve_valves_schema_options(UserValves, schema, user) - return schema - return None - else: + tools = await Tools.get_tool_by_id(id, db=db) + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = await load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + schema = UserValves.schema() + # Resolve dynamic options for select dropdowns + schema = resolve_valves_schema_options(UserValves, schema, user) + return schema + return None + @router.post('/id/{id}/valves/user/update', response_model=Optional[dict]) async def update_tools_user_valves_by_id( @@ -811,36 +877,51 @@ async def update_tools_user_valves_by_id( id: str, form_data: dict, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - tools = Tools.get_tool_by_id(id, db=db) + tools = await Tools.get_tool_by_id(id, db=db) + if not tools: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, 'UserValves'): - UserValves = tools_module.UserValves - - try: - form_data = {k: v for k, v in form_data.items() if v is not None} - user_valves = UserValves(**form_data) - user_valves_dict = user_valves.model_dump(exclude_unset=True) - Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) - return user_valves_dict - except Exception as e: - log.exception(f'Failed to update user valves by id {id}: {e}') - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if ( + tools.user_id != user.id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = await load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + + try: + form_data = {k: v for k, v in form_data.items() if v is not None} + user_valves = UserValves(**form_data) + user_valves_dict = user_valves.model_dump(exclude_unset=True) + await Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) + return user_valves_dict + except Exception as e: + log.exception(f'Failed to update user valves by id {id}: {e}') raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), ) else: raise HTTPException( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 0bc28a2b748..33d1cd425cd 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -1,6 +1,6 @@ import logging from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession import base64 import io @@ -14,7 +14,7 @@ from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.groups import Groups -from open_webui.models.chats import Chats + from open_webui.models.users import ( UserModel, UserGroupIdsModel, @@ -29,8 +29,8 @@ ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import STATIC_DIR -from open_webui.internal.db import get_session +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR +from open_webui.internal.db import get_async_session from open_webui.utils.auth import ( @@ -40,6 +40,7 @@ validate_password, ) from open_webui.utils.access_control import get_permissions, has_permission +from open_webui.socket.main import disconnect_user_sessions log = logging.getLogger(__name__) @@ -48,6 +49,8 @@ ############################ # GetUsers +# A house is only as strong as its care for the least of +# its members. Let none here be counted without being served. ############################ @@ -61,7 +64,7 @@ async def get_users( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -78,14 +81,14 @@ async def get_users( filter['direction'] = direction - result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) users = result['users'] total = result['total'] # Fetch groups for all users in a single query to avoid N+1 user_ids = [user.id for user in users] - user_groups = Groups.get_groups_by_member_ids(user_ids, db=db) + user_groups = await Groups.get_groups_by_member_ids(user_ids, db=db) return { 'users': [ @@ -104,9 +107,9 @@ async def get_users( @router.get('/all', response_model=UserInfoListResponse) async def get_all_users( user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - return Users.get_users(db=db) + return await Users.get_users(db=db) @router.get('/search', response_model=UserInfoListResponse) @@ -116,7 +119,7 @@ async def search_users( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): limit = PAGE_ITEM_COUNT @@ -131,7 +134,7 @@ async def search_users( if direction: filter['direction'] = direction - return Users.get_users(filter=filter, skip=skip, limit=limit, db=db) + return await Users.get_users(filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -140,8 +143,8 @@ async def search_users( @router.get('/groups') -async def get_user_groups(user=Depends(get_verified_user), db: Session = Depends(get_session)): - return Groups.get_groups_by_member_id(user.id, db=db) +async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + return await Groups.get_groups_by_member_id(user.id, db=db) ############################ @@ -153,9 +156,9 @@ async def get_user_groups(user=Depends(get_verified_user), db: Session = Depends async def get_user_permissisions( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): - user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) + user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) return user_permissions @@ -190,6 +193,8 @@ class SharingPermissions(BaseModel): public_skills: bool = False notes: bool = False public_notes: bool = True + public_chats: bool = False + public_calendars: bool = False class AccessGrantsPermissions(BaseModel): @@ -230,6 +235,8 @@ class FeaturesPermissions(BaseModel): image_generation: bool = True code_interpreter: bool = True memories: bool = True + automations: bool = False + calendar: bool = True class SettingsPermissions(BaseModel): @@ -269,15 +276,11 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi @router.get('/user/settings', response_model=Optional[UserSettings]) -async def get_user_settings_by_session_user(user=Depends(get_verified_user), db: Session = Depends(get_session)): - user = Users.get_user_by_id(user.id, db=db) - if user: - return user.settings - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) +async def get_user_settings_by_session_user( + user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + # user already fetched by get_verified_user — no need to refetch + return user.settings ############################ @@ -290,7 +293,7 @@ async def update_user_settings_by_session_user( request: Request, form_data: UserSettings, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): updated_user_settings = form_data.model_dump() ui_settings = updated_user_settings.get('ui') @@ -298,7 +301,7 @@ async def update_user_settings_by_session_user( user.role != 'admin' and ui_settings is not None and 'toolServers' in ui_settings.keys() - and not has_permission( + and not await has_permission( user.id, 'features.direct_tool_servers', request.app.state.config.USER_PERMISSIONS, @@ -307,7 +310,7 @@ async def update_user_settings_by_session_user( # If the user is not an admin and does not have permission to use tool servers, remove the key updated_user_settings['ui'].pop('toolServers', None) - user = Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) + user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: return user.settings else: @@ -326,21 +329,15 @@ async def update_user_settings_by_session_user( async def get_user_status_by_session_user( request: Request, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not request.app.state.config.ENABLE_USER_STATUS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - user = Users.get_user_by_id(user.id, db=db) - if user: - return user - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + return user ############################ @@ -353,22 +350,21 @@ async def update_user_status_by_session_user( request: Request, form_data: UserStatus, user=Depends(get_verified_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): if not request.app.state.config.ENABLE_USER_STATUS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - user = Users.get_user_by_id(user.id, db=db) - if user: - user = Users.update_user_status_by_id(user.id, form_data, db=db) - return user - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # user already fetched by get_verified_user — no need to refetch + updated = await Users.update_user_status_by_id(user.id, form_data, db=db) + if updated: + return updated + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) ############################ @@ -377,15 +373,9 @@ async def update_user_status_by_session_user( @router.get('/user/info', response_model=Optional[dict]) -async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Session = Depends(get_session)): - user = Users.get_user_by_id(user.id, db=db) - if user: - return user.info - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) +async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): + # user already fetched by get_verified_user — no need to refetch + return user.info ############################ @@ -395,21 +385,15 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Ses @router.post('/user/info/update', response_model=Optional[dict]) async def update_user_info_by_session_user( - form_data: dict, user=Depends(get_verified_user), db: Session = Depends(get_session) + form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) ): - user = Users.get_user_by_id(user.id, db=db) - if user: - if user.info is None: - user.info = {} - - user = Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db) - if user: - return user.info - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) + # Merges against the auth-time snapshot of user.info. The previous pre-merge + # refetch only narrowed (did not eliminate) the lost-update window on concurrent + # same-user writes; real safety needs row locking or a version column. + existing_info = user.info or {} + updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db) + if updated: + return updated.info else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -432,28 +416,16 @@ class UserActiveResponse(UserStatus): @router.get('/{user_id}', response_model=UserActiveResponse) -async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - # Check if user_id is a shared chat - # If it is, get the user_id from the chat - if user_id.startswith('shared-'): - chat_id = user_id.replace('shared-', '') - chat = Chats.get_chat_by_id(chat_id) - if chat: - user_id = chat.user_id - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.USER_NOT_FOUND, - ) +async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if user: - groups = Groups.get_groups_by_member_id(user_id, db=db) + groups = await Groups.get_groups_by_member_id(user_id, db=db) return UserActiveResponse( **{ **user.model_dump(), 'groups': [{'id': group.id, 'name': group.name} for group in groups], - 'is_active': Users.is_user_active(user_id, db=db), + 'is_active': await Users.is_user_active(user_id, db=db), } ) else: @@ -464,15 +436,17 @@ async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: Session @router.get('/{user_id}/info', response_model=UserInfoResponse) -async def get_user_info_by_id(user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): - user = Users.get_user_by_id(user_id, db=db) +async def get_user_info_by_id( + user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + user = await Users.get_user_by_id(user_id, db=db) if user: - groups = Groups.get_groups_by_member_id(user_id, db=db) + groups = await Groups.get_groups_by_member_id(user_id, db=db) return UserInfoResponse( **{ **user.model_dump(), 'groups': [{'id': group.id, 'name': group.name} for group in groups], - 'is_active': Users.is_user_active(user_id, db=db), + 'is_active': await Users.is_user_active(user_id, db=db), } ) else: @@ -483,8 +457,10 @@ async def get_user_info_by_id(user_id: str, user=Depends(get_verified_user), db: @router.get('/{user_id}/oauth/sessions') -async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - sessions = OAuthSessions.get_sessions_by_user_id(user_id, db=db) +async def get_user_oauth_sessions_by_id( + user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + sessions = await OAuthSessions.get_sessions_by_user_id(user_id, db=db) if sessions and len(sessions) > 0: return sessions else: @@ -500,27 +476,36 @@ async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_use @router.get('/{user_id}/profile/image') -def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): - user = Users.get_user_by_id(user_id) +async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): + user = await Users.get_user_by_id(user_id) if user: if user.profile_image_url: - # check if it's url or base64 if user.profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': user.profile_image_url}, - ) + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': user.profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. elif user.profile_image_url.startswith('data:image'): try: header, base64_data = user.profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(';')[0].lstrip('data:') + media_type = header.split(';')[0].lstrip('data:').lower() + + if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: + return FileResponse(f'{STATIC_DIR}/user.png') return StreamingResponse( image_buffer, media_type=media_type, - headers={'Content-Disposition': 'inline'}, + headers={ + 'Content-Disposition': 'inline', + 'X-Content-Type-Options': 'nosniff', + }, ) except Exception as e: pass @@ -539,10 +524,10 @@ def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): @router.get('/{user_id}/active', response_model=dict) async def get_user_active_status_by_id( - user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) + user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) ): return { - 'active': Users.is_user_active(user_id, db=db), + 'active': await Users.is_user_active(user_id, db=db), } @@ -556,11 +541,11 @@ async def update_user_by_id( user_id: str, form_data: UserUpdateForm, session_user=Depends(get_admin_user), - db: Session = Depends(get_session), + db: AsyncSession = Depends(get_async_session), ): # Prevent modification of the primary admin user by other admins try: - first_user = Users.get_first_user(db=db) + first_user = await Users.get_first_user(db=db) if first_user: if user_id == first_user.id: if session_user.id != user_id: @@ -570,13 +555,15 @@ async def update_user_by_id( detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - if form_data.role != 'admin': + if form_data.role is not None and form_data.role != 'admin': # If the primary admin is trying to change their own role, prevent it raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) + except HTTPException: + raise except Exception as e: log.error(f'Error checking primary admin status: {e}') raise HTTPException( @@ -584,11 +571,11 @@ async def update_user_by_id( detail='Could not verify primary admin status.', ) - user = Users.get_user_by_id(user_id, db=db) + user = await Users.get_user_by_id(user_id, db=db) if user: - if form_data.email.lower() != user.email: - email_user = Users.get_user_by_email(form_data.email.lower(), db=db) + if form_data.email is not None and form_data.email.lower() != user.email: + email_user = await Users.get_user_by_email(form_data.email.lower(), db=db) if email_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -602,21 +589,34 @@ async def update_user_by_id( raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.password) - Auths.update_user_password_by_id(user_id, hashed, db=db) - - Auths.update_email_by_id(user_id, form_data.email.lower(), db=db) - updated_user = Users.update_user_by_id( - user_id, - { - 'role': form_data.role, - 'name': form_data.name, - 'email': form_data.email.lower(), - 'profile_image_url': form_data.profile_image_url, - }, - db=db, - ) + await Auths.update_user_password_by_id(user_id, hashed, db=db) + + # Build update dict from only the provided fields + update_data = {} + if form_data.role is not None: + update_data['role'] = form_data.role + if form_data.name is not None: + update_data['name'] = form_data.name + if form_data.email is not None: + update_data['email'] = form_data.email.lower() + await Auths.update_email_by_id(user_id, form_data.email.lower(), db=db) + if form_data.profile_image_url is not None: + update_data['profile_image_url'] = form_data.profile_image_url + + if update_data: + updated_user = await Users.update_user_by_id( + user_id, + update_data, + db=db, + ) + else: + updated_user = user if updated_user: + # If the role changed, disconnect all socket sessions so stale + # privileges cached in SESSION_POOL are invalidated. + if updated_user.role != user.role: + await disconnect_user_sessions(user_id) return updated_user raise HTTPException( @@ -636,15 +636,17 @@ async def update_user_by_id( @router.delete('/{user_id}', response_model=bool) -async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): +async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)): # Prevent deletion of the primary admin user try: - first_user = Users.get_first_user(db=db) + first_user = await Users.get_first_user(db=db) if first_user and user_id == first_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) + except HTTPException: + raise except Exception as e: log.error(f'Error checking primary admin status: {e}') raise HTTPException( @@ -653,9 +655,10 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Sess ) if user.id != user_id: - result = Auths.delete_auth_by_id(user_id, db=db) + result = await Auths.delete_auth_by_id(user_id, db=db) if result: + await disconnect_user_sessions(user_id) return True raise HTTPException( @@ -676,5 +679,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Sess @router.get('/{user_id}/groups') -async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): - return Groups.get_groups_by_member_id(user_id, db=db) +async def get_user_groups_by_id( + user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session) +): + return await Groups.get_groups_by_member_id(user_id, db=db) diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index 7ea41500212..20705c2c44b 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -42,6 +42,12 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)): @router.post('/code/execute') async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)): + if not request.app.state.config.ENABLE_CODE_EXECUTION: + raise HTTPException( + status_code=403, + detail=ERROR_MESSAGES.FEATURE_DISABLED('Code execution'), + ) + if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter': output = await execute_code_jupyter( request.app.state.config.CODE_EXECUTION_JUPYTER_URL, @@ -63,7 +69,7 @@ async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_v else: raise HTTPException( status_code=400, - detail='Code execution engine not supported', + detail=ERROR_MESSAGES.DEFAULT('Code execution engine not supported'), ) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1518193da8b..d59ff532772 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -55,6 +55,8 @@ log = logging.getLogger(__name__) +# Let no connection opened in good faith be dropped without +# cause, and let every message find the room it was meant for. REDIS = None # Configure CORS for Socket.IO @@ -310,6 +312,24 @@ async def enter_room_for_users(room: str, user_ids: list[str]): log.debug(f'Failed to make users {user_ids} join room {room}: {e}') +async def disconnect_user_sessions(user_id: str): + """Disconnect all Socket.IO sessions belonging to a user. + + Call this when a user's role is changed or the user is deleted so that + stale role/permission data cached in SESSION_POOL is invalidated. + The client will automatically reconnect and re-authenticate with + fresh data from the database. + """ + try: + session_ids = get_session_ids_from_room(f'user:{user_id}') + for sid in session_ids: + await sio.disconnect(sid) + if session_ids: + log.info(f'Disconnected {len(session_ids)} session(s) for user {user_id}') + except Exception as e: + log.warning(f'Failed to disconnect sessions for user {user_id}: {e}') + + @sio.on('usage') async def usage(sid, data): if sid in SESSION_POOL: @@ -331,7 +351,7 @@ async def connect(sid, environ, auth): data = decode_token(auth['token']) if data is not None and 'id' in data: - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) if user: SESSION_POOL[sid] = { @@ -359,7 +379,7 @@ async def user_join(sid, data): if data is None or 'id' not in data: return - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) if not user: return @@ -379,8 +399,8 @@ async def user_join(sid, data): await sio.enter_room(sid, f'user:{user.id}') # Join all the channels only if user has channels permission - if user.role == 'admin' or has_permission(user.id, 'features.channels'): - channels = Channels.get_channels_by_user_id(user.id) + if user.role == 'admin' or await has_permission(user.id, 'features.channels'): + channels = await Channels.get_channels_by_user_id(user.id) log.debug(f'{channels=}') for channel in channels: await sio.enter_room(sid, f'channel:{channel.id}') @@ -393,7 +413,7 @@ async def heartbeat(sid, data): user = SESSION_POOL.get(sid) if user: SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())} - Users.update_last_active_by_id(user['id']) + await Users.update_last_active_by_id(user['id']) @sio.on('join-channels') @@ -406,13 +426,13 @@ async def join_channel(sid, data): if data is None or 'id' not in data: return - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) if not user: return # Join all the channels only if user has channels permission - if user.role == 'admin' or has_permission(user.id, 'features.channels'): - channels = Channels.get_channels_by_user_id(user.id) + if user.role == 'admin' or await has_permission(user.id, 'features.channels'): + channels = await Channels.get_channels_by_user_id(user.id) log.debug(f'{channels=}') for channel in channels: await sio.enter_room(sid, f'channel:{channel.id}') @@ -428,11 +448,11 @@ async def join_note(sid, data): if token_data is None or 'id' not in token_data: return - user = Users.get_user_by_id(token_data['id']) + user = await Users.get_user_by_id(token_data['id']) if not user: return - note = Notes.get_note_by_id(data['note_id']) + note = await Notes.get_note_by_id(data['note_id']) if not note: log.error(f'Note {data["note_id"]} not found for user {user.id}') return @@ -440,7 +460,7 @@ async def join_note(sid, data): if ( user.role != 'admin' and user.id != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='note', resource_id=note.id, @@ -486,7 +506,20 @@ async def channel_events(sid, data): room=room, ) elif event_type == 'last_read_at': - Channels.update_member_last_read_at(data['channel_id'], user['id']) + await Channels.update_member_last_read_at(data['channel_id'], user['id']) + + +@sio.on('events:chat') +async def chat_events(sid, data): + user = SESSION_POOL.get(sid) + if not user: + return + + event_data = data.get('data', {}) + event_type = event_data.get('type') + + if event_type == 'last_read_at': + await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id']) def normalize_document_id(document_id: str) -> str: @@ -514,7 +547,7 @@ async def ydoc_document_join(sid, data): if document_id.startswith('note:'): note_id = document_id.split(':')[1] - note = Notes.get_note_by_id(note_id) + note = await Notes.get_note_by_id(note_id) if not note: log.error(f'Note {note_id} not found') return @@ -522,7 +555,7 @@ async def ydoc_document_join(sid, data): if ( user.get('role') != 'admin' and user.get('id') != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.get('id'), resource_type='note', resource_id=note.id, @@ -587,7 +620,7 @@ async def document_save_handler(document_id, data, user): if document_id.startswith('note:'): note_id = document_id.split(':')[1] - note = Notes.get_note_by_id(note_id) + note = await Notes.get_note_by_id(note_id) if not note: log.error(f'Note {note_id} not found') return @@ -595,17 +628,17 @@ async def document_save_handler(document_id, data, user): if ( user.get('role') != 'admin' and user.get('id') != note.user_id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.get('id'), resource_type='note', resource_id=note.id, - permission='read', + permission='write', ) ): - log.error(f'User {user.get("id")} does not have access to note {note_id}') + log.error(f'User {user.get("id")} does not have write access to note {note_id}') return - Notes.update_note_by_id(note_id, NoteUpdateForm(data=data)) + await Notes.update_note_by_id(note_id, NoteUpdateForm(data=data)) @sio.on('ydoc:document:state') @@ -664,6 +697,31 @@ async def yjs_document_update(sid, data): log.warning(f'Session {sid} not in room {room}. Rejecting update.') return + # Verify write permission — room membership only proves read access + user = SESSION_POOL.get(sid) + if not user: + return + + if document_id.startswith('note:'): + note_id = document_id.split(':')[1] + note = await Notes.get_note_by_id(note_id) + if not note: + log.error(f'Note {note_id} not found') + return + + if ( + user.get('role') != 'admin' + and user.get('id') != note.user_id + and not await AccessGrants.has_access( + user_id=user.get('id'), + resource_type='note', + resource_id=note.id, + permission='write', + ) + ): + log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') + return + try: await stop_item_tasks(REDIS, document_id) except Exception: @@ -691,10 +749,6 @@ async def yjs_document_update(sid, data): skip_sid=sid, ) - user = SESSION_POOL.get(sid) - if not user: - return - async def debounced_save(): await asyncio.sleep(0.5) await document_save_handler(document_id, data.get('data', {}), user) @@ -710,7 +764,7 @@ async def debounced_save(): async def yjs_document_leave(sid, data): """Handle user leaving a document""" try: - document_id = data['document_id'] + document_id = normalize_document_id(data['document_id']) user_id = data.get('user_id', sid) log.info(f'User {user_id} leaving document {document_id}') @@ -778,7 +832,76 @@ async def disconnect(sid): # print(f"Unknown session ID {sid} disconnected") -def get_event_emitter(request_info, update_db=True): +async def _make_channel_emitter(request_info): + """Event emitter that routes pipeline output to a channel message. + + Translates chat:completion events into channel message:update socket + emissions, throttled to avoid flooding with per-token updates. + """ + channel_id = request_info['chat_id'].removeprefix('channel:') + message_id = request_info['message_id'] + + state = {'last_emit_at': 0.0} + THROTTLE_INTERVAL = 0.15 # ~6 updates/sec + + async def _emit_channel_update(content: str, done: bool = False): + from open_webui.models.messages import Messages, MessageForm + + update_form = MessageForm(content=content) + if done: + # Merge done flag into existing meta (preserve model_id etc.) + msg = await Messages.get_message_by_id(message_id) + existing_meta = (msg.meta or {}) if msg else {} + update_form = MessageForm( + content=content, + meta={**existing_meta, 'done': True}, + ) + + await Messages.update_message_by_id(message_id, update_form) + message = await Messages.get_message_by_id(message_id) + if message: + await sio.emit( + 'events:channel', + { + 'channel_id': channel_id, + 'message_id': message_id, + 'data': { + 'type': 'message:update', + 'data': message.model_dump(), + }, + }, + to=f'channel:{channel_id}', + ) + + async def __channel_emitter__(event_data): + event_type = event_data.get('type') + + if event_type == 'chat:completion': + data = event_data.get('data', {}) + content = data.get('content', '') + done = data.get('done', False) + + if not content and not done: + return + + now = __import__('time').time() + if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL: + state['last_emit_at'] = now + await _emit_channel_update(content, done) + + elif event_type == 'chat:message:error': + error = event_data.get('data', {}).get('error', {}) + error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error) + await _emit_channel_update(f'Error: {error_content}', done=True) + + return __channel_emitter__ + + +async def get_event_emitter(request_info, update_db=True): + # Channel mode: route pipeline output to channel message updates + if request_info.get('chat_id', '').startswith('channel:'): + return await _make_channel_emitter(request_info) + async def __event_emitter__(event_data): user_id = request_info['user_id'] chat_id = request_info['chat_id'] @@ -798,16 +921,14 @@ async def __event_emitter__(event_data): event_type = event_data.get('type') if event_type == 'status': - await asyncio.to_thread( - Chats.add_message_status_to_chat_by_id_and_message_id, + await Chats.add_message_status_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], event_data.get('data', {}), ) elif event_type == 'message': - message = await asyncio.to_thread( - Chats.get_message_by_id_and_message_id, + message = await Chats.get_message_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], ) @@ -816,8 +937,7 @@ async def __event_emitter__(event_data): content = message.get('content', '') content += event_data.get('data', {}).get('content', '') - await asyncio.to_thread( - Chats.upsert_message_to_chat_by_id_and_message_id, + await Chats.upsert_message_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], { @@ -828,8 +948,7 @@ async def __event_emitter__(event_data): elif event_type == 'replace': content = event_data.get('data', {}).get('content', '') - await asyncio.to_thread( - Chats.upsert_message_to_chat_by_id_and_message_id, + await Chats.upsert_message_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], { @@ -838,17 +957,17 @@ async def __event_emitter__(event_data): ) elif event_type == 'embeds': - message = await asyncio.to_thread( - Chats.get_message_by_id_and_message_id, - request_info['chat_id'], - request_info['message_id'], - ) + event_payload = event_data.get('data', {}) + embeds = event_payload.get('embeds', []) - embeds = event_data.get('data', {}).get('embeds', []) - embeds.extend(message.get('embeds', [])) + if not event_payload.get('replace', False): + message = await Chats.get_message_by_id_and_message_id( + request_info['chat_id'], + request_info['message_id'], + ) + embeds.extend(message.get('embeds', [])) - await asyncio.to_thread( - Chats.upsert_message_to_chat_by_id_and_message_id, + await Chats.upsert_message_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], { @@ -857,8 +976,7 @@ async def __event_emitter__(event_data): ) elif event_type == 'files': - message = await asyncio.to_thread( - Chats.get_message_by_id_and_message_id, + message = await Chats.get_message_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], ) @@ -866,8 +984,7 @@ async def __event_emitter__(event_data): files = event_data.get('data', {}).get('files', []) files.extend(message.get('files', [])) - await asyncio.to_thread( - Chats.upsert_message_to_chat_by_id_and_message_id, + await Chats.upsert_message_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], { @@ -878,8 +995,7 @@ async def __event_emitter__(event_data): elif event_type in ('source', 'citation'): data = event_data.get('data', {}) if data.get('type') is None: - message = await asyncio.to_thread( - Chats.get_message_by_id_and_message_id, + message = await Chats.get_message_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], ) @@ -887,8 +1003,7 @@ async def __event_emitter__(event_data): sources = message.get('sources', []) sources.append(data) - await asyncio.to_thread( - Chats.upsert_message_to_chat_by_id_and_message_id, + await Chats.upsert_message_to_chat_by_id_and_message_id( request_info['chat_id'], request_info['message_id'], { @@ -902,19 +1017,29 @@ async def __event_emitter__(event_data): return None -def get_event_call(request_info): +async def get_event_call(request_info): async def __event_caller__(event_data): - response = await sio.call( - 'events', - { - 'chat_id': request_info.get('chat_id', None), - 'message_id': request_info.get('message_id', None), - 'data': event_data, - }, - to=request_info['session_id'], - timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, - ) - return response + session_id = request_info['session_id'] + + # Fast-fail if the client has disconnected. + if session_id not in SESSION_POOL: + log.warning(f'Event caller: session {session_id} no longer connected') + return {'error': 'Client session disconnected.'} + + try: + return await sio.call( + 'events', + { + 'chat_id': request_info.get('chat_id', None), + 'message_id': request_info.get('message_id', None), + 'data': event_data, + }, + to=session_id, + timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, + ) + except TimeoutError: + log.warning(f'Event caller timed out for session {session_id}') + return {'error': 'Event call timed out. The browser tab may be inactive or closed.'} if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: return __event_caller__ diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 682d779ccdb..16b0cc3855a 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -82,13 +82,22 @@ def items(self): return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()] def set(self, mapping: dict): - pipe = self.redis.pipeline() - - pipe.delete(self.name) - if mapping: - pipe.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()}) + if not mapping: + self.redis.delete(self.name) + return - pipe.execute() + # Fetch existing keys before writing so we know which ones to remove. + # HKEYS is cheap — it transfers only short key strings, not large JSON values. + existing_keys = set(self.redis.hkeys(self.name)) + new_keys = set(mapping.keys()) + keys_to_remove = existing_keys - new_keys + + # HSET first (add/update all new values), then HDEL (remove stale keys). + # We never DELETE the whole hash — this eliminates the race window + # where concurrent readers would see an empty models dict. + self.redis.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()}) + if keys_to_remove: + self.redis.hdel(self.name, *keys_to_remove) def get(self, key, default=None): try: diff --git a/backend/open_webui/static/assets/pdf-style.css b/backend/open_webui/static/assets/pdf-style.css index 8b4e8d23705..644dd58ae6f 100644 --- a/backend/open_webui/static/assets/pdf-style.css +++ b/backend/open_webui/static/assets/pdf-style.css @@ -25,7 +25,8 @@ } html { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR', + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR', 'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; /* Default font size */ diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico index 14c5f9c6d43..b819d42f96d 100644 Binary files a/backend/open_webui/static/favicon.ico and b/backend/open_webui/static/favicon.ico differ diff --git a/backend/open_webui/static/favicon.png b/backend/open_webui/static/favicon.png index 63735ad4616..10c84f440ce 100644 Binary files a/backend/open_webui/static/favicon.png and b/backend/open_webui/static/favicon.png differ diff --git a/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js b/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js index dcd1c5313d7..86568978728 100644 --- a/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js +++ b/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js @@ -14,7 +14,7 @@ i = { 69119: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.BLANK_URL = o.relativeFirstCharacters = o.whitespaceEscapeCharsRegex = @@ -31,7 +31,7 @@ (o.urlSchemeRegex = /^.+(:|:)/gim), (o.whitespaceEscapeCharsRegex = /(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g), (o.relativeFirstCharacters = ['.', '/']), - (o.BLANK_URL = 'about:blank'); + (o.BLANK_URL = 'about:blank')); }, 16750: (s, o, i) => { 'use strict'; @@ -83,7 +83,7 @@ }, 67526: (s, o) => { 'use strict'; - (o.byteLength = function byteLength(s) { + ((o.byteLength = function byteLength(s) { var o = getLens(s), i = o[0], u = o[1]; @@ -103,14 +103,14 @@ L = 0, B = C > 0 ? x - 4 : x; for (i = 0; i < B; i += 4) - (o = + ((o = (u[s.charCodeAt(i)] << 18) | (u[s.charCodeAt(i + 1)] << 12) | (u[s.charCodeAt(i + 2)] << 6) | u[s.charCodeAt(i + 3)]), (j[L++] = (o >> 16) & 255), (j[L++] = (o >> 8) & 255), - (j[L++] = 255 & o); + (j[L++] = 255 & o)); 2 === C && ((o = (u[s.charCodeAt(i)] << 2) | (u[s.charCodeAt(i + 1)] >> 4)), (j[L++] = 255 & o)); @@ -136,7 +136,7 @@ ((o = (s[u - 2] << 8) + s[u - 1]), w.push(i[o >> 10] + i[(o >> 4) & 63] + i[(o << 2) & 63] + '=')); return w.join(''); - }); + })); for ( var i = [], u = [], @@ -146,20 +146,20 @@ x < 64; ++x ) - (i[x] = w[x]), (u[w.charCodeAt(x)] = x); + ((i[x] = w[x]), (u[w.charCodeAt(x)] = x)); function getLens(s) { var o = s.length; if (o % 4 > 0) throw new Error('Invalid string. Length must be a multiple of 4'); var i = s.indexOf('='); - return -1 === i && (i = o), [i, i === o ? 0 : 4 - (i % 4)]; + return (-1 === i && (i = o), [i, i === o ? 0 : 4 - (i % 4)]); } function encodeChunk(s, o, u) { for (var _, w, x = [], C = o; C < u; C += 3) - (_ = ((s[C] << 16) & 16711680) + ((s[C + 1] << 8) & 65280) + (255 & s[C + 2])), - x.push(i[((w = _) >> 18) & 63] + i[(w >> 12) & 63] + i[(w >> 6) & 63] + i[63 & w]); + ((_ = ((s[C] << 16) & 16711680) + ((s[C + 1] << 8) & 65280) + (255 & s[C + 2])), + x.push(i[((w = _) >> 18) & 63] + i[(w >> 12) & 63] + i[(w >> 6) & 63] + i[63 & w])); return x.join(''); } - (u['-'.charCodeAt(0)] = 62), (u['_'.charCodeAt(0)] = 63); + ((u['-'.charCodeAt(0)] = 62), (u['_'.charCodeAt(0)] = 63)); }, 48287: (s, o, i) => { 'use strict'; @@ -169,17 +169,17 @@ 'function' == typeof Symbol && 'function' == typeof Symbol.for ? Symbol.for('nodejs.util.inspect.custom') : null; - (o.Buffer = Buffer), + ((o.Buffer = Buffer), (o.SlowBuffer = function SlowBuffer(s) { +s != s && (s = 0); return Buffer.alloc(+s); }), - (o.INSPECT_MAX_BYTES = 50); + (o.INSPECT_MAX_BYTES = 50)); const x = 2147483647; function createBuffer(s) { if (s > x) throw new RangeError('The value "' + s + '" is invalid for option "size"'); const o = new Uint8Array(s); - return Object.setPrototypeOf(o, Buffer.prototype), o; + return (Object.setPrototypeOf(o, Buffer.prototype), o); } function Buffer(s, o, i) { if ('number' == typeof s) { @@ -232,7 +232,7 @@ if (Buffer.isBuffer(s)) { const o = 0 | checked(s.length), i = createBuffer(o); - return 0 === i.length || s.copy(i, 0, 0, o), i; + return (0 === i.length || s.copy(i, 0, 0, o), i); } if (void 0 !== s.length) return 'number' != typeof s.length || numberIsNaN(s.length) @@ -257,7 +257,7 @@ if (s < 0) throw new RangeError('The value "' + s + '" is invalid for option "size"'); } function allocUnsafe(s) { - return assertSize(s), createBuffer(s < 0 ? 0 : 0 | checked(s)); + return (assertSize(s), createBuffer(s < 0 ? 0 : 0 | checked(s))); } function fromArrayLike(s) { const o = s.length < 0 ? 0 : 0 | checked(s.length), @@ -323,7 +323,7 @@ return base64ToBytes(s).length; default: if (_) return u ? -1 : utf8ToBytes(s).length; - (o = ('' + o).toLowerCase()), (_ = !0); + ((o = ('' + o).toLowerCase()), (_ = !0)); } } function slowToString(s, o, i) { @@ -352,12 +352,12 @@ return utf16leSlice(this, o, i); default: if (u) throw new TypeError('Unknown encoding: ' + s); - (s = (s + '').toLowerCase()), (u = !0); + ((s = (s + '').toLowerCase()), (u = !0)); } } function swap(s, o, i) { const u = s[o]; - (s[o] = s[i]), (s[i] = u); + ((s[o] = s[i]), (s[i] = u)); } function bidirectionalIndexOf(s, o, i, u, _) { if (0 === s.length) return -1; @@ -403,7 +403,7 @@ 'utf-16le' === u) ) { if (s.length < 2 || o.length < 2) return -1; - (x = 2), (C /= 2), (j /= 2), (i /= 2); + ((x = 2), (C /= 2), (j /= 2), (i /= 2)); } function read(s, o) { return 1 === x ? s[o] : s.readUInt16BE(o * x); @@ -413,7 +413,7 @@ for (w = i; w < C; w++) if (read(s, w) === read(o, -1 === u ? 0 : w - u)) { if ((-1 === u && (u = w), w - u + 1 === j)) return u * x; - } else -1 !== u && (w -= w - u), (u = -1); + } else (-1 !== u && (w -= w - u), (u = -1)); } else for (i + j > C && (i = C - j), w = i; w >= 0; w--) { let i = !0; @@ -463,7 +463,7 @@ let i, u, _; const w = []; for (let x = 0; x < s.length && !((o -= 2) < 0); ++x) - (i = s.charCodeAt(x)), (u = i >> 8), (_ = i % 256), w.push(_), w.push(u); + ((i = s.charCodeAt(x)), (u = i >> 8), (_ = i % 256), w.push(_), w.push(u)); return w; })(o, s.length - i), s, @@ -489,34 +489,34 @@ o < 128 && (w = o); break; case 2: - (i = s[_ + 1]), - 128 == (192 & i) && ((j = ((31 & o) << 6) | (63 & i)), j > 127 && (w = j)); + ((i = s[_ + 1]), + 128 == (192 & i) && ((j = ((31 & o) << 6) | (63 & i)), j > 127 && (w = j))); break; case 3: - (i = s[_ + 1]), + ((i = s[_ + 1]), (u = s[_ + 2]), 128 == (192 & i) && 128 == (192 & u) && ((j = ((15 & o) << 12) | ((63 & i) << 6) | (63 & u)), - j > 2047 && (j < 55296 || j > 57343) && (w = j)); + j > 2047 && (j < 55296 || j > 57343) && (w = j))); break; case 4: - (i = s[_ + 1]), + ((i = s[_ + 1]), (u = s[_ + 2]), (C = s[_ + 3]), 128 == (192 & i) && 128 == (192 & u) && 128 == (192 & C) && ((j = ((15 & o) << 18) | ((63 & i) << 12) | ((63 & u) << 6) | (63 & C)), - j > 65535 && j < 1114112 && (w = j)); + j > 65535 && j < 1114112 && (w = j))); } } - null === w + (null === w ? ((w = 65533), (x = 1)) : w > 65535 && ((w -= 65536), u.push(((w >>> 10) & 1023) | 55296), (w = 56320 | (1023 & w))), u.push(w), - (_ += x); + (_ += x)); } return (function decodeCodePointsArray(s) { const o = s.length; @@ -527,7 +527,7 @@ return i; })(u); } - (o.kMaxLength = x), + ((o.kMaxLength = x), (Buffer.TYPED_ARRAY_SUPPORT = (function typedArraySupport() { try { const s = new Uint8Array(1), @@ -606,7 +606,7 @@ u = o.length; for (let _ = 0, w = Math.min(i, u); _ < w; ++_) if (s[_] !== o[_]) { - (i = s[_]), (u = o[_]); + ((i = s[_]), (u = o[_])); break; } return i < u ? -1 : u < i ? 1 : 0; @@ -663,17 +663,17 @@ (Buffer.prototype.swap32 = function swap32() { const s = this.length; if (s % 4 != 0) throw new RangeError('Buffer size must be a multiple of 32-bits'); - for (let o = 0; o < s; o += 4) swap(this, o, o + 3), swap(this, o + 1, o + 2); + for (let o = 0; o < s; o += 4) (swap(this, o, o + 3), swap(this, o + 1, o + 2)); return this; }), (Buffer.prototype.swap64 = function swap64() { const s = this.length; if (s % 8 != 0) throw new RangeError('Buffer size must be a multiple of 64-bits'); for (let o = 0; o < s; o += 8) - swap(this, o, o + 7), + (swap(this, o, o + 7), swap(this, o + 1, o + 6), swap(this, o + 2, o + 5), - swap(this, o + 3, o + 4); + swap(this, o + 3, o + 4)); return this; }), (Buffer.prototype.toString = function toString() { @@ -729,7 +729,7 @@ L = s.slice(o, i); for (let s = 0; s < C; ++s) if (j[s] !== L[s]) { - (w = j[s]), (x = L[s]); + ((w = j[s]), (x = L[s])); break; } return w < x ? -1 : x < w ? 1 : 0; @@ -744,17 +744,17 @@ return bidirectionalIndexOf(this, s, o, i, !1); }), (Buffer.prototype.write = function write(s, o, i, u) { - if (void 0 === o) (u = 'utf8'), (i = this.length), (o = 0); - else if (void 0 === i && 'string' == typeof o) (u = o), (i = this.length), (o = 0); + if (void 0 === o) ((u = 'utf8'), (i = this.length), (o = 0)); + else if (void 0 === i && 'string' == typeof o) ((u = o), (i = this.length), (o = 0)); else { if (!isFinite(o)) throw new Error( 'Buffer.write(string, encoding, offset[, length]) is no longer supported' ); - (o >>>= 0), + ((o >>>= 0), isFinite(i) ? ((i >>>= 0), void 0 === u && (u = 'utf8')) - : ((u = i), (i = void 0)); + : ((u = i), (i = void 0))); } const _ = this.length - o; if ( @@ -784,12 +784,12 @@ return ucs2Write(this, s, o, i); default: if (w) throw new TypeError('Unknown encoding: ' + u); - (u = ('' + u).toLowerCase()), (w = !0); + ((u = ('' + u).toLowerCase()), (w = !0)); } }), (Buffer.prototype.toJSON = function toJSON() { return { type: 'Buffer', data: Array.prototype.slice.call(this._arr || this, 0) }; - }); + })); const C = 4096; function asciiSlice(s, o, i) { let u = ''; @@ -805,7 +805,7 @@ } function hexSlice(s, o, i) { const u = s.length; - (!o || o < 0) && (o = 0), (!i || i < 0 || i > u) && (i = u); + ((!o || o < 0) && (o = 0), (!i || i < 0 || i > u) && (i = u)); let _ = ''; for (let u = o; u < i; ++u) _ += B[s[u]]; return _; @@ -830,7 +830,13 @@ function wrtBigUInt64LE(s, o, i, u, _) { checkIntBI(o, u, _, s, i, 7); let w = Number(o & BigInt(4294967295)); - (s[i++] = w), (w >>= 8), (s[i++] = w), (w >>= 8), (s[i++] = w), (w >>= 8), (s[i++] = w); + ((s[i++] = w), + (w >>= 8), + (s[i++] = w), + (w >>= 8), + (s[i++] = w), + (w >>= 8), + (s[i++] = w)); let x = Number((o >> BigInt(32)) & BigInt(4294967295)); return ( (s[i++] = x), @@ -846,13 +852,13 @@ function wrtBigUInt64BE(s, o, i, u, _) { checkIntBI(o, u, _, s, i, 7); let w = Number(o & BigInt(4294967295)); - (s[i + 7] = w), + ((s[i + 7] = w), (w >>= 8), (s[i + 6] = w), (w >>= 8), (s[i + 5] = w), (w >>= 8), - (s[i + 4] = w); + (s[i + 4] = w)); let x = Number((o >> BigInt(32)) & BigInt(4294967295)); return ( (s[i + 3] = x), @@ -871,25 +877,33 @@ } function writeFloat(s, o, i, u, w) { return ( - (o = +o), (i >>>= 0), w || checkIEEE754(s, 0, i, 4), _.write(s, o, i, u, 23, 4), i + 4 + (o = +o), + (i >>>= 0), + w || checkIEEE754(s, 0, i, 4), + _.write(s, o, i, u, 23, 4), + i + 4 ); } function writeDouble(s, o, i, u, w) { return ( - (o = +o), (i >>>= 0), w || checkIEEE754(s, 0, i, 8), _.write(s, o, i, u, 52, 8), i + 8 + (o = +o), + (i >>>= 0), + w || checkIEEE754(s, 0, i, 8), + _.write(s, o, i, u, 52, 8), + i + 8 ); } - (Buffer.prototype.slice = function slice(s, o) { + ((Buffer.prototype.slice = function slice(s, o) { const i = this.length; - (s = ~~s) < 0 ? (s += i) < 0 && (s = 0) : s > i && (s = i), + ((s = ~~s) < 0 ? (s += i) < 0 && (s = 0) : s > i && (s = i), (o = void 0 === o ? i : ~~o) < 0 ? (o += i) < 0 && (o = 0) : o > i && (o = i), - o < s && (o = s); + o < s && (o = s)); const u = this.subarray(s, o); - return Object.setPrototypeOf(u, Buffer.prototype), u; + return (Object.setPrototypeOf(u, Buffer.prototype), u); }), (Buffer.prototype.readUintLE = Buffer.prototype.readUIntLE = function readUIntLE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s], _ = 1, w = 0; @@ -898,7 +912,7 @@ }), (Buffer.prototype.readUintBE = Buffer.prototype.readUIntBE = function readUIntBE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s + --o], _ = 1; for (; o > 0 && (_ *= 256); ) u += this[s + --o] * _; @@ -906,18 +920,22 @@ }), (Buffer.prototype.readUint8 = Buffer.prototype.readUInt8 = function readUInt8(s, o) { - return (s >>>= 0), o || checkOffset(s, 1, this.length), this[s]; + return ((s >>>= 0), o || checkOffset(s, 1, this.length), this[s]); }), (Buffer.prototype.readUint16LE = Buffer.prototype.readUInt16LE = function readUInt16LE(s, o) { return ( - (s >>>= 0), o || checkOffset(s, 2, this.length), this[s] | (this[s + 1] << 8) + (s >>>= 0), + o || checkOffset(s, 2, this.length), + this[s] | (this[s + 1] << 8) ); }), (Buffer.prototype.readUint16BE = Buffer.prototype.readUInt16BE = function readUInt16BE(s, o) { return ( - (s >>>= 0), o || checkOffset(s, 2, this.length), (this[s] << 8) | this[s + 1] + (s >>>= 0), + o || checkOffset(s, 2, this.length), + (this[s] << 8) | this[s + 1] ); }), (Buffer.prototype.readUint32LE = Buffer.prototype.readUInt32LE = @@ -955,20 +973,20 @@ return (BigInt(u) << BigInt(32)) + BigInt(_); })), (Buffer.prototype.readIntLE = function readIntLE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s], _ = 1, w = 0; for (; ++w < o && (_ *= 256); ) u += this[s + w] * _; - return (_ *= 128), u >= _ && (u -= Math.pow(2, 8 * o)), u; + return ((_ *= 128), u >= _ && (u -= Math.pow(2, 8 * o)), u); }), (Buffer.prototype.readIntBE = function readIntBE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = o, _ = 1, w = this[s + --u]; for (; u > 0 && (_ *= 256); ) w += this[s + --u] * _; - return (_ *= 128), w >= _ && (w -= Math.pow(2, 8 * o)), w; + return ((_ *= 128), w >= _ && (w -= Math.pow(2, 8 * o)), w); }), (Buffer.prototype.readInt8 = function readInt8(s, o) { return ( @@ -978,12 +996,12 @@ ); }), (Buffer.prototype.readInt16LE = function readInt16LE(s, o) { - (s >>>= 0), o || checkOffset(s, 2, this.length); + ((s >>>= 0), o || checkOffset(s, 2, this.length)); const i = this[s] | (this[s + 1] << 8); return 32768 & i ? 4294901760 | i : i; }), (Buffer.prototype.readInt16BE = function readInt16BE(s, o) { - (s >>>= 0), o || checkOffset(s, 2, this.length); + ((s >>>= 0), o || checkOffset(s, 2, this.length)); const i = this[s + 1] | (this[s] << 8); return 32768 & i ? 4294901760 | i : i; }), @@ -1024,16 +1042,16 @@ ); })), (Buffer.prototype.readFloatLE = function readFloatLE(s, o) { - return (s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !0, 23, 4); + return ((s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !0, 23, 4)); }), (Buffer.prototype.readFloatBE = function readFloatBE(s, o) { - return (s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !1, 23, 4); + return ((s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !1, 23, 4)); }), (Buffer.prototype.readDoubleLE = function readDoubleLE(s, o) { - return (s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !0, 52, 8); + return ((s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !0, 52, 8)); }), (Buffer.prototype.readDoubleBE = function readDoubleBE(s, o) { - return (s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !1, 52, 8); + return ((s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !1, 52, 8)); }), (Buffer.prototype.writeUintLE = Buffer.prototype.writeUIntLE = function writeUIntLE(s, o, i, u) { @@ -1134,8 +1152,8 @@ w = 1, x = 0; for (this[o] = 255 & s; ++_ < i && (w *= 256); ) - s < 0 && 0 === x && 0 !== this[o + _ - 1] && (x = 1), - (this[o + _] = (((s / w) | 0) - x) & 255); + (s < 0 && 0 === x && 0 !== this[o + _ - 1] && (x = 1), + (this[o + _] = (((s / w) | 0) - x) & 255)); return o + i; }), (Buffer.prototype.writeIntBE = function writeIntBE(s, o, i, u) { @@ -1147,8 +1165,8 @@ w = 1, x = 0; for (this[o + _] = 255 & s; --_ >= 0 && (w *= 256); ) - s < 0 && 0 === x && 0 !== this[o + _ + 1] && (x = 1), - (this[o + _] = (((s / w) | 0) - x) & 255); + (s < 0 && 0 === x && 0 !== this[o + _ + 1] && (x = 1), + (this[o + _] = (((s / w) | 0) - x) & 255)); return o + i; }), (Buffer.prototype.writeInt8 = function writeInt8(s, o, i) { @@ -1257,7 +1275,8 @@ if (o < 0) throw new RangeError('targetStart out of bounds'); if (i < 0 || i >= this.length) throw new RangeError('Index out of range'); if (u < 0) throw new RangeError('sourceEnd out of bounds'); - u > this.length && (u = this.length), s.length - o < u - i && (u = s.length - o + i); + (u > this.length && (u = this.length), + s.length - o < u - i && (u = s.length - o + i)); const _ = u - i; return ( this === s && 'function' == typeof Uint8Array.prototype.copyWithin @@ -1301,12 +1320,12 @@ for (_ = 0; _ < i - o; ++_) this[_ + o] = w[_ % x]; } return this; - }); + })); const j = {}; function E(s, o, i) { j[s] = class NodeError extends i { constructor() { - super(), + (super(), Object.defineProperty(this, 'message', { value: o.apply(this, arguments), writable: !0, @@ -1314,7 +1333,7 @@ }), (this.name = `${this.name} [${s}]`), this.stack, - delete this.name; + delete this.name); } get code() { return s; @@ -1344,18 +1363,18 @@ const u = 'bigint' == typeof o ? 'n' : ''; let _; throw ( - ((_ = + (_ = w > 3 ? 0 === o || o === BigInt(0) ? `>= 0${u} and < 2${u} ** ${8 * (w + 1)}${u}` : `>= -(2${u} ** ${8 * (w + 1) - 1}${u}) and < 2 ** ${8 * (w + 1) - 1}${u}` : `>= ${o}${u} and <= ${i}${u}`), - new j.ERR_OUT_OF_RANGE('value', _, s)) + new j.ERR_OUT_OF_RANGE('value', _, s) ); } !(function checkBounds(s, o, i) { - validateNumber(o, 'offset'), - (void 0 !== s[o] && void 0 !== s[o + i]) || boundsError(o, s.length - (i + 1)); + (validateNumber(o, 'offset'), + (void 0 !== s[o] && void 0 !== s[o + i]) || boundsError(o, s.length - (i + 1))); })(u, _, w); } function validateNumber(s, o) { @@ -1367,7 +1386,7 @@ if (o < 0) throw new j.ERR_BUFFER_OUT_OF_BOUNDS(); throw new j.ERR_OUT_OF_RANGE(i || 'offset', `>= ${i ? 1 : 0} and <= ${o}`, s); } - E( + (E( 'ERR_BUFFER_OUT_OF_BOUNDS', function (s) { return s @@ -1401,7 +1420,7 @@ ); }, RangeError - ); + )); const L = /[^+/0-9A-Za-z-_]/g; function utf8ToBytes(s, o) { let i; @@ -1424,7 +1443,7 @@ continue; } if (i < 56320) { - (o -= 3) > -1 && w.push(239, 191, 189), (_ = i); + ((o -= 3) > -1 && w.push(239, 191, 189), (_ = i)); continue; } i = 65536 + (((_ - 55296) << 10) | (i - 56320)); @@ -1505,7 +1524,7 @@ j, L, B = !1; - o || (o = {}), (i = o.debug || !1); + (o || (o = {}), (i = o.debug || !1)); try { if ( ((x = u()), @@ -1525,12 +1544,12 @@ L.addEventListener('copy', function (u) { if ((u.stopPropagation(), o.format)) if ((u.preventDefault(), void 0 === u.clipboardData)) { - i && console.warn('unable to use e.clipboardData'), + (i && console.warn('unable to use e.clipboardData'), i && console.warn('trying IE specific stuff'), - window.clipboardData.clearData(); + window.clipboardData.clearData()); var w = _[o.format] || _.default; window.clipboardData.setData(w, s); - } else u.clipboardData.clearData(), u.clipboardData.setData(o.format, s); + } else (u.clipboardData.clearData(), u.clipboardData.setData(o.format, s)); o.onCopy && (u.preventDefault(), o.onCopy(u.clipboardData)); }), document.body.appendChild(L), @@ -1541,32 +1560,32 @@ throw new Error('copy command was unsuccessful'); B = !0; } catch (u) { - i && console.error('unable to copy using execCommand: ', u), - i && console.warn('trying IE specific stuff'); + (i && console.error('unable to copy using execCommand: ', u), + i && console.warn('trying IE specific stuff')); try { - window.clipboardData.setData(o.format || 'text', s), + (window.clipboardData.setData(o.format || 'text', s), o.onCopy && o.onCopy(window.clipboardData), - (B = !0); + (B = !0)); } catch (u) { - i && console.error('unable to copy using clipboardData: ', u), + (i && console.error('unable to copy using clipboardData: ', u), i && console.error('falling back to prompt'), (w = (function format(s) { var o = (/mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl') + '+C'; return s.replace(/#{\s*key\s*}/g, o); })('message' in o ? o.message : 'Copy to clipboard: #{key}, Enter')), - window.prompt(w, s); + window.prompt(w, s)); } } finally { - j && ('function' == typeof j.removeRange ? j.removeRange(C) : j.removeAllRanges()), + (j && ('function' == typeof j.removeRange ? j.removeRange(C) : j.removeAllRanges()), L && document.body.removeChild(L), - x(); + x()); } return B; }; }, 2205: function (s, o, i) { var u; - (u = void 0 !== i.g ? i.g : this), + ((u = void 0 !== i.g ? i.g : this), (s.exports = (function (s) { if (s.CSS && s.CSS.escape) return s.CSS.escape; var cssEscape = function (s) { @@ -1575,7 +1594,6 @@ for ( var o, i = String(s), u = i.length, _ = -1, w = '', x = i.charCodeAt(0); ++_ < u; - ) 0 != (o = i.charCodeAt(_)) ? (w += @@ -1598,8 +1616,8 @@ : (w += '�'); return w; }; - return s.CSS || (s.CSS = {}), (s.CSS.escape = cssEscape), cssEscape; - })(u)); + return (s.CSS || (s.CSS = {}), (s.CSS.escape = cssEscape), cssEscape); + })(u))); }, 81919: (s, o, i) => { 'use strict'; @@ -1610,7 +1628,7 @@ function cloneSpecificValue(s) { if (s instanceof u) { var o = u.alloc ? u.alloc(s.length) : new u(s.length); - return s.copy(o), o; + return (s.copy(o), o); } if (s instanceof Date) return new Date(s.getTime()); if (s instanceof RegExp) return new RegExp(s); @@ -1746,9 +1764,9 @@ ); } function deepmerge(s, i, u) { - ((u = u || {}).arrayMerge = u.arrayMerge || defaultArrayMerge), + (((u = u || {}).arrayMerge = u.arrayMerge || defaultArrayMerge), (u.isMergeableObject = u.isMergeableObject || o), - (u.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified); + (u.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified)); var _ = Array.isArray(i); return _ === Array.isArray(s) ? _ @@ -1777,7 +1795,7 @@ } = Object; let { freeze: w, seal: x, create: C } = Object, { apply: j, construct: L } = 'undefined' != typeof Reflect && Reflect; - w || + (w || (w = function freeze(s) { return s; }), @@ -1792,7 +1810,7 @@ L || (L = function construct(s, o) { return new s(...o); - }); + })); const B = unapply(Array.prototype.forEach), $ = unapply(Array.prototype.pop), V = unapply(Array.prototype.push), @@ -2533,7 +2551,10 @@ try { return s.createPolicy(_, { createHTML: (s) => s, createScriptURL: (s) => s }); } catch (s) { - return console.warn('TrustedTypes policy ' + _ + ' could not be created.'), null; + return ( + console.warn('TrustedTypes policy ' + _ + ' could not be created.'), + null + ); } }; function createDOMPurify() { @@ -2544,7 +2565,7 @@ (DOMPurify.removed = []), !o || !o.document || o.document.nodeType !== rt.document) ) - return (DOMPurify.isSupported = !1), DOMPurify; + return ((DOMPurify.isSupported = !1), DOMPurify); let { document: i } = o; const u = i, _ = u.currentScript, @@ -2779,11 +2800,11 @@ throw ce( 'TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.' ); - (ct = s.TRUSTED_TYPES_POLICY), (ut = ct.createHTML('')); + ((ct = s.TRUSTED_TYPES_POLICY), (ut = ct.createHTML(''))); } else - void 0 === ct && (ct = st(Ye, _)), - null !== ct && 'string' == typeof ut && (ut = ct.createHTML('')); - w && w(s), (yr = s); + (void 0 === ct && (ct = st(Ye, _)), + null !== ct && 'string' == typeof ut && (ut = ct.createHTML(''))); + (w && w(s), (yr = s)); } }, Er = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']), @@ -2910,7 +2931,7 @@ }, Nr = function _sanitizeElements(s) { let o = null; - if ((Tr('beforeSanitizeElements', s, null), Pr(s))) return Or(s), !0; + if ((Tr('beforeSanitizeElements', s, null), Pr(s))) return (Or(s), !0); const i = gr(s.nodeName); if ( (Tr('uponSanitizeElement', s, { tagName: i, allowedTags: Ot }), @@ -2919,9 +2940,9 @@ le(/<[/\w]/g, s.innerHTML) && le(/<[/\w]/g, s.textContent)) ) - return Or(s), !0; - if (s.nodeType === rt.progressingInstruction) return Or(s), !0; - if (Ft && s.nodeType === rt.comment && le(/<[/\w]/g, s.data)) return Or(s), !0; + return (Or(s), !0); + if (s.nodeType === rt.progressingInstruction) return (Or(s), !0); + if (Ft && s.nodeType === rt.comment && le(/<[/\w]/g, s.data)) return (Or(s), !0); if (!Ot[i] || Mt[i]) { if (!Mt[i] && Dr(i)) { if (Pt.tagNameCheck instanceof RegExp && le(Pt.tagNameCheck, i)) return !1; @@ -2933,11 +2954,11 @@ if (i && o) for (let u = i.length - 1; u >= 0; --u) { const _ = et(i[u], !0); - (_.__removalCount = (s.__removalCount || 0) + 1), - o.insertBefore(_, it(s)); + ((_.__removalCount = (s.__removalCount || 0) + 1), + o.insertBefore(_, it(s))); } } - return Or(s), !0; + return (Or(s), !0); } return s instanceof Re && !Cr(s) ? (Or(s), !0) @@ -3041,8 +3062,8 @@ L = ct.createScriptURL(L); } try { - x ? s.setAttributeNS(x, w, L) : s.setAttribute(w, L), - Pr(s) ? Or(s) : $(DOMPurify.removed); + (x ? s.setAttributeNS(x, w, L) : s.setAttribute(w, L), + Pr(s) ? Or(s) : $(DOMPurify.removed)); } catch (s) {} } } @@ -3052,8 +3073,8 @@ let o = null; const i = Ir(s); for (Tr('beforeSanitizeShadowDOM', s, null); (o = i.nextNode()); ) - Tr('uponSanitizeShadowNode', o, null), - Nr(o) || (o.content instanceof x && _sanitizeShadowDOM(o.content), Lr(o)); + (Tr('uponSanitizeShadowNode', o, null), + Nr(o) || (o.content instanceof x && _sanitizeShadowDOM(o.content), Lr(o))); Tr('afterSanitizeShadowDOM', s, null); }; return ( @@ -3078,11 +3099,11 @@ throw ce('root node is forbidden and cannot be sanitized in-place'); } } else if (s instanceof L) - (i = jr('\x3c!----\x3e')), + ((i = jr('\x3c!----\x3e')), (_ = i.ownerDocument.importNode(s, !0)), (_.nodeType === rt.element && 'BODY' === _.nodeName) || 'HTML' === _.nodeName ? (i = _) - : i.appendChild(_); + : i.appendChild(_)); else { if (!Ut && !Bt && !qt && -1 === s.indexOf('<')) return ct && Wt ? ct.createHTML(s) : s; @@ -3098,7 +3119,7 @@ for (C = dt.call(i.ownerDocument); i.firstChild; ) C.appendChild(i.firstChild); else C = i; - return (jt.shadowroot || jt.shadowrootmode) && (C = gt.call(u, C, !0)), C; + return ((jt.shadowroot || jt.shadowrootmode) && (C = gt.call(u, C, !0)), C); } let $ = qt ? i.outerHTML : i.innerHTML; return ( @@ -3117,11 +3138,11 @@ ); }), (DOMPurify.setConfig = function () { - _r(arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}), - ($t = !0); + (_r(arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}), + ($t = !0)); }), (DOMPurify.clearConfig = function () { - (yr = null), ($t = !1); + ((yr = null), ($t = !1)); }), (DOMPurify.isValidAttribute = function (s, o, i) { yr || _r({}); @@ -3151,7 +3172,7 @@ 'use strict'; class SubRange { constructor(s, o) { - (this.low = s), (this.high = o), (this.length = 1 + o - s); + ((this.low = s), (this.high = o), (this.length = 1 + o - s)); } overlaps(s) { return !(this.high < s.low || this.low > s.high); @@ -3177,7 +3198,7 @@ } class DRange { constructor(s, o) { - (this.ranges = []), (this.length = 0), null != s && this.add(s, o); + ((this.ranges = []), (this.length = 0), null != s && this.add(s, o)); } _update_length() { this.length = this.ranges.reduce((s, o) => s + o.length, 0); @@ -3188,10 +3209,9 @@ for ( var i = this.ranges.slice(0, o); o < this.ranges.length && s.touches(this.ranges[o]); - ) - (s = s.add(this.ranges[o])), o++; - i.push(s), (this.ranges = i.concat(this.ranges.slice(o))), this._update_length(); + ((s = s.add(this.ranges[o])), o++); + (i.push(s), (this.ranges = i.concat(this.ranges.slice(o))), this._update_length()); }; return ( s instanceof DRange @@ -3206,10 +3226,9 @@ for ( var i = this.ranges.slice(0, o); o < this.ranges.length && s.overlaps(this.ranges[o]); - ) - (i = i.concat(this.ranges[o].subtract(s))), o++; - (this.ranges = i.concat(this.ranges.slice(o))), this._update_length(); + ((i = i.concat(this.ranges[o].subtract(s))), o++); + ((this.ranges = i.concat(this.ranges.slice(o))), this._update_length()); }; return ( s instanceof DRange @@ -3225,7 +3244,7 @@ for (; o < this.ranges.length && s.overlaps(this.ranges[o]); ) { var u = Math.max(this.ranges[o].low, s.low), _ = Math.min(this.ranges[o].high, s.high); - i.push(new SubRange(u, _)), o++; + (i.push(new SubRange(u, _)), o++); } }; return ( @@ -3239,7 +3258,7 @@ } index(s) { for (var o = 0; o < this.ranges.length && this.ranges[o].length <= s; ) - (s -= this.ranges[o].length), o++; + ((s -= this.ranges[o].length), o++); return this.ranges[o].low + s; } toString() { @@ -3250,7 +3269,7 @@ } numbers() { return this.ranges.reduce((s, o) => { - for (var i = o.low; i <= o.high; ) s.push(i), i++; + for (var i = o.low; i <= o.high; ) (s.push(i), i++); return s; }, []); } @@ -3292,27 +3311,28 @@ function EventEmitter() { EventEmitter.init.call(this); } - (s.exports = EventEmitter), + ((s.exports = EventEmitter), (s.exports.once = function once(s, o) { return new Promise(function (i, u) { function errorListener(i) { - s.removeListener(o, resolver), u(i); + (s.removeListener(o, resolver), u(i)); } function resolver() { - 'function' == typeof s.removeListener && s.removeListener('error', errorListener), - i([].slice.call(arguments)); + ('function' == typeof s.removeListener && + s.removeListener('error', errorListener), + i([].slice.call(arguments))); } - eventTargetAgnosticAddListener(s, o, resolver, { once: !0 }), + (eventTargetAgnosticAddListener(s, o, resolver, { once: !0 }), 'error' !== o && (function addErrorHandlerIfEventEmitter(s, o, i) { 'function' == typeof s.on && eventTargetAgnosticAddListener(s, 'error', o, i); - })(s, errorListener, { once: !0 }); + })(s, errorListener, { once: !0 })); }); }), (EventEmitter.EventEmitter = EventEmitter), (EventEmitter.prototype._events = void 0), (EventEmitter.prototype._eventsCount = 0), - (EventEmitter.prototype._maxListeners = void 0); + (EventEmitter.prototype._maxListeners = void 0)); var w = 10; function checkListener(s) { if ('function' != typeof s) @@ -3334,7 +3354,7 @@ (x = w[o])), void 0 === x) ) - (x = w[o] = i), ++s._eventsCount; + ((x = w[o] = i), ++s._eventsCount); else if ( ('function' == typeof x ? (x = w[o] = u ? [i, x] : [x, i]) @@ -3351,13 +3371,13 @@ String(o) + ' listeners added. Use emitter.setMaxListeners() to increase limit' ); - (C.name = 'MaxListenersExceededWarning'), + ((C.name = 'MaxListenersExceededWarning'), (C.emitter = s), (C.type = o), (C.count = x.length), (function ProcessEmitWarning(s) { console && console.warn && console.warn(s); - })(C); + })(C)); } return s; } @@ -3374,7 +3394,7 @@ function _onceWrap(s, o, i) { var u = { fired: !1, wrapFn: void 0, target: s, type: o, listener: i }, _ = onceWrapper.bind(u); - return (_.listener = i), (u.wrapFn = _), _; + return ((_.listener = i), (u.wrapFn = _), _); } function _listeners(s, o, i) { var u = s._events; @@ -3415,11 +3435,11 @@ 'The "emitter" argument must be of type EventEmitter. Received type ' + typeof s ); s.addEventListener(o, function wrapListener(_) { - u.once && s.removeEventListener(o, wrapListener), i(_); + (u.once && s.removeEventListener(o, wrapListener), i(_)); }); } } - Object.defineProperty(EventEmitter, 'defaultMaxListeners', { + (Object.defineProperty(EventEmitter, 'defaultMaxListeners', { enumerable: !0, get: function () { return w; @@ -3435,9 +3455,9 @@ } }), (EventEmitter.init = function () { - (void 0 !== this._events && this._events !== Object.getPrototypeOf(this)._events) || + ((void 0 !== this._events && this._events !== Object.getPrototypeOf(this)._events) || ((this._events = Object.create(null)), (this._eventsCount = 0)), - (this._maxListeners = this._maxListeners || void 0); + (this._maxListeners = this._maxListeners || void 0)); }), (EventEmitter.prototype.setMaxListeners = function setMaxListeners(s) { if ('number' != typeof s || s < 0 || _(s)) @@ -3446,7 +3466,7 @@ s + '.' ); - return (this._maxListeners = s), this; + return ((this._maxListeners = s), this); }), (EventEmitter.prototype.getMaxListeners = function getMaxListeners() { return _getMaxListeners(this); @@ -3481,10 +3501,10 @@ return _addListener(this, s, o, !0); }), (EventEmitter.prototype.once = function once(s, o) { - return checkListener(o), this.on(s, _onceWrap(this, s, o)), this; + return (checkListener(o), this.on(s, _onceWrap(this, s, o)), this); }), (EventEmitter.prototype.prependOnceListener = function prependOnceListener(s, o) { - return checkListener(o), this.prependListener(s, _onceWrap(this, s, o)), this; + return (checkListener(o), this.prependListener(s, _onceWrap(this, s, o)), this); }), (EventEmitter.prototype.removeListener = function removeListener(s, o) { var i, u, _, w, x; @@ -3498,18 +3518,18 @@ else if ('function' != typeof i) { for (_ = -1, w = i.length - 1; w >= 0; w--) if (i[w] === o || i[w].listener === o) { - (x = i[w].listener), (_ = w); + ((x = i[w].listener), (_ = w)); break; } if (_ < 0) return this; - 0 === _ + (0 === _ ? i.shift() : (function spliceOne(s, o) { for (; o + 1 < s.length; o++) s[o] = s[o + 1]; s.pop(); })(i, _), 1 === i.length && (u[s] = i[0]), - void 0 !== u.removeListener && this.emit('removeListener', s, x || o); + void 0 !== u.removeListener && this.emit('removeListener', s, x || o)); } return this; }), @@ -3558,26 +3578,26 @@ (EventEmitter.prototype.listenerCount = listenerCount), (EventEmitter.prototype.eventNames = function eventNames() { return this._eventsCount > 0 ? o(this._events) : []; - }); + })); }, 85587: (s, o, i) => { 'use strict'; var u = i(26311), _ = create(Error); function create(s) { - return (FormattedError.displayName = s.displayName || s.name), FormattedError; + return ((FormattedError.displayName = s.displayName || s.name), FormattedError); function FormattedError(o) { - return o && (o = u.apply(null, arguments)), new s(o); + return (o && (o = u.apply(null, arguments)), new s(o)); } } - (s.exports = _), + ((s.exports = _), (_.eval = create(EvalError)), (_.range = create(RangeError)), (_.reference = create(ReferenceError)), (_.syntax = create(SyntaxError)), (_.type = create(TypeError)), (_.uri = create(URIError)), - (_.create = create); + (_.create = create)); }, 26311: (s) => { !(function () { @@ -3599,7 +3619,7 @@ return x[w++]; }, slurpNumber = function () { - for (var i = ''; /\d/.test(s[C]); ) (i += s[C++]), (o = s[C]); + for (var i = ''; /\d/.test(s[C]); ) ((i += s[C++]), (o = s[C])); return i.length > 0 ? parseInt(i) : null; }; C < j; @@ -3629,8 +3649,8 @@ L += parseInt(nextArg(), 10); break; case 'f': - (u = String(parseFloat(nextArg()).toFixed(_ || 6))), - (L += $ ? u : u.replace(/^0/, '')); + ((u = String(parseFloat(nextArg()).toFixed(_ || 6))), + (L += $ ? u : u.replace(/^0/, ''))); break; case 'j': L += JSON.stringify(nextArg()); @@ -3653,7 +3673,7 @@ else '%' === o ? (B = !0) : (L += o); return L; } - ((o = s.exports = format).format = format), + (((o = s.exports = format).format = format), (o.vsprintf = function vsprintf(s, o) { return format.apply(null, [s].concat(o)); }), @@ -3661,7 +3681,7 @@ 'function' == typeof console.log && (o.printf = function printf() { console.log(format.apply(null, arguments)); - }); + })); })(); }, 45981: (s) => { @@ -3694,7 +3714,9 @@ o.default = i; class Response { constructor(s) { - void 0 === s.data && (s.data = {}), (this.data = s.data), (this.isMatchIgnored = !1); + (void 0 === s.data && (s.data = {}), + (this.data = s.data), + (this.isMatchIgnored = !1)); } ignoreMatch() { this.isMatchIgnored = !0; @@ -3721,7 +3743,7 @@ const emitsWrappingTags = (s) => !!s.kind; class HTMLRenderer { constructor(s, o) { - (this.buffer = ''), (this.classPrefix = o.classPrefix), s.walk(this); + ((this.buffer = ''), (this.classPrefix = o.classPrefix), s.walk(this)); } addText(s) { this.buffer += escapeHTML(s); @@ -3729,7 +3751,7 @@ openNode(s) { if (!emitsWrappingTags(s)) return; let o = s.kind; - s.sublanguage || (o = `${this.classPrefix}${o}`), this.span(o); + (s.sublanguage || (o = `${this.classPrefix}${o}`), this.span(o)); } closeNode(s) { emitsWrappingTags(s) && (this.buffer += ''); @@ -3743,7 +3765,7 @@ } class TokenTree { constructor() { - (this.rootNode = { children: [] }), (this.stack = [this.rootNode]); + ((this.rootNode = { children: [] }), (this.stack = [this.rootNode])); } get top() { return this.stack[this.stack.length - 1]; @@ -3756,7 +3778,7 @@ } openNode(s) { const o = { kind: s, children: [] }; - this.add(o), this.stack.push(o); + (this.add(o), this.stack.push(o)); } closeNode() { if (this.stack.length > 1) return this.stack.pop(); @@ -3791,7 +3813,7 @@ } class TokenTreeEmitter extends TokenTree { constructor(s) { - super(), (this.options = s); + (super(), (this.options = s)); } addKeyword(s, o) { '' !== s && (this.openNode(o), this.addText(s), this.closeNode()); @@ -3801,7 +3823,7 @@ } addSublanguage(s, o) { const i = s.root; - (i.kind = o), (i.sublanguage = !0), this.add(i); + ((i.kind = o), (i.sublanguage = !0), this.add(i)); } toHTML() { return new HTMLRenderer(this, this.options).value(); @@ -3945,7 +3967,7 @@ function compileMatch(s, o) { if (s.match) { if (s.begin || s.end) throw new Error('begin & end are not supported with match'); - (s.begin = s.match), delete s.match; + ((s.begin = s.match), delete s.match); } } function compileRelevance(s, o) { @@ -3977,11 +3999,11 @@ u ); function compileList(s, i) { - o && (i = i.map((s) => s.toLowerCase())), + (o && (i = i.map((s) => s.toLowerCase())), i.forEach(function (o) { const i = o.split('|'); u[i[0]] = [s, scoreForKeyword(i[0], i[1])]; - }); + })); } } function scoreForKeyword(s, o) { @@ -3999,24 +4021,24 @@ } class MultiRegex { constructor() { - (this.matchIndexes = {}), + ((this.matchIndexes = {}), (this.regexes = []), (this.matchAt = 1), - (this.position = 0); + (this.position = 0)); } addRule(s, o) { - (o.position = this.position++), + ((o.position = this.position++), (this.matchIndexes[this.matchAt] = o), this.regexes.push([o, s]), (this.matchAt += (function countMatchGroups(s) { return new RegExp(s.toString() + '|').exec('').length - 1; - })(s) + 1); + })(s) + 1)); } compile() { 0 === this.regexes.length && (this.exec = () => null); const s = this.regexes.map((s) => s[1]); - (this.matcherRe = langRe( + ((this.matcherRe = langRe( (function join(s, o = '|') { let i = 0; return s @@ -4031,11 +4053,11 @@ w += _; break; } - (w += _.substring(0, s.index)), + ((w += _.substring(0, s.index)), (_ = _.substring(s.index + s[0].length)), '\\' === s[0][0] && s[1] ? (w += '\\' + String(Number(s[1]) + o)) - : ((w += s[0]), '(' === s[0] && i++); + : ((w += s[0]), '(' === s[0] && i++)); } return w; }) @@ -4044,7 +4066,7 @@ })(s), !0 )), - (this.lastIndex = 0); + (this.lastIndex = 0)); } exec(s) { this.matcherRe.lastIndex = this.lastIndex; @@ -4052,16 +4074,16 @@ if (!o) return null; const i = o.findIndex((s, o) => o > 0 && void 0 !== s), u = this.matchIndexes[i]; - return o.splice(0, i), Object.assign(o, u); + return (o.splice(0, i), Object.assign(o, u)); } } class ResumableMultiRegex { constructor() { - (this.rules = []), + ((this.rules = []), (this.multiRegexes = []), (this.count = 0), (this.lastIndex = 0), - (this.regexIndex = 0); + (this.regexIndex = 0)); } getMatcher(s) { if (this.multiRegexes[s]) return this.multiRegexes[s]; @@ -4080,7 +4102,7 @@ this.regexIndex = 0; } addRule(s, o) { - this.rules.push([s, o]), 'begin' === o.type && this.count++; + (this.rules.push([s, o]), 'begin' === o.type && this.count++); } exec(s) { const o = this.getMatcher(this.regexIndex); @@ -4090,7 +4112,7 @@ if (i && i.index === this.lastIndex); else { const o = this.getMatcher(0); - (o.lastIndex = this.lastIndex + 1), (i = o.exec(s)); + ((o.lastIndex = this.lastIndex + 1), (i = o.exec(s))); } return ( i && @@ -4112,11 +4134,11 @@ (function compileMode(o, i) { const u = o; if (o.isCompiled) return u; - [compileMatch].forEach((s) => s(o, i)), + ([compileMatch].forEach((s) => s(o, i)), s.compilerExtensions.forEach((s) => s(o, i)), (o.__beforeBegin = null), [beginKeywords, compileIllegal, compileRelevance].forEach((s) => s(o, i)), - (o.isCompiled = !0); + (o.isCompiled = !0)); let _ = null; if ( ('object' == typeof o.keywords && @@ -4237,7 +4259,7 @@ const u = nodeStream(s); if (!u.length) return; const _ = document.createElement('div'); - (_.innerHTML = o.value), + ((_.innerHTML = o.value), (o.value = (function mergeStreams(s, o, i) { let u = 0, _ = ''; @@ -4274,15 +4296,15 @@ ) { w.reverse().forEach(close); do { - render(o.splice(0, 1)[0]), (o = selectStream()); + (render(o.splice(0, 1)[0]), (o = selectStream())); } while (o === s && o.length && o[0].offset === u); w.reverse().forEach(open); } else - 'start' === o[0].event ? w.push(o[0].node) : w.pop(), - render(o.splice(0, 1)[0]); + ('start' === o[0].event ? w.push(o[0].node) : w.pop(), + render(o.splice(0, 1)[0])); } return _ + escapeHTML(i.substr(u)); - })(u, nodeStream(_), i)); + })(u, nodeStream(_), i))); } }; function tag(s) { @@ -4355,7 +4377,7 @@ const x = { code: _, language: w }; fire('before:highlight', x); const C = x.result ? x.result : _highlight(x.language, x.code, i, u); - return (C.code = x.code), fire('after:highlight', C), C; + return ((C.code = x.code), fire('after:highlight', C), C); } function _highlight(s, o, u, x) { function keywordData(s, o) { @@ -4363,17 +4385,17 @@ return Object.prototype.hasOwnProperty.call(s.keywords, i) && s.keywords[i]; } function processBuffer() { - null != U.subLanguage + (null != U.subLanguage ? (function processSubLanguage() { if ('' === Z) return; let s = null; if ('string' == typeof U.subLanguage) { if (!i[U.subLanguage]) return void Y.addText(Z); - (s = _highlight(U.subLanguage, Z, !0, z[U.subLanguage])), - (z[U.subLanguage] = s.top); + ((s = _highlight(U.subLanguage, Z, !0, z[U.subLanguage])), + (z[U.subLanguage] = s.top)); } else s = highlightAuto(Z, U.subLanguage.length ? U.subLanguage : null); - U.relevance > 0 && (ee += s.relevance), - Y.addSublanguage(s.emitter, s.language); + (U.relevance > 0 && (ee += s.relevance), + Y.addSublanguage(s.emitter, s.language)); })() : (function processKeywords() { if (!U.keywords) return void Y.addText(Z); @@ -4392,11 +4414,11 @@ Y.addKeyword(o[0], i); } } else i += o[0]; - (s = U.keywordPatternRe.lastIndex), (o = U.keywordPatternRe.exec(Z)); + ((s = U.keywordPatternRe.lastIndex), (o = U.keywordPatternRe.exec(Z))); } - (i += Z.substr(s)), Y.addText(i); + ((i += Z.substr(s)), Y.addText(i)); })(), - (Z = ''); + (Z = '')); } function startNewMode(s) { return ( @@ -4413,7 +4435,7 @@ if (u) { if (s['on:end']) { const i = new Response(s); - s['on:end'](o, i), i.isMatchIgnored && (u = !1); + (s['on:end'](o, i), i.isMatchIgnored && (u = !1)); } if (u) { for (; s.endsParent && s.parent; ) s = s.parent; @@ -4458,9 +4480,9 @@ processBuffer(), w.excludeEnd && (Z = i)); do { - U.className && Y.closeNode(), + (U.className && Y.closeNode(), U.skip || U.subLanguage || (ee += U.relevance), - (U = U.parent); + (U = U.parent)); } while (U !== _.parent); return ( _.starts && @@ -4471,7 +4493,7 @@ let j = {}; function processLexeme(i, _) { const x = _ && _[0]; - if (((Z += i), null == x)) return processBuffer(), 0; + if (((Z += i), null == x)) return (processBuffer(), 0); if ('begin' === j.type && 'end' === _.type && j.index === _.index && '' === x) { if (((Z += o.slice(_.index, _.index + 1)), !w)) { const o = new Error('0 width match regex'); @@ -4494,7 +4516,7 @@ if (ae > 1e5 && ae > 3 * _.index) { throw new Error('potential infinite loop, way more iterations than matches'); } - return (Z += x), x.length; + return ((Z += x), x.length); } const B = getLanguage(s); if (!B) throw (error(C.replace('{}', s)), new Error('Unknown language: "' + s + '"')); @@ -4515,7 +4537,7 @@ le = !1; try { for (U.matcher.considerAll(); ; ) { - ae++, le ? (le = !1) : U.matcher.considerAll(), (U.matcher.lastIndex = ie); + (ae++, le ? (le = !1) : U.matcher.considerAll(), (U.matcher.lastIndex = ie)); const s = U.matcher.exec(o); if (!s) break; const i = processLexeme(o.substring(ie, s.index), s); @@ -4572,7 +4594,7 @@ illegal: !1, top: j }; - return o.emitter.addText(s), o; + return (o.emitter.addText(s), o); })(s), _ = o .filter(getLanguage) @@ -4589,7 +4611,7 @@ }), [x, C] = w, B = x; - return (B.second_best = C), B; + return ((B.second_best = C), B); } const B = { 'before:highlightElement': ({ el: s }) => { @@ -4625,14 +4647,14 @@ return o.split(/\s+/).find((s) => shouldNotHighlight(s) || getLanguage(s)); })(s); if (shouldNotHighlight(i)) return; - fire('before:highlightElement', { el: s, language: i }), (o = s); + (fire('before:highlightElement', { el: s, language: i }), (o = s)); const _ = o.textContent, w = i ? highlight(_, { language: i, ignoreIllegals: !0 }) : highlightAuto(_); - fire('after:highlightElement', { el: s, result: w, text: _ }), + (fire('after:highlightElement', { el: s, result: w, text: _ }), (s.innerHTML = w.value), (function updateClassName(s, o, i) { const _ = o ? u[o] : i; - s.classList.add('hljs'), _ && s.classList.add(_); + (s.classList.add('hljs'), _ && s.classList.add(_)); })(s, i, w.language), (s.result = { language: w.language, re: w.relevance, relavance: w.relevance }), w.second_best && @@ -4640,15 +4662,15 @@ language: w.second_best.language, re: w.second_best.relevance, relavance: w.second_best.relevance - }); + })); } const initHighlighting = () => { if (initHighlighting.called) return; - (initHighlighting.called = !0), + ((initHighlighting.called = !0), deprecated( '10.6.0', 'initHighlighting() is deprecated. Use highlightAll() instead.' - ); + )); document.querySelectorAll('pre code').forEach(highlightElement); }; let U = !1; @@ -4657,13 +4679,13 @@ document.querySelectorAll('pre code').forEach(highlightElement); } function getLanguage(s) { - return (s = (s || '').toLowerCase()), i[s] || i[u[s]]; + return ((s = (s || '').toLowerCase()), i[s] || i[u[s]]); } function registerAliases(s, { languageName: o }) { - 'string' == typeof s && (s = [s]), + ('string' == typeof s && (s = [s]), s.forEach((s) => { u[s.toLowerCase()] = o; - }); + })); } function autoDetection(s) { const o = getLanguage(s); @@ -4675,7 +4697,7 @@ s[i] && s[i](o); }); } - 'undefined' != typeof window && + ('undefined' != typeof window && window.addEventListener && window.addEventListener( 'DOMContentLoaded', @@ -4719,21 +4741,21 @@ ); }, configure: function configure(s) { - s.useBR && + (s.useBR && (deprecated('10.3.0', "'useBR' will be removed entirely in v11.0"), deprecated( '10.3.0', 'Please see https://github.com/highlightjs/highlight.js/issues/2559' )), - (L = Se(L, s)); + (L = Se(L, s))); }, initHighlighting, initHighlightingOnLoad: function initHighlightingOnLoad() { - deprecated( + (deprecated( '10.6.0', 'initHighlightingOnLoad() is deprecated. Use highlightAll() instead.' ), - (U = !0); + (U = !0)); }, registerLanguage: function registerLanguage(o, u) { let _ = null; @@ -4747,12 +4769,12 @@ !w) ) throw s; - error(s), (_ = j); + (error(s), (_ = j)); } - _.name || (_.name = o), + (_.name || (_.name = o), (i[o] = _), (_.rawDefinition = u.bind(null, s)), - _.aliases && registerAliases(_.aliases, { languageName: o }); + _.aliases && registerAliases(_.aliases, { languageName: o })); }, unregisterLanguage: function unregisterLanguage(s) { delete i[s]; @@ -4764,11 +4786,11 @@ getLanguage, registerAliases, requireLanguage: function requireLanguage(s) { - deprecated('10.4.0', 'requireLanguage will be removed entirely in v11.'), + (deprecated('10.4.0', 'requireLanguage will be removed entirely in v11.'), deprecated( '10.4.0', 'Please see https://github.com/highlightjs/highlight.js/pull/2844' - ); + )); const o = getLanguage(s); if (o) return o; throw new Error( @@ -4778,8 +4800,8 @@ autoDetection, inherit: Se, addPlugin: function addPlugin(s) { - !(function upgradePluginAPI(s) { - s['before:highlightBlock'] && + (!(function upgradePluginAPI(s) { + (s['before:highlightBlock'] && !s['before:highlightElement'] && (s['before:highlightElement'] = (o) => { s['before:highlightBlock'](Object.assign({ block: o.el }, o)); @@ -4788,9 +4810,9 @@ !s['after:highlightElement'] && (s['after:highlightElement'] = (o) => { s['after:highlightBlock'](Object.assign({ block: o.el }, o)); - }); + })); })(s), - _.push(s); + _.push(s)); }, vuePlugin: BuildVuePlugin(s).VuePlugin }), @@ -4800,9 +4822,9 @@ (s.safeMode = function () { w = !0; }), - (s.versionString = '10.7.3'); + (s.versionString = '10.7.3')); for (const s in fe) 'object' == typeof fe[s] && o(fe[s]); - return Object.assign(s, fe), s.addPlugin(B), s.addPlugin(be), s.addPlugin(V), s; + return (Object.assign(s, fe), s.addPlugin(B), s.addPlugin(be), s.addPlugin(V), s); })({}); s.exports = Pe; }, @@ -5712,7 +5734,7 @@ }; }, 251: (s, o) => { - (o.read = function (s, o, i, u, _) { + ((o.read = function (s, o, i, u, _) { var w, x, C = 8 * _ - u - 1, @@ -5735,7 +5757,7 @@ if (0 === w) w = 1 - L; else { if (w === j) return x ? NaN : (1 / 0) * (U ? -1 : 1); - (x += Math.pow(2, u)), (w -= L); + ((x += Math.pow(2, u)), (w -= L)); } return (U ? -1 : 1) * x * Math.pow(2, w - u); }), @@ -5768,14 +5790,14 @@ ); for (x = (x << _) | C, L += _; L > 0; s[i + U] = 255 & x, U += z, x /= 256, L -= 8); s[i + U - z] |= 128 * Y; - }); + })); }, 9404: function (s) { s.exports = (function () { 'use strict'; var s = Array.prototype.slice; function createClass(s, o) { - o && (s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s); + (o && (s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s)); } function Iterable(s) { return isIterable(s) ? s : Seq(s); @@ -5804,7 +5826,7 @@ function isOrdered(s) { return !(!s || !s[_]); } - createClass(KeyedIterable, Iterable), + (createClass(KeyedIterable, Iterable), createClass(IndexedIterable, Iterable), createClass(SetIterable, Iterable), (Iterable.isIterable = isIterable), @@ -5814,7 +5836,7 @@ (Iterable.isOrdered = isOrdered), (Iterable.Keyed = KeyedIterable), (Iterable.Indexed = IndexedIterable), - (Iterable.Set = SetIterable); + (Iterable.Set = SetIterable)); var o = '@@__IMMUTABLE_ITERABLE__@@', i = '@@__IMMUTABLE_KEYED__@@', u = '@@__IMMUTABLE_INDEXED__@@', @@ -5827,7 +5849,7 @@ B = { value: !1 }, $ = { value: !1 }; function MakeRef(s) { - return (s.value = !1), s; + return ((s.value = !1), s); } function SetRef(s) { s && (s.value = !0); @@ -5840,7 +5862,7 @@ return u; } function ensureSize(s) { - return void 0 === s.size && (s.size = s.__iterate(returnTrue)), s.size; + return (void 0 === s.size && (s.size = s.__iterate(returnTrue)), s.size); } function wrapIndex(s, o) { if ('number' != typeof o) { @@ -5884,7 +5906,7 @@ } function iteratorValue(s, o, i, u) { var _ = 0 === s ? o : 1 === s ? i : [o, i]; - return u ? (u.value = _) : (u = { value: _, done: !1 }), u; + return (u ? (u.value = _) : (u = { value: _, done: !1 }), u); } function iteratorDone() { return { value: void 0, done: !0 }; @@ -5938,7 +5960,7 @@ : indexedSeqFromValue(s) ).toSetSeq(); } - (Iterator.prototype.toString = function () { + ((Iterator.prototype.toString = function () { return '[Iterator]'; }), (Iterator.KEYS = V), @@ -6005,23 +6027,23 @@ (Seq.isSeq = isSeq), (Seq.Keyed = KeyedSeq), (Seq.Set = SetSeq), - (Seq.Indexed = IndexedSeq); + (Seq.Indexed = IndexedSeq)); var ie, ae, le, ce = '@@__IMMUTABLE_SEQ__@@'; function ArraySeq(s) { - (this._array = s), (this.size = s.length); + ((this._array = s), (this.size = s.length)); } function ObjectSeq(s) { var o = Object.keys(s); - (this._object = s), (this._keys = o), (this.size = o.length); + ((this._object = s), (this._keys = o), (this.size = o.length)); } function IterableSeq(s) { - (this._iterable = s), (this.size = s.length || s.size); + ((this._iterable = s), (this.size = s.length || s.size)); } function IteratorSeq(s) { - (this._iterator = s), (this._iteratorCache = []); + ((this._iterator = s), (this._iteratorCache = [])); } function isSeq(s) { return !(!s || !s[ce]); @@ -6163,12 +6185,12 @@ else { _ = !0; var w = s; - (s = o), (o = w); + ((s = o), (o = w)); } var x = !0, C = o.__iterate(function (o, u) { if (i ? !s.has(o) : _ ? !is(o, s.get(u, L)) : !is(s.get(u, L), o)) - return (x = !1), !1; + return ((x = !1), !1); }); return x && s.size === C; } @@ -6210,7 +6232,7 @@ function KeyedCollection() {} function IndexedCollection() {} function SetCollection() {} - (Seq.prototype[ce] = !0), + ((Seq.prototype[ce] = !0), createClass(ArraySeq, IndexedSeq), (ArraySeq.prototype.get = function (s, o) { return this.has(s) ? this._array[wrapIndex(this, s)] : o; @@ -6397,7 +6419,7 @@ w = 0; return new Iterator(function () { var x = _; - return (_ += o ? -u : u), w > i ? iteratorDone() : iteratorValue(s, w++, x); + return ((_ += o ? -u : u), w > i ? iteratorDone() : iteratorValue(s, w++, x)); }); }), (Range.prototype.equals = function (s) { @@ -6411,7 +6433,7 @@ createClass(SetCollection, Collection), (Collection.Keyed = KeyedCollection), (Collection.Indexed = IndexedCollection), - (Collection.Set = SetCollection); + (Collection.Set = SetCollection)); var pe = 'function' == typeof Math.imul && -2 === Math.imul(4294967295, 2) ? Math.imul @@ -6476,10 +6498,10 @@ void 0 !== s.propertyIsEnumerable && s.propertyIsEnumerable === s.constructor.prototype.propertyIsEnumerable ) - (s.propertyIsEnumerable = function () { + ((s.propertyIsEnumerable = function () { return this.constructor.prototype.propertyIsEnumerable.apply(this, arguments); }), - (s.propertyIsEnumerable[we] = o); + (s.propertyIsEnumerable[we] = o)); else { if (void 0 === s.nodeType) throw new Error('Unable to set a non-enumerable property on object.'); @@ -6491,7 +6513,7 @@ var de = Object.isExtensible, fe = (function () { try { - return Object.defineProperty({}, '@', {}), !0; + return (Object.defineProperty({}, '@', {}), !0); } catch (s) { return !1; } @@ -6525,16 +6547,16 @@ ? s : emptyMap().withMutations(function (o) { var i = KeyedIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s, i) { return o.set(i, s); - }); + })); }); } function isMap(s) { return !(!s || !s[qe]); } - createClass(Map, KeyedCollection), + (createClass(Map, KeyedCollection), (Map.of = function () { var o = s.call(arguments, 0); return emptyMap().withMutations(function (s) { @@ -6620,7 +6642,7 @@ }), (Map.prototype.withMutations = function (s) { var o = this.asMutable(); - return s(o), o.wasAltered() ? o.__ensureOwner(this.__ownerID) : this; + return (s(o), o.wasAltered() ? o.__ensureOwner(this.__ownerID) : this); }), (Map.prototype.asMutable = function () { return this.__ownerID ? this : this.__ensureOwner(new OwnerID()); @@ -6640,7 +6662,7 @@ return ( this._root && this._root.iterate(function (o) { - return u++, s(o[1], o[0], i); + return (u++, s(o[1], o[0], i)); }, o), u ); @@ -6652,29 +6674,29 @@ ? makeMap(this.size, this._root, s, this.__hash) : ((this.__ownerID = s), (this.__altered = !1), this); }), - (Map.isMap = isMap); + (Map.isMap = isMap)); var Re, qe = '@@__IMMUTABLE_MAP__@@', $e = Map.prototype; function ArrayMapNode(s, o) { - (this.ownerID = s), (this.entries = o); + ((this.ownerID = s), (this.entries = o)); } function BitmapIndexedNode(s, o, i) { - (this.ownerID = s), (this.bitmap = o), (this.nodes = i); + ((this.ownerID = s), (this.bitmap = o), (this.nodes = i)); } function HashArrayMapNode(s, o, i) { - (this.ownerID = s), (this.count = o), (this.nodes = i); + ((this.ownerID = s), (this.count = o), (this.nodes = i)); } function HashCollisionNode(s, o, i) { - (this.ownerID = s), (this.keyHash = o), (this.entries = i); + ((this.ownerID = s), (this.keyHash = o), (this.entries = i)); } function ValueNode(s, o, i) { - (this.ownerID = s), (this.keyHash = o), (this.entry = i); + ((this.ownerID = s), (this.keyHash = o), (this.entry = i)); } function MapIterator(s, o, i) { - (this._type = o), + ((this._type = o), (this._reverse = i), - (this._stack = s._root && mapIteratorFrame(s._root)); + (this._stack = s._root && mapIteratorFrame(s._root))); } function mapIteratorValue(s, o) { return iteratorValue(s, o[0], o[1]); @@ -6706,7 +6728,7 @@ _ = s.size + (w.value ? (i === L ? -1 : 1) : 0); } else { if (i === L) return s; - (_ = 1), (u = new ArrayMapNode(s.__ownerID, [[o, i]])); + ((_ = 1), (u = new ArrayMapNode(s.__ownerID, [[o, i]]))); } return s.__ownerID ? ((s.size = _), (s._root = u), (s.__hash = void 0), (s.__altered = !0), s) @@ -6759,17 +6781,17 @@ function expandNodes(s, o, i, u, _) { for (var w = 0, x = new Array(C), j = 0; 0 !== i; j++, i >>>= 1) x[j] = 1 & i ? o[w++] : void 0; - return (x[u] = _), new HashArrayMapNode(s, w + 1, x); + return ((x[u] = _), new HashArrayMapNode(s, w + 1, x)); } function mergeIntoMapWith(s, o, i) { for (var u = [], _ = 0; _ < i.length; _++) { var w = i[_], x = KeyedIterable(w); - isIterable(w) || + (isIterable(w) || (x = x.map(function (s) { return fromJS(s); })), - u.push(x); + u.push(x)); } return mergeIntoCollectionWith(s, o, u); } @@ -6835,23 +6857,23 @@ } function setIn(s, o, i, u) { var _ = u ? s : arrCopy(s); - return (_[o] = i), _; + return ((_[o] = i), _); } function spliceIn(s, o, i, u) { var _ = s.length + 1; - if (u && o + 1 === _) return (s[o] = i), s; + if (u && o + 1 === _) return ((s[o] = i), s); for (var w = new Array(_), x = 0, C = 0; C < _; C++) C === o ? ((w[C] = i), (x = -1)) : (w[C] = s[C + x]); return w; } function spliceOut(s, o, i) { var u = s.length - 1; - if (i && o === u) return s.pop(), s; + if (i && o === u) return (s.pop(), s); for (var _ = new Array(u), w = 0, x = 0; x < u; x++) - x === o && (w = 1), (_[x] = s[x + w]); + (x === o && (w = 1), (_[x] = s[x + w])); return _; } - ($e[qe] = !0), + (($e[qe] = !0), ($e[w] = $e.remove), ($e.removeIn = $e.deleteIn), (ArrayMapNode.prototype.get = function (s, o, i, u) { @@ -7022,7 +7044,7 @@ o = this._stack = this._stack.__prev; } return iteratorDone(); - }); + })); var ze = C / 4, We = C / 2, He = C / 4; @@ -7038,16 +7060,16 @@ u > 0 && u < C ? makeList(0, u, x, null, new VNode(i.toArray())) : o.withMutations(function (s) { - s.setSize(u), + (s.setSize(u), i.forEach(function (o, i) { return s.set(i, o); - }); + })); })); } function isList(s) { return !(!s || !s[Ye]); } - createClass(List, IndexedCollection), + (createClass(List, IndexedCollection), (List.of = function () { return this(arguments); }), @@ -7143,7 +7165,6 @@ for ( var i, u = 0, _ = iterateList(this, o); (i = _()) !== tt && !1 !== s(i, u++, this); - ); return u; }), @@ -7162,13 +7183,13 @@ ) : ((this.__ownerID = s), this); }), - (List.isList = isList); + (List.isList = isList)); var Ye = '@@__IMMUTABLE_LIST__@@', Xe = List.prototype; function VNode(s, o) { - (this.array = s), (this.ownerID = o); + ((this.array = s), (this.ownerID = o)); } - (Xe[Ye] = !0), + ((Xe[Ye] = !0), (Xe[w] = Xe.remove), (Xe.setIn = $e.setIn), (Xe.deleteIn = Xe.removeIn = $e.removeIn), @@ -7193,7 +7214,7 @@ if (w && !_) return this; var L = editableVNode(this, s); if (!w) for (var B = 0; B < u; B++) L.array[B] = void 0; - return _ && (L.array[u] = _), L; + return (_ && (L.array[u] = _), L); }), (VNode.prototype.removeAfter = function (s, o, i) { if (i === (o ? 1 << o : 0) || 0 === this.array.length) return this; @@ -7206,8 +7227,8 @@ return this; } var C = editableVNode(this, s); - return C.array.splice(_ + 1), u && (C.array[_] = u), C; - }); + return (C.array.splice(_ + 1), u && (C.array[_] = u), C); + })); var Qe, et, tt = {}; @@ -7318,12 +7339,12 @@ if (o >= getTailOffset(s._capacity)) return s._tail; if (o < 1 << (s._level + x)) { for (var i = s._root, u = s._level; i && u > 0; ) - (i = i.array[(o >>> u) & j]), (u -= x); + ((i = i.array[(o >>> u) & j]), (u -= x)); return i; } } function setListBounds(s, o, i) { - void 0 !== o && (o |= 0), void 0 !== i && (i |= 0); + (void 0 !== o && (o |= 0), void 0 !== i && (i |= 0)); var u = s.__ownerID || new OwnerID(), _ = s._origin, w = s._capacity, @@ -7332,10 +7353,10 @@ if (C === _ && L === w) return s; if (C >= L) return s.clear(); for (var B = s._level, $ = s._root, V = 0; C + V < 0; ) - ($ = new VNode($ && $.array.length ? [void 0, $] : [], u)), (V += 1 << (B += x)); + (($ = new VNode($ && $.array.length ? [void 0, $] : [], u)), (V += 1 << (B += x))); V && ((C += V), (_ += V), (L += V), (w += V)); for (var U = getTailOffset(w), z = getTailOffset(L); z >= 1 << (B + x); ) - ($ = new VNode($ && $.array.length ? [$] : [], u)), (B += x); + (($ = new VNode($ && $.array.length ? [$] : [], u)), (B += x)); var Y = s._tail, Z = z < U ? listNodeFor(s, L - 1) : z > U ? new VNode([], u) : Y; if (Y && z > U && C < w && Y.array.length) { @@ -7346,16 +7367,16 @@ ee.array[(U >>> x) & j] = Y; } if ((L < w && (Z = Z && Z.removeAfter(u, 0, L)), C >= z)) - (C -= z), (L -= z), (B = x), ($ = null), (Z = Z && Z.removeBefore(u, 0, C)); + ((C -= z), (L -= z), (B = x), ($ = null), (Z = Z && Z.removeBefore(u, 0, C))); else if (C > _ || z < U) { for (V = 0; $; ) { var le = (C >>> B) & j; if ((le !== z >>> B) & j) break; - le && (V += (1 << B) * le), (B -= x), ($ = $.array[le]); + (le && (V += (1 << B) * le), (B -= x), ($ = $.array[le])); } - $ && C > _ && ($ = $.removeBefore(u, B, C - V)), + ($ && C > _ && ($ = $.removeBefore(u, B, C - V)), $ && z < U && ($ = $.removeAfter(u, B, z - V)), - V && ((C -= V), (L -= V)); + V && ((C -= V), (L -= V))); } return s.__ownerID ? ((s.size = L - C), @@ -7373,14 +7394,14 @@ for (var u = [], _ = 0, w = 0; w < i.length; w++) { var x = i[w], C = IndexedIterable(x); - C.size > _ && (_ = C.size), + (C.size > _ && (_ = C.size), isIterable(x) || (C = C.map(function (s) { return fromJS(s); })), - u.push(C); + u.push(C)); } - return _ > s.size && (s = s.setSize(_)), mergeIntoCollectionWith(s, o, u); + return (_ > s.size && (s = s.setSize(_)), mergeIntoCollectionWith(s, o, u)); } function getTailOffset(s) { return s < C ? 0 : ((s - 1) >>> x) << x; @@ -7392,10 +7413,10 @@ ? s : emptyOrderedMap().withMutations(function (o) { var i = KeyedIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s, i) { return o.set(i, s); - }); + })); }); } function isOrderedMap(s) { @@ -7438,23 +7459,23 @@ : ((u = w.remove(o)), (_ = j === x.size - 1 ? x.pop() : x.set(j, void 0))); } else if (B) { if (i === x.get(j)[1]) return s; - (u = w), (_ = x.set(j, [o, i])); - } else (u = w.set(o, x.size)), (_ = x.set(x.size, [o, i])); + ((u = w), (_ = x.set(j, [o, i]))); + } else ((u = w.set(o, x.size)), (_ = x.set(x.size, [o, i]))); return s.__ownerID ? ((s.size = u.size), (s._map = u), (s._list = _), (s.__hash = void 0), s) : makeOrderedMap(u, _); } function ToKeyedSequence(s, o) { - (this._iter = s), (this._useKeys = o), (this.size = s.size); + ((this._iter = s), (this._useKeys = o), (this.size = s.size)); } function ToIndexedSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function ToSetSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function FromEntriesSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function flipFactory(s) { var o = makeSequence(s); @@ -7493,7 +7514,7 @@ var s = u.next(); if (!s.done) { var o = s.value[0]; - (s.value[0] = s.value[1]), (s.value[1] = o); + ((s.value[0] = s.value[1]), (s.value[1] = o)); } return s; }); @@ -7590,7 +7611,7 @@ C = 0; return ( s.__iterate(function (s, w, j) { - if (o.call(i, s, w, j)) return C++, _(s, u ? w : C - 1, x); + if (o.call(i, s, w, j)) return (C++, _(s, u ? w : C - 1, x)); }, w), C ); @@ -7628,7 +7649,7 @@ _ = (isOrdered(s) ? OrderedMap() : Map()).asMutable(); s.__iterate(function (w, x) { _.update(o.call(i, w, x, s), function (s) { - return (s = s || []).push(u ? [x, w] : w), s; + return ((s = s || []).push(u ? [x, w] : w), s); }); }); var w = iterableClass(s); @@ -7669,7 +7690,7 @@ return ( s.__iterate(function (s, i) { if (!j || !(j = x++ < w)) - return L++, !1 !== o(s, u ? i : L - 1, _) && L !== C; + return (L++, !1 !== o(s, u ? i : L - 1, _) && L !== C); }), L ); @@ -7737,7 +7758,7 @@ j = 0; return ( s.__iterate(function (s, w, L) { - if (!C || !(C = o.call(i, s, w, L))) return j++, _(s, u ? w : j - 1, x); + if (!C || !(C = o.call(i, s, w, L))) return (j++, _(s, u ? w : j - 1, x)); }), j ); @@ -7756,7 +7777,7 @@ ? s : iteratorValue(_, L++, _ === V ? void 0 : s.value[1], s); var $ = s.value; - (w = $[0]), (B = $[1]), j && (j = o.call(i, B, w, x)); + ((w = $[0]), (B = $[1]), j && (j = o.call(i, B, w, x))); } while (j); return _ === z ? s : iteratorValue(_, w, B, s); }); @@ -7815,7 +7836,7 @@ ); }, _); } - return flatDeep(s, 0), w; + return (flatDeep(s, 0), w); }), (u.__iteratorUncached = function (u, _) { var w = s.__iterator(u, _), @@ -7828,7 +7849,7 @@ var j = s.value; if ((u === z && (j = j[1]), (o && !(x.length < o)) || !isIterable(j))) return i ? s : iteratorValue(u, C++, j, s); - x.push(w), (w = j.__iterator(u, _)); + (x.push(w), (w = j.__iterator(u, _))); } else w = x.pop(); } return iteratorDone(); @@ -7934,13 +7955,12 @@ for ( var i, u = this.__iterator(U, o), _ = 0; !(i = u.next()).done && !1 !== s(i.value, _++, this); - ); return _; }), (u.__iteratorUncached = function (s, u) { var _ = i.map(function (s) { - return (s = Iterable(s)), getIterator(u ? s.reverse() : s); + return ((s = Iterable(s)), getIterator(u ? s.reverse() : s)); }), w = 0, x = !1; @@ -7979,7 +7999,7 @@ if (s !== Object(s)) throw new TypeError('Expected [K, V] tuple: ' + s); } function resolveSize(s) { - return assertNotInfinite(s.size), ensureSize(s); + return (assertNotInfinite(s.size), ensureSize(s)); } function iterableClass(s) { return isKeyed(s) ? KeyedIterable : isIndexed(s) ? IndexedIterable : SetIterable; @@ -8013,18 +8033,18 @@ if (!i) { i = !0; var x = Object.keys(s); - setProps(_, x), + (setProps(_, x), (_.size = x.length), (_._name = o), (_._keys = x), - (_._defaultValues = s); + (_._defaultValues = s)); } this._map = Map(w); }, _ = (u.prototype = Object.create(rt)); - return (_.constructor = u), u; + return ((_.constructor = u), u); } - createClass(OrderedMap, Map), + (createClass(OrderedMap, Map), (OrderedMap.of = function () { return this(arguments); }), @@ -8211,7 +8231,7 @@ return this._map ? this._map.get(s, i) : i; }), (Record.prototype.clear = function () { - if (this.__ownerID) return this._map && this._map.clear(), this; + if (this.__ownerID) return (this._map && this._map.clear(), this); var s = this.constructor; return s._empty || (s._empty = makeRecord(this, emptyMap())); }), @@ -8250,11 +8270,11 @@ if (s === this.__ownerID) return this; var o = this._map && this._map.__ensureOwner(s); return s ? makeRecord(this, o, s) : ((this.__ownerID = s), (this._map = o), this); - }); + })); var rt = Record.prototype; function makeRecord(s, o, i) { var u = Object.create(Object.getPrototypeOf(s)); - return (u._map = o), (u.__ownerID = i), u; + return ((u._map = o), (u.__ownerID = i), u); } function recordName(s) { return s._name || s.constructor.name || 'Record'; @@ -8270,7 +8290,7 @@ return this.get(o); }, set: function (s) { - invariant(this.__ownerID, 'Cannot set on an immutable record.'), this.set(o, s); + (invariant(this.__ownerID, 'Cannot set on an immutable record.'), this.set(o, s)); } }); } @@ -8281,16 +8301,16 @@ ? s : emptySet().withMutations(function (o) { var i = SetIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s) { return o.add(s); - }); + })); }); } function isSet(s) { return !(!s || !s[st]); } - (rt[w] = rt.remove), + ((rt[w] = rt.remove), (rt.deleteIn = rt.removeIn = $e.removeIn), (rt.merge = $e.merge), (rt.mergeWith = $e.mergeWith), @@ -8406,7 +8426,7 @@ var o = this._map.__ensureOwner(s); return s ? this.__make(o, s) : ((this.__ownerID = s), (this._map = o), this); }), - (Set.isSet = isSet); + (Set.isSet = isSet)); var nt, st = '@@__IMMUTABLE_SET__@@', ot = Set.prototype; @@ -8421,7 +8441,7 @@ } function makeSet(s, o) { var i = Object.create(ot); - return (i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i; + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i); } function emptySet() { return nt || (nt = makeSet(emptyMap())); @@ -8433,16 +8453,16 @@ ? s : emptyOrderedSet().withMutations(function (o) { var i = SetIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s) { return o.add(s); - }); + })); }); } function isOrderedSet(s) { return isSet(s) && isOrdered(s); } - (ot[st] = !0), + ((ot[st] = !0), (ot[w] = ot.remove), (ot.mergeDeep = ot.merge), (ot.mergeDeepWith = ot.mergeWith), @@ -8461,12 +8481,12 @@ (OrderedSet.prototype.toString = function () { return this.__toString('OrderedSet {', '}'); }), - (OrderedSet.isOrderedSet = isOrderedSet); + (OrderedSet.isOrderedSet = isOrderedSet)); var it, at = OrderedSet.prototype; function makeOrderedSet(s, o) { var i = Object.create(at); - return (i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i; + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i); } function emptyOrderedSet() { return it || (it = makeOrderedSet(emptyOrderedMap())); @@ -8477,7 +8497,7 @@ function isStack(s) { return !(!s || !s[ct]); } - (at[_] = !0), + ((at[_] = !0), (at.__empty = emptyOrderedSet), (at.__make = makeOrderedSet), createClass(Stack, IndexedCollection), @@ -8518,7 +8538,7 @@ i = this._head; return ( s.reverse().forEach(function (s) { - o++, (i = { value: s, next: i }); + (o++, (i = { value: s, next: i })); }), this.__ownerID ? ((this.size = o), @@ -8585,12 +8605,12 @@ return new Iterator(function () { if (u) { var o = u.value; - return (u = u.next), iteratorValue(s, i++, o); + return ((u = u.next), iteratorValue(s, i++, o)); } return iteratorDone(); }); }), - (Stack.isStack = isStack); + (Stack.isStack = isStack)); var lt, ct = '@@__IMMUTABLE_STACK__@@', ut = Stack.prototype; @@ -8618,7 +8638,7 @@ s ); } - (ut[ct] = !0), + ((ut[ct] = !0), (ut.withMutations = $e.withMutations), (ut.asMutable = $e.asMutable), (ut.asImmutable = $e.asImmutable), @@ -8717,7 +8737,7 @@ var i = !0; return ( this.__iterate(function (u, _, w) { - if (!s.call(o, u, _, w)) return (i = !1), !1; + if (!s.call(o, u, _, w)) return ((i = !1), !1); }), i ); @@ -8730,15 +8750,15 @@ return u ? u[1] : i; }, forEach: function (s, o) { - return assertNotInfinite(this.size), this.__iterate(o ? s.bind(o) : s); + return (assertNotInfinite(this.size), this.__iterate(o ? s.bind(o) : s)); }, join: function (s) { - assertNotInfinite(this.size), (s = void 0 !== s ? '' + s : ','); + (assertNotInfinite(this.size), (s = void 0 !== s ? '' + s : ',')); var o = '', i = !0; return ( this.__iterate(function (u) { - i ? (i = !1) : (o += s), (o += null != u ? u.toString() : ''); + (i ? (i = !1) : (o += s), (o += null != u ? u.toString() : '')); }), o ); @@ -8816,7 +8836,7 @@ var u = i; return ( this.__iterate(function (i, _, w) { - if (s.call(o, i, _, w)) return (u = [_, i]), !1; + if (s.call(o, i, _, w)) return ((u = [_, i]), !1); }), u ); @@ -8944,9 +8964,9 @@ hashCode: function () { return this.__hash || (this.__hash = hashIterable(this)); } - }); + })); var pt = Iterable.prototype; - (pt[o] = !0), + ((pt[o] = !0), (pt[ee] = pt.values), (pt.__toJS = pt.toArray), (pt.__toStringMapper = quoteString), @@ -8984,7 +9004,7 @@ .flip() ); } - }); + })); var ht = KeyedIterable.prototype; function keyMapper(s, o) { return o; @@ -9129,7 +9149,7 @@ var s = [this].concat(arrCopy(arguments)), o = zipWithFactory(this.toSeq(), IndexedSeq.of, s), i = o.flatten(!0); - return o.size && (i.size = o.size * s.length), reify(this, i); + return (o.size && (i.size = o.size * s.length), reify(this, i)); }, keySeq: function () { return Range(0, this.size); @@ -9148,7 +9168,7 @@ }, zipWith: function (s) { var o = arrCopy(arguments); - return (o[0] = this), reify(this, zipWithFactory(this, s, o)); + return ((o[0] = this), reify(this, zipWithFactory(this, s, o))); } }), (IndexedIterable.prototype[u] = !0), @@ -9204,9 +9224,9 @@ if (o) { s.super_ = o; var TempCtor = function () {}; - (TempCtor.prototype = o.prototype), + ((TempCtor.prototype = o.prototype), (s.prototype = new TempCtor()), - (s.prototype.constructor = s); + (s.prototype.constructor = s)); } }); }, @@ -9222,15 +9242,15 @@ ? window.URL.createObjectURL(_) : window.webkitURL.createObjectURL(_), x = document.createElement('a'); - (x.style.display = 'none'), + ((x.style.display = 'none'), (x.href = w), x.setAttribute('download', o), void 0 === x.download && x.setAttribute('target', '_blank'), document.body.appendChild(x), x.click(), setTimeout(function () { - document.body.removeChild(x), window.URL.revokeObjectURL(w); - }, 200); + (document.body.removeChild(x), window.URL.revokeObjectURL(w)); + }, 200)); } }; }, @@ -9291,7 +9311,7 @@ function invokeFunc(o) { var i = u, w = _; - return (u = _ = void 0), (L = o), (x = s.apply(w, i)); + return ((u = _ = void 0), (L = o), (x = s.apply(w, i))); } function shouldInvoke(s) { var i = s - j; @@ -9309,7 +9329,7 @@ ); } function trailingEdge(s) { - return (C = void 0), z && u ? invokeFunc(s) : ((u = _ = void 0), x); + return ((C = void 0), z && u ? invokeFunc(s) : ((u = _ = void 0), x)); } function debounced() { var s = now(), @@ -9317,11 +9337,11 @@ if (((u = arguments), (_ = this), (j = s), i)) { if (void 0 === C) return (function leadingEdge(s) { - return (L = s), (C = setTimeout(timerExpired, o)), B ? invokeFunc(s) : x; + return ((L = s), (C = setTimeout(timerExpired, o)), B ? invokeFunc(s) : x); })(j); - if ($) return (C = setTimeout(timerExpired, o)), invokeFunc(j); + if ($) return ((C = setTimeout(timerExpired, o)), invokeFunc(j)); } - return void 0 === C && (C = setTimeout(timerExpired, o)), x; + return (void 0 === C && (C = setTimeout(timerExpired, o)), x); } return ( (o = toNumber(o) || 0), @@ -9330,7 +9350,7 @@ (w = ($ = 'maxWait' in i) ? V(toNumber(i.maxWait) || 0, o) : w), (z = 'trailing' in i ? !!i.trailing : z)), (debounced.cancel = function cancel() { - void 0 !== C && clearTimeout(C), (L = 0), (u = j = _ = C = void 0); + (void 0 !== C && clearTimeout(C), (L = 0), (u = j = _ = C = void 0)); }), (debounced.flush = function flush() { return void 0 === C ? x : trailingEdge(now()); @@ -9357,28 +9377,28 @@ this.set(u[0], u[1]); } } - (Hash.prototype.clear = u), + ((Hash.prototype.clear = u), (Hash.prototype.delete = _), (Hash.prototype.get = w), (Hash.prototype.has = x), (Hash.prototype.set = C), - (s.exports = Hash); + (s.exports = Hash)); }, 30980: (s, o, i) => { var u = i(39344), _ = i(94033); function LazyWrapper(s) { - (this.__wrapped__ = s), + ((this.__wrapped__ = s), (this.__actions__ = []), (this.__dir__ = 1), (this.__filtered__ = !1), (this.__iteratees__ = []), (this.__takeCount__ = 4294967295), - (this.__views__ = []); + (this.__views__ = [])); } - (LazyWrapper.prototype = u(_.prototype)), + ((LazyWrapper.prototype = u(_.prototype)), (LazyWrapper.prototype.constructor = LazyWrapper), - (s.exports = LazyWrapper); + (s.exports = LazyWrapper)); }, 80079: (s, o, i) => { var u = i(63702), @@ -9394,26 +9414,26 @@ this.set(u[0], u[1]); } } - (ListCache.prototype.clear = u), + ((ListCache.prototype.clear = u), (ListCache.prototype.delete = _), (ListCache.prototype.get = w), (ListCache.prototype.has = x), (ListCache.prototype.set = C), - (s.exports = ListCache); + (s.exports = ListCache)); }, 56017: (s, o, i) => { var u = i(39344), _ = i(94033); function LodashWrapper(s, o) { - (this.__wrapped__ = s), + ((this.__wrapped__ = s), (this.__actions__ = []), (this.__chain__ = !!o), (this.__index__ = 0), - (this.__values__ = void 0); + (this.__values__ = void 0)); } - (LodashWrapper.prototype = u(_.prototype)), + ((LodashWrapper.prototype = u(_.prototype)), (LodashWrapper.prototype.constructor = LodashWrapper), - (s.exports = LodashWrapper); + (s.exports = LodashWrapper)); }, 68223: (s, o, i) => { var u = i(56110)(i(9325), 'Map'); @@ -9433,12 +9453,12 @@ this.set(u[0], u[1]); } } - (MapCache.prototype.clear = u), + ((MapCache.prototype.clear = u), (MapCache.prototype.delete = _), (MapCache.prototype.get = w), (MapCache.prototype.has = x), (MapCache.prototype.set = C), - (s.exports = MapCache); + (s.exports = MapCache)); }, 32804: (s, o, i) => { var u = i(56110)(i(9325), 'Promise'); @@ -9457,9 +9477,9 @@ i = null == s ? 0 : s.length; for (this.__data__ = new u(); ++o < i; ) this.add(s[o]); } - (SetCache.prototype.add = SetCache.prototype.push = _), + ((SetCache.prototype.add = SetCache.prototype.push = _), (SetCache.prototype.has = w), - (s.exports = SetCache); + (s.exports = SetCache)); }, 37217: (s, o, i) => { var u = i(80079), @@ -9472,12 +9492,12 @@ var o = (this.__data__ = new u(s)); this.size = o.size; } - (Stack.prototype.clear = _), + ((Stack.prototype.clear = _), (Stack.prototype.delete = w), (Stack.prototype.get = x), (Stack.prototype.has = C), (Stack.prototype.set = j), - (s.exports = Stack); + (s.exports = Stack)); }, 51873: (s, o, i) => { var u = i(9325).Symbol; @@ -9664,7 +9684,7 @@ be = '[object Function]', _e = '[object Object]', we = {}; - (we[ye] = + ((we[ye] = we['[object Array]'] = we['[object ArrayBuffer]'] = we['[object DataView]'] = @@ -9713,7 +9733,7 @@ Pe || (Pe = new u()); var Ye = Pe.get(s); if (Ye) return Ye; - Pe.set(s, Te), + (Pe.set(s, Te), pe(s) ? s.forEach(function (u) { Te.add(baseClone(u, o, i, u, s, Pe)); @@ -9721,15 +9741,15 @@ : le(s) && s.forEach(function (u, _) { Te.set(_, baseClone(u, o, i, _, s, Pe)); - }); + })); var Xe = ze ? void 0 : ($e ? (qe ? U : V) : qe ? fe : de)(s); return ( _(Xe || s, function (u, _) { - Xe && (u = s[(_ = u)]), w(Te, _, baseClone(u, o, i, _, s, Pe)); + (Xe && (u = s[(_ = u)]), w(Te, _, baseClone(u, o, i, _, s, Pe))); }), Te ); - }); + })); }, 39344: (s, o, i) => { var u = i(23805), @@ -9741,7 +9761,7 @@ if (_) return _(s); object.prototype = s; var o = new object(); - return (object.prototype = void 0), o; + return ((object.prototype = void 0), o); }; })(); s.exports = w; @@ -9878,11 +9898,12 @@ fe = le == ce; if (fe && L(s)) { if (!L(o)) return !1; - (ie = !0), (pe = !1); + ((ie = !0), (pe = !1)); } if (fe && !pe) return ( - ee || (ee = new u()), ie || B(s) ? _(s, o, i, Y, Z, ee) : w(s, o, le, i, Y, Z, ee) + ee || (ee = new u()), + ie || B(s) ? _(s, o, i, Y, Z, ee) : w(s, o, le, i, Y, Z, ee) ); if (!(1 & i)) { var ye = pe && z.call(s, '__wrapped__'), @@ -9890,7 +9911,7 @@ if (ye || be) { var _e = ye ? s.value() : s, we = be ? o.value() : o; - return ee || (ee = new u()), Z(_e, we, i, Y, ee); + return (ee || (ee = new u()), Z(_e, we, i, Y, ee)); } } return !!fe && (ee || (ee = new u()), x(s, o, i, Y, Z, ee)); @@ -9968,7 +9989,7 @@ _ = i(30294), w = i(40346), x = {}; - (x['[object Float32Array]'] = + ((x['[object Float32Array]'] = x['[object Float64Array]'] = x['[object Int8Array]'] = x['[object Int16Array]'] = @@ -9996,7 +10017,7 @@ !1), (s.exports = function baseIsTypedArray(s) { return w(s) && _(s.length) && !!x[u(s)]; - }); + })); }, 15389: (s, o, i) => { var u = i(93663), @@ -10089,7 +10110,7 @@ if (($ || ($ = new u()), C(w))) x(s, o, j, i, baseMerge, B, $); else { var V = B ? B(L(s, j), w, j + '', s, o, $) : void 0; - void 0 === V && (V = w), _(s, j, V); + (void 0 === V && (V = w), _(s, j, V)); } }, j @@ -10124,7 +10145,7 @@ var _e = L(de), we = !_e && $(de), Se = !_e && !we && Y(de); - (ye = de), + ((ye = de), _e || we || Se ? L(pe) ? (ye = pe) @@ -10137,9 +10158,9 @@ : (ye = []) : z(de) || j(de) ? ((ye = pe), j(pe) ? (ye = ee(pe)) : (U(pe) && !V(pe)) || (ye = C(de))) - : (be = !1); + : (be = !1)); } - be && (ce.set(de, ye), ae(ye, de, ie, le, ce), ce.delete(de)), u(s, i, ye); + (be && (ce.set(de, ye), ae(ye, de, ie, le, ce), ce.delete(de)), u(s, i, ye)); } }; }, @@ -10199,7 +10220,7 @@ var Y = V[U]; void 0 === (z = j ? j(Y, U, V) : void 0) && (z = x(Y) ? Y : w(o[L + 1]) ? [] : {}); } - u(V, U, z), (V = V[U]); + (u(V, U, z), (V = V[U])); } return s; }; @@ -10209,7 +10230,7 @@ _ = i(48152), w = _ ? function (s, o) { - return _.set(s, o), s; + return (_.set(s, o), s); } : u; s.exports = w; @@ -10234,10 +10255,10 @@ s.exports = function baseSlice(s, o, i) { var u = -1, _ = s.length; - o < 0 && (o = -o > _ ? 0 : _ + o), + (o < 0 && (o = -o > _ ? 0 : _ + o), (i = i > _ ? _ : i) < 0 && (i += _), (_ = o > i ? 0 : (i - o) >>> 0), - (o >>>= 0); + (o >>>= 0)); for (var w = Array(_); ++u < _; ) w[u] = s[u + o]; return w; }; @@ -10295,7 +10316,7 @@ w = i(68969), x = i(77797); s.exports = function baseUnset(s, o) { - return (o = u(o, s)), null == (s = w(s, o)) || delete s[x(_(o))]; + return ((o = u(o, s)), null == (s = w(s, o)) || delete s[x(_(o))]); }; }, 51234: (s) => { @@ -10325,14 +10346,14 @@ var u = i(25160); s.exports = function castSlice(s, o, i) { var _ = s.length; - return (i = void 0 === i ? _ : i), !o && i >= _ ? s : u(s, o, i); + return ((i = void 0 === i ? _ : i), !o && i >= _ ? s : u(s, o, i)); }; }, 49653: (s, o, i) => { var u = i(37828); s.exports = function cloneArrayBuffer(s) { var o = new s.constructor(s.byteLength); - return new u(o).set(new u(s)), o; + return (new u(o).set(new u(s)), o); }; }, 93290: (s, o, i) => { @@ -10346,7 +10367,7 @@ if (o) return s.slice(); var i = s.length, u = C ? C(i) : new s.constructor(i); - return s.copy(u), u; + return (s.copy(u), u); }; }, 76169: (s, o, i) => { @@ -10360,7 +10381,7 @@ var o = /\w*$/; s.exports = function cloneRegExp(s) { var i = new s.constructor(s.source, o.exec(s)); - return (i.lastIndex = s.lastIndex), i; + return ((i.lastIndex = s.lastIndex), i); }; }, 93736: (s, o, i) => { @@ -10391,7 +10412,6 @@ $ = Array(L + B), V = !_; ++j < L; - ) $[j] = i[j]; for (; ++w < C; ) (V || w < x) && ($[u[w]] = s[w]); @@ -10413,7 +10433,6 @@ V = Array($ + B), U = !_; ++w < $; - ) V[w] = s[w]; for (var z = w; ++L < B; ) V[z + L] = i[L]; @@ -10438,7 +10457,7 @@ for (var C = -1, j = o.length; ++C < j; ) { var L = o[C], B = w ? w(i[L], s[L], L, i, s) : void 0; - void 0 === B && (B = s[L]), x ? _(i, L, B) : u(i, L, B); + (void 0 === B && (B = s[L]), x ? _(i, L, B) : u(i, L, B)); } return i; }; @@ -10481,7 +10500,6 @@ C && _(i[0], i[1], C) && ((x = w < 3 ? void 0 : x), (w = 1)), o = Object(o); ++u < w; - ) { var j = i[u]; j && s(o, j, u, x); @@ -10499,7 +10517,6 @@ for ( var w = i.length, x = o ? w : -1, C = Object(i); (o ? x-- : ++x < w) && !1 !== _(C[x], x, C); - ); return i; }; @@ -10615,10 +10632,10 @@ var C = Object(o); if (!_(o)) { var j = u(i, 3); - (o = w(o)), + ((o = w(o)), (i = function (s) { return j(C[s], s, C); - }); + })); } var L = s(o, i, x); return L > -1 ? C[j ? o[L] : L] : void 0; @@ -10685,7 +10702,6 @@ $ = Array(B + _), V = this && this !== w && this instanceof wrapper ? j : s; ++L < B; - ) $[L] = x[L]; for (; _--; ) $[L++] = arguments[++o]; @@ -10699,7 +10715,7 @@ w = i(70981); s.exports = function createRecurry(s, o, i, x, C, j, L, B, $, V) { var U = 8 & o; - (o |= U ? 32 : 64), 4 & (o &= ~(U ? 64 : 32)) || (o &= -4); + ((o |= U ? 32 : 64), 4 & (o &= ~(U ? 64 : 32)) || (o &= -4)); var z = [ s, o, @@ -10713,7 +10729,7 @@ V ], Y = i.apply(void 0, z); - return u(s) && _(Y, z), (Y.placeholder = x), w(Y, s, o); + return (u(s) && _(Y, z), (Y.placeholder = x), w(Y, s, o)); }; }, 66977: (s, o, i) => { @@ -10973,7 +10989,7 @@ _ = (function () { try { var s = u(Object, 'defineProperty'); - return s({}, '', {}), s; + return (s({}, '', {}), s); } catch (s) {} })(); s.exports = _; @@ -11016,7 +11032,7 @@ break; } } - return j.delete(s), j.delete(o), Y; + return (j.delete(s), j.delete(o), Y); }; }, 21986: (s, o, i) => { @@ -11032,7 +11048,7 @@ switch (i) { case '[object DataView]': if (s.byteLength != o.byteLength || s.byteOffset != o.byteOffset) return !1; - (s = s.buffer), (o = o.buffer); + ((s = s.buffer), (o = o.buffer)); case '[object ArrayBuffer]': return !(s.byteLength != o.byteLength || !$(new _(s), new _(o))); case '[object Boolean]': @@ -11051,9 +11067,9 @@ if ((U || (U = j), s.size != o.size && !z)) return !1; var Y = V.get(s); if (Y) return Y == o; - (u |= 2), V.set(s, o); + ((u |= 2), V.set(s, o)); var Z = x(U(s), U(o), u, L, $, V); - return V.delete(s), Z; + return (V.delete(s), Z); case '[object Symbol]': if (B) return B.call(s) == B.call(o); } @@ -11076,7 +11092,7 @@ z = C.get(o); if (U && z) return U == o && z == s; var Y = !0; - C.set(s, o), C.set(o, s); + (C.set(s, o), C.set(o, s)); for (var Z = j; ++$ < B; ) { var ee = s[(V = L[$])], ie = o[V]; @@ -11099,7 +11115,7 @@ ce instanceof ce) || (Y = !1); } - return C.delete(s), C.delete(o), Y; + return (C.delete(s), C.delete(o), Y); }; }, 38816: (s, o, i) => { @@ -11202,7 +11218,7 @@ var u = !0; } catch (s) {} var _ = x.call(s); - return u && (o ? (s[C] = i) : delete s[C]), _; + return (u && (o ? (s[C] = i) : delete s[C]), _); }; }, 4664: (s, o, i) => { @@ -11229,7 +11245,7 @@ x = i(63345), C = Object.getOwnPropertySymbols ? function (s) { - for (var o = []; s; ) u(o, w(s)), (s = _(s)); + for (var o = []; s; ) (u(o, w(s)), (s = _(s))); return o; } : x; @@ -11254,7 +11270,7 @@ ie = L(x), ae = L(C), le = j; - ((u && le(new u(new ArrayBuffer(1))) != z) || + (((u && le(new u(new ArrayBuffer(1))) != z) || (_ && le(new _()) != B) || (w && le(w.resolve()) != $) || (x && le(new x()) != V) || @@ -11278,7 +11294,7 @@ } return o; }), - (s.exports = le); + (s.exports = le)); }, 10392: (s) => { s.exports = function getValue(s, o) { @@ -11328,13 +11344,13 @@ 22032: (s, o, i) => { var u = i(81042); s.exports = function hashClear() { - (this.__data__ = u ? u(null) : {}), (this.size = 0); + ((this.__data__ = u ? u(null) : {}), (this.size = 0)); }; }, 63862: (s) => { s.exports = function hashDelete(s) { var o = this.has(s) && delete this.__data__[s]; - return (this.size -= o ? 1 : 0), o; + return ((this.size -= o ? 1 : 0), o); }; }, 66721: (s, o, i) => { @@ -11540,7 +11556,7 @@ }, 63702: (s) => { s.exports = function listCacheClear() { - (this.__data__ = []), (this.size = 0); + ((this.__data__ = []), (this.size = 0)); }; }, 70080: (s, o, i) => { @@ -11571,7 +11587,7 @@ s.exports = function listCacheSet(s, o) { var i = this.__data__, _ = u(i, s); - return _ < 0 ? (++this.size, i.push([s, o])) : (i[_][1] = o), this; + return (_ < 0 ? (++this.size, i.push([s, o])) : (i[_][1] = o), this); }; }, 63040: (s, o, i) => { @@ -11579,15 +11595,15 @@ _ = i(80079), w = i(68223); s.exports = function mapCacheClear() { - (this.size = 0), - (this.__data__ = { hash: new u(), map: new (w || _)(), string: new u() }); + ((this.size = 0), + (this.__data__ = { hash: new u(), map: new (w || _)(), string: new u() })); }; }, 17670: (s, o, i) => { var u = i(12651); s.exports = function mapCacheDelete(s) { var o = u(this, s).delete(s); - return (this.size -= o ? 1 : 0), o; + return ((this.size -= o ? 1 : 0), o); }; }, 90289: (s, o, i) => { @@ -11607,7 +11623,7 @@ s.exports = function mapCacheSet(s, o) { var i = u(this, s), _ = i.size; - return i.set(s, o), (this.size += i.size == _ ? 0 : 1), this; + return (i.set(s, o), (this.size += i.size == _ ? 0 : 1), this); }; }, 20317: (s) => { @@ -11633,7 +11649,7 @@ var u = i(50104); s.exports = function memoizeCapped(s) { var o = u(s, function (s) { - return 500 === i.size && i.clear(), s; + return (500 === i.size && i.clear(), s); }), i = o.cache; return o; @@ -11660,7 +11676,7 @@ var U = o[3]; if (U) { var z = s[3]; - (s[3] = z ? u(z, U, o[4]) : U), (s[4] = z ? w(s[3], x) : o[4]); + ((s[3] = z ? u(z, U, o[4]) : U), (s[4] = z ? w(s[3], x) : o[4])); } return ( (U = o[5]) && @@ -11732,7 +11748,7 @@ j[x] = w[o + x]; x = -1; for (var L = Array(o + 1); ++x < o; ) L[x] = w[x]; - return (L[o] = i(j)), u(s, this, L); + return ((L[o] = i(j)), u(s, this, L)); } ); }; @@ -11782,7 +11798,7 @@ }, 31380: (s) => { s.exports = function setCacheAdd(s) { - return this.__data__.set(s, '__lodash_hash_undefined__'), this; + return (this.__data__.set(s, '__lodash_hash_undefined__'), this); }; }, 51459: (s) => { @@ -11840,14 +11856,14 @@ 51420: (s, o, i) => { var u = i(80079); s.exports = function stackClear() { - (this.__data__ = new u()), (this.size = 0); + ((this.__data__ = new u()), (this.size = 0)); }; }, 90938: (s) => { s.exports = function stackDelete(s) { var o = this.__data__, i = o.delete(s); - return (this.size = o.size), i; + return ((this.size = o.size), i); }; }, 63605: (s) => { @@ -11868,10 +11884,10 @@ var i = this.__data__; if (i instanceof u) { var x = i.__data__; - if (!_ || x.length < 199) return x.push([s, o]), (this.size = ++i.size), this; + if (!_ || x.length < 199) return (x.push([s, o]), (this.size = ++i.size), this); i = this.__data__ = new w(x); } - return i.set(s, o), (this.size = i.size), this; + return (i.set(s, o), (this.size = i.size), this); }; }, 76959: (s) => { @@ -12044,7 +12060,7 @@ 84058: (s, o, i) => { var u = i(14792), _ = i(45539)(function (s, o, i) { - return (o = o.toLowerCase()), s + (i ? u(o) : o); + return ((o = o.toLowerCase()), s + (i ? u(o) : o)); }); s.exports = _; }, @@ -12072,9 +12088,9 @@ var u = i(66977); function curry(s, o, i) { var _ = u(s, 8, void 0, void 0, void 0, void 0, void 0, (o = i ? void 0 : o)); - return (_.placeholder = curry.placeholder), _; + return ((_.placeholder = curry.placeholder), _); } - (curry.placeholder = {}), (s.exports = curry); + ((curry.placeholder = {}), (s.exports = curry)); }, 38221: (s, o, i) => { var u = i(23805), @@ -12097,7 +12113,7 @@ function invokeFunc(o) { var i = j, u = L; - return (j = L = void 0), (z = o), ($ = s.apply(u, i)); + return ((j = L = void 0), (z = o), ($ = s.apply(u, i))); } function shouldInvoke(s) { var i = s - U; @@ -12115,7 +12131,7 @@ ); } function trailingEdge(s) { - return (V = void 0), ee && j ? invokeFunc(s) : ((j = L = void 0), $); + return ((V = void 0), ee && j ? invokeFunc(s) : ((j = L = void 0), $)); } function debounced() { var s = _(), @@ -12123,11 +12139,11 @@ if (((j = arguments), (L = this), (U = s), i)) { if (void 0 === V) return (function leadingEdge(s) { - return (z = s), (V = setTimeout(timerExpired, o)), Y ? invokeFunc(s) : $; + return ((z = s), (V = setTimeout(timerExpired, o)), Y ? invokeFunc(s) : $); })(U); - if (Z) return clearTimeout(V), (V = setTimeout(timerExpired, o)), invokeFunc(U); + if (Z) return (clearTimeout(V), (V = setTimeout(timerExpired, o)), invokeFunc(U)); } - return void 0 === V && (V = setTimeout(timerExpired, o)), $; + return (void 0 === V && (V = setTimeout(timerExpired, o)), $); } return ( (o = w(o) || 0), @@ -12136,7 +12152,7 @@ (B = (Z = 'maxWait' in i) ? x(w(i.maxWait) || 0, o) : B), (ee = 'trailing' in i ? !!i.trailing : ee)), (debounced.cancel = function cancel() { - void 0 !== V && clearTimeout(V), (z = 0), (j = U = L = V = void 0); + (void 0 !== V && clearTimeout(V), (z = 0), (j = U = L = V = void 0)); }), (debounced.flush = function flush() { return void 0 === V ? $ : trailingEdge(_()); @@ -12180,7 +12196,7 @@ var C = null == s ? 0 : s.length; if (!C) return -1; var j = null == i ? 0 : w(i); - return j < 0 && (j = x(C + j, 0)), u(s, _(o, 3), j); + return (j < 0 && (j = x(C + j, 0)), u(s, _(o, 3), j)); }; }, 35970: (s, o, i) => { @@ -12212,7 +12228,7 @@ if (i) { for (var u = Array(i); i--; ) u[i] = arguments[i]; var _ = (u[0] = o.apply(void 0, u)); - return s.apply(void 0, u), _; + return (s.apply(void 0, u), _); } }; } @@ -12357,7 +12373,9 @@ var x = _[o], C = _.slice(0, o); return ( - x && w.apply(C, x), o != u && w.apply(C, _.slice(o + 1)), s.apply(this, C) + x && w.apply(C, x), + o != u && w.apply(C, _.slice(o + 1)), + s.apply(this, C) ); }; })(o, x); @@ -12373,12 +12391,11 @@ for ( var i = -1, u = (o = Te(o)).length, _ = u - 1, w = pe(Object(s)), x = w; null != x && ++i < u; - ) { var C = o[i], j = x[C]; - null == j || _e(j) || be(j) || we(j) || (x[C] = pe(i == _ ? j : Object(j))), - (x = x[C]); + (null == j || _e(j) || be(j) || we(j) || (x[C] = pe(i == _ ? j : Object(j))), + (x = x[C])); } return w; } @@ -12399,7 +12416,7 @@ if (!i) return s(); for (var u = Array(i); i--; ) u[i] = arguments[i]; var _ = U ? 0 : i - 1; - return (u[_] = o(u[_])), s.apply(void 0, u); + return ((u[_] = o(u[_])), s.apply(void 0, u)); }; } function wrap(s, o, i) { @@ -12469,7 +12486,7 @@ var o = $e[s]; if ('function' == typeof o) { for (var i = ze.length; i--; ) if (ze[i][0] == s) return; - (o.convert = createConverter(s, o)), ze.push([s, o]); + ((o.convert = createConverter(s, o)), ze.push([s, o])); } }), fe(ze, function (s) { @@ -12489,7 +12506,7 @@ }; }, 16962: (s, o) => { - (o.aliasToReal = { + ((o.aliasToReal = { each: 'forEach', eachRight: 'forEachRight', entries: 'toPairs', @@ -12982,7 +12999,7 @@ zip: !0, zipObject: !0, zipObjectDeep: !0 - }); + })); }, 47934: (s, o, i) => { s.exports = { @@ -13017,7 +13034,7 @@ }, 77731: (s, o, i) => { var u = i(79920)('set', i(63560)); - (u.placeholder = i(2874)), (s.exports = u); + ((u.placeholder = i(2874)), (s.exports = u)); }, 58156: (s, o, i) => { var u = i(47422); @@ -13291,11 +13308,11 @@ _ = memoized.cache; if (_.has(u)) return _.get(u); var w = s.apply(this, i); - return (memoized.cache = _.set(u, w) || _), w; + return ((memoized.cache = _.set(u, w) || _), w); }; - return (memoized.cache = new (memoize.Cache || u)()), memoized; + return ((memoized.cache = new (memoize.Cache || u)()), memoized); } - (memoize.Cache = u), (s.exports = memoize); + ((memoize.Cache = u), (s.exports = memoize)); }, 55364: (s, o, i) => { var u = i(85250), @@ -13345,11 +13362,11 @@ var i = {}; if (null == s) return i; var L = !1; - (o = u(o, function (o) { - return (o = x(o, s)), L || (L = o.length > 1), o; + ((o = u(o, function (o) { + return ((o = x(o, s)), L || (L = o.length > 1), o); })), C(s, B(s), i), - L && (i = _(i, 7, j)); + L && (i = _(i, 7, j))); for (var $ = o.length; $--; ) w(i, o[$]); return i; }); @@ -13398,7 +13415,7 @@ C = i(36800); s.exports = function some(s, o, i) { var j = x(s) ? u : w; - return i && C(s, o, i) && (o = void 0), j(s, _(o, 3)); + return (i && C(s, o, i) && (o = void 0), j(s, _(o, 3))); }; }, 63345: (s) => { @@ -13497,7 +13514,8 @@ x = i(22225); s.exports = function words(s, o, i) { return ( - (s = w(s)), void 0 === (o = i ? void 0 : o) ? (_(s) ? x(s) : u(s)) : s.match(o) || [] + (s = w(s)), + void 0 === (o = i ? void 0 : o) ? (_(s) ? x(s) : u(s)) : s.match(o) || [] ); }; }, @@ -13516,9 +13534,9 @@ } return new _(s); } - (lodash.prototype = w.prototype), + ((lodash.prototype = w.prototype), (lodash.prototype.constructor = lodash), - (s.exports = lodash); + (s.exports = lodash)); }, 47248: (s, o, i) => { var u = i(16547), @@ -13531,7 +13549,7 @@ 'use strict'; var u = i(45981), _ = i(85587); - (o.highlight = highlight), + ((o.highlight = highlight), (o.highlightAuto = function highlightAuto(s, o) { var i, x, @@ -13544,14 +13562,14 @@ U = -1; null == $ && ($ = w); if ('string' != typeof s) throw _('Expected `string` for value, got `%s`', s); - (x = { relevance: 0, language: null, value: [] }), - (i = { relevance: 0, language: null, value: [] }); + ((x = { relevance: 0, language: null, value: [] }), + (i = { relevance: 0, language: null, value: [] })); for (; ++U < V; ) - (j = B[U]), + ((j = B[U]), u.getLanguage(j) && (((C = highlight(j, s, o)).language = j), C.relevance > x.relevance && (x = C), - C.relevance > i.relevance && ((x = i), (i = C))); + C.relevance > i.relevance && ((x = i), (i = C)))); x.language && (i.secondBest = x); return i; }), @@ -13572,13 +13590,13 @@ i, u = this.stack; if ('' === s) return; - (o = u[u.length - 1]), + ((o = u[u.length - 1]), (i = o.children[o.children.length - 1]) && 'text' === i.type ? (i.value += s) - : o.children.push({ type: 'text', value: s }); + : o.children.push({ type: 'text', value: s })); }), (Emitter.prototype.addKeyword = function addKeyword(s, o) { - this.openNode(o), this.addText(s), this.closeNode(); + (this.openNode(o), this.addText(s), this.closeNode()); }), (Emitter.prototype.addSublanguage = function addSublanguage(s, o) { var i = this.stack, @@ -13604,7 +13622,7 @@ properties: { className: [i] }, children: [] }; - u.children.push(_), o.push(_); + (u.children.push(_), o.push(_)); }), (Emitter.prototype.closeNode = function close() { this.stack.pop(); @@ -13613,7 +13631,7 @@ (Emitter.prototype.finalize = noop), (Emitter.prototype.toHTML = function toHtmlNoop() { return ''; - }); + })); var w = 'hljs-'; function highlight(s, o, i) { var x, @@ -13637,7 +13655,9 @@ }; } function Emitter(s) { - (this.options = s), (this.rootNode = { children: [] }), (this.stack = [this.rootNode]); + ((this.options = s), + (this.rootNode = { children: [] }), + (this.stack = [this.rootNode])); } function noop() {} }, @@ -13675,7 +13695,8 @@ } filter(s, o) { return ( - (s = coerceElementMatchingCallback(s)), new ArraySlice(this.elements.filter(s, o)) + (s = coerceElementMatchingCallback(s)), + new ArraySlice(this.elements.filter(s, o)) ); } reject(s, o) { @@ -13685,7 +13706,7 @@ ); } find(s, o) { - return (s = coerceElementMatchingCallback(s)), this.elements.find(s, o); + return ((s = coerceElementMatchingCallback(s)), this.elements.find(s, o)); } forEach(s, o) { this.elements.forEach(s, o); @@ -13703,7 +13724,7 @@ this.elements.unshift(this.refract(s)); } push(s) { - return this.elements.push(this.refract(s)), this; + return (this.elements.push(this.refract(s)), this); } add(s) { this.push(s); @@ -13725,16 +13746,16 @@ return this.elements[0]; } } - 'undefined' != typeof Symbol && + ('undefined' != typeof Symbol && (ArraySlice.prototype[Symbol.iterator] = function symbol() { return this.elements[Symbol.iterator](); }), - (s.exports = ArraySlice); + (s.exports = ArraySlice)); }, 55973: (s) => { class KeyValuePair { constructor(s, o) { - (this.key = s), (this.value = o); + ((this.key = s), (this.value = o)); } clone() { const s = new KeyValuePair(); @@ -13757,17 +13778,19 @@ L = i(86804); class Namespace { constructor(s) { - (this.elementMap = {}), + ((this.elementMap = {}), (this.elementDetection = []), (this.Element = L.Element), (this.KeyValuePair = L.KeyValuePair), (s && s.noDefault) || this.useDefault(), (this._attributeElementKeys = []), - (this._attributeElementArrayKeys = []); + (this._attributeElementArrayKeys = [])); } use(s) { return ( - s.namespace && s.namespace({ base: this }), s.load && s.load({ base: this }), this + s.namespace && s.namespace({ base: this }), + s.load && s.load({ base: this }), + this ); } useDefault() { @@ -13791,10 +13814,10 @@ ); } register(s, o) { - return (this._elements = void 0), (this.elementMap[s] = o), this; + return ((this._elements = void 0), (this.elementMap[s] = o), this); } unregister(s) { - return (this._elements = void 0), delete this.elementMap[s], this; + return ((this._elements = void 0), delete this.elementMap[s], this); } detect(s, o, i) { return ( @@ -13842,7 +13865,7 @@ return new j(this); } } - (j.prototype.Namespace = Namespace), (s.exports = Namespace); + ((j.prototype.Namespace = Namespace), (s.exports = Namespace)); }, 10866: (s, o, i) => { const u = i(6048), @@ -13897,7 +13920,7 @@ } return s; } - (u.prototype.ObjectElement = B), + ((u.prototype.ObjectElement = B), (u.prototype.RefElement = V), (u.prototype.MemberElement = L), (u.prototype.refract = refract), @@ -13917,13 +13940,13 @@ ArraySlice: U, ObjectSlice: z, KeyValuePair: Y - }); + })); }, 86303: (s, o, i) => { const u = i(10316); s.exports = class LinkElement extends u { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'link'); + (super(s || [], o, i), (this.element = 'link')); } get relation() { return this.attributes.get('relation'); @@ -13943,7 +13966,7 @@ const u = i(10316); s.exports = class RefElement extends u { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'ref'), this.path || (this.path = 'element'); + (super(s || [], o, i), (this.element = 'ref'), this.path || (this.path = 'element')); } get path() { return this.attributes.get('path'); @@ -13956,7 +13979,7 @@ 34035: (s, o, i) => { const u = i(3110), _ = i(86804); - (o.g$ = u), + ((o.g$ = u), (o.KeyValuePair = i(55973)), (o.G6 = _.ArraySlice), (o.ot = _.ObjectSlice), @@ -13972,7 +13995,7 @@ (o.Ft = _.LinkElement), (o.e = _.refract), i(85105), - i(75147); + i(75147)); }, 6233: (s, o, i) => { const u = i(6048), @@ -13980,7 +14003,7 @@ w = i(92340); class ArrayElement extends _ { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'array'); + (super(s || [], o, i), (this.element = 'array')); } primitive() { return 'array'; @@ -13996,7 +14019,7 @@ return this.content[s]; } set(s, o) { - return (this.content[s] = this.refract(o)), this; + return ((this.content[s] = this.refract(o)), this); } remove(s) { const o = this.content.splice(s, 1); @@ -14050,7 +14073,7 @@ this.content.unshift(this.refract(s)); } push(s) { - return this.content.push(this.refract(s)), this; + return (this.content.push(this.refract(s)), this); } add(s) { this.push(s); @@ -14061,8 +14084,10 @@ _ = void 0 === i.results ? [] : i.results; return ( this.forEach((o, i, w) => { - u && void 0 !== o.findElements && o.findElements(s, { results: _, recursive: u }), - s(o, i, w) && _.push(o); + (u && + void 0 !== o.findElements && + o.findElements(s, { results: _, recursive: u }), + s(o, i, w) && _.push(o)); }), _ ); @@ -14125,7 +14150,7 @@ return this.getIndex(this.length - 1); } } - (ArrayElement.empty = function empty() { + ((ArrayElement.empty = function empty() { return new this(); }), (ArrayElement['fantasy-land/empty'] = ArrayElement.empty), @@ -14133,13 +14158,13 @@ (ArrayElement.prototype[Symbol.iterator] = function symbol() { return this.content[Symbol.iterator](); }), - (s.exports = ArrayElement); + (s.exports = ArrayElement)); }, 12242: (s, o, i) => { const u = i(10316); s.exports = class BooleanElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'boolean'); + (super(s, o, i), (this.element = 'boolean')); } primitive() { return 'boolean'; @@ -14152,14 +14177,14 @@ w = i(92340); class Element { constructor(s, o, i) { - o && (this.meta = o), i && (this.attributes = i), (this.content = s); + (o && (this.meta = o), i && (this.attributes = i), (this.content = s)); } freeze() { Object.isFrozen(this) || (this._meta && ((this.meta.parent = this), this.meta.freeze()), this._attributes && ((this.attributes.parent = this), this.attributes.freeze()), this.children.forEach((s) => { - (s.parent = this), s.freeze(); + ((s.parent = this), s.freeze()); }, this), this.content && Array.isArray(this.content) && Object.freeze(this.content), Object.freeze(this)); @@ -14197,7 +14222,7 @@ if ('' === this.id.toValue()) throw Error('Cannot create reference to an element that does not contain an ID'); const o = new this.RefElement(this.id.toValue()); - return s && (o.path = s), o; + return (s && (o.path = s), o); } findRecursive(...s) { if (arguments.length > 1 && !this.isFrozen) @@ -14237,7 +14262,7 @@ ); } set(s) { - return (this.content = s), this; + return ((this.content = s), this); } equals(s) { return u(this.toValue(), s); @@ -14246,7 +14271,7 @@ if (!this.meta.hasKey(s)) { if (this.isFrozen) { const s = this.refract(o); - return s.freeze(), s; + return (s.freeze(), s); } this.meta.set(s, o); } @@ -14286,7 +14311,7 @@ if (!this._meta) { if (this.isFrozen) { const s = new this.ObjectElement(); - return s.freeze(), s; + return (s.freeze(), s); } this._meta = new this.ObjectElement(); } @@ -14299,7 +14324,7 @@ if (!this._attributes) { if (this.isFrozen) { const s = new this.ObjectElement(); - return s.freeze(), s; + return (s.freeze(), s); } this._attributes = new this.ObjectElement(); } @@ -14346,14 +14371,14 @@ get parents() { let { parent: s } = this; const o = new w(); - for (; s; ) o.push(s), (s = s.parent); + for (; s; ) (o.push(s), (s = s.parent)); return o; } get children() { if (Array.isArray(this.content)) return new w(this.content); if (this.content instanceof _) { const s = new w([this.content.key]); - return this.content.value && s.push(this.content.value), s; + return (this.content.value && s.push(this.content.value), s); } return this.content instanceof Element ? new w([this.content]) : new w(); } @@ -14361,10 +14386,10 @@ const s = new w(); return ( this.children.forEach((o) => { - s.push(o), + (s.push(o), o.recursiveChildren.forEach((o) => { s.push(o); - }); + })); }), s ); @@ -14377,7 +14402,7 @@ _ = i(10316); s.exports = class MemberElement extends _ { constructor(s, o, i, _) { - super(new u(), i, _), (this.element = 'member'), (this.key = s), (this.value = o); + (super(new u(), i, _), (this.element = 'member'), (this.key = s), (this.value = o)); } get key() { return this.content.key; @@ -14397,7 +14422,7 @@ const u = i(10316); s.exports = class NullElement extends u { constructor(s, o, i) { - super(s || null, o, i), (this.element = 'null'); + (super(s || null, o, i), (this.element = 'null')); } primitive() { return 'null'; @@ -14411,7 +14436,7 @@ const u = i(10316); s.exports = class NumberElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'number'); + (super(s, o, i), (this.element = 'number')); } primitive() { return 'number'; @@ -14426,7 +14451,7 @@ C = i(10866); s.exports = class ObjectElement extends w { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'object'); + (super(s || [], o, i), (this.element = 'object')); } primitive() { return 'object'; @@ -14465,7 +14490,7 @@ ); const i = s, u = this.getMember(i); - return u ? (u.value = o) : this.content.push(new x(i, o)), this; + return (u ? (u.value = o) : this.content.push(new x(i, o)), this); } keys() { return this.content.map((s) => s.key.toValue()); @@ -14507,7 +14532,7 @@ const u = i(10316); s.exports = class StringElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'string'); + (super(s, o, i), (this.element = 'string')); } primitive() { return 'string'; @@ -14533,22 +14558,22 @@ o && (i.attributes = o); } else if (s._attributes && s._attributes.length > 0) { let { attributes: u } = s; - u.get('metadata') && + (u.get('metadata') && ((u = u.clone()), u.set('meta', u.get('metadata')), u.remove('metadata')), 'member' === s.element && o && ((u = u.clone()), u.remove('variable')), - u.length > 0 && (i.attributes = this.serialiseObject(u)); + u.length > 0 && (i.attributes = this.serialiseObject(u))); } if (u) i.content = this.enumSerialiseContent(s, i); else if (this[`${s.element}SerialiseContent`]) i.content = this[`${s.element}SerialiseContent`](s, i); else if (void 0 !== s.content) { let u; - o && s.content.key + (o && s.content.key ? ((u = s.content.clone()), u.key.attributes.set('variable', o), (u = this.serialiseContent(u))) : (u = this.serialiseContent(s.content)), - this.shouldSerialiseContent(s, u) && (i.content = u); + this.shouldSerialiseContent(s, u) && (i.content = u)); } else this.shouldSerialiseContent(s, s.content) && s instanceof this.namespace.elements.Array && @@ -14566,7 +14591,7 @@ ); } refSerialiseContent(s, o) { - return delete o.attributes, { href: s.toValue(), path: s.path.toValue() }; + return (delete o.attributes, { href: s.toValue(), path: s.path.toValue() }); } sourceMapSerialiseContent(s) { return s.toValue(); @@ -14604,12 +14629,12 @@ if (o && o.length > 0) return o.content.map((s) => { const o = s.clone(); - return o.attributes.remove('typeAttributes'), this.serialise(o); + return (o.attributes.remove('typeAttributes'), this.serialise(o)); }); } if (s.content) { const o = s.content.clone(); - return o.attributes.remove('typeAttributes'), [this.serialise(o)]; + return (o.attributes.remove('typeAttributes'), [this.serialise(o)]); } return []; } @@ -14622,30 +14647,30 @@ return new this.namespace.elements.Array(s.map(this.deserialise, this)); const o = this.namespace.getElementClass(s.element), i = new o(); - i.element !== s.element && (i.element = s.element), + (i.element !== s.element && (i.element = s.element), s.meta && this.deserialiseObject(s.meta, i.meta), - s.attributes && this.deserialiseObject(s.attributes, i.attributes); + s.attributes && this.deserialiseObject(s.attributes, i.attributes)); const u = this.deserialiseContent(s.content); if (((void 0 === u && null !== i.content) || (i.content = u), 'enum' === i.element)) { i.content && i.attributes.set('enumerations', i.content); let s = i.attributes.get('samples'); if ((i.attributes.remove('samples'), s)) { const u = s; - (s = new this.namespace.elements.Array()), + ((s = new this.namespace.elements.Array()), u.forEach((u) => { u.forEach((u) => { const _ = new o(u); - (_.element = i.element), s.push(_); + ((_.element = i.element), s.push(_)); }); - }); + })); const _ = s.shift(); - (i.content = _ ? _.content : void 0), i.attributes.set('samples', s); + ((i.content = _ ? _.content : void 0), i.attributes.set('samples', s)); } else i.content = void 0; let u = i.attributes.get('default'); if (u && u.length > 0) { u = u.get(0); const s = new o(u); - (s.element = i.element), i.attributes.set('default', s); + ((s.element = i.element), i.attributes.set('default', s)); } } else if ('dataStructure' === i.element && Array.isArray(i.content)) [i.content] = i.content; @@ -14665,7 +14690,7 @@ if (s instanceof this.namespace.elements.Element) return this.serialise(s); if (s instanceof this.namespace.KeyValuePair) { const o = { key: this.serialise(s.key) }; - return s.value && (o.value = this.serialise(s.value)), o; + return (s.value && (o.value = this.serialise(s.value)), o); } return s && s.map ? s.map(this.serialise, this) : s; } @@ -14674,7 +14699,7 @@ if (s.element) return this.deserialise(s); if (s.key) { const o = new this.namespace.KeyValuePair(this.deserialise(s.key)); - return s.value && (o.value = this.deserialise(s.value)), o; + return (s.value && (o.value = this.deserialise(s.value)), o); } if (s.map) return s.map(this.deserialise, this); } @@ -14737,28 +14762,28 @@ if (!(s instanceof this.namespace.elements.Element)) throw new TypeError(`Given element \`${s}\` is not an Element instance`); const o = { element: s.element }; - s._meta && s._meta.length > 0 && (o.meta = this.serialiseObject(s.meta)), + (s._meta && s._meta.length > 0 && (o.meta = this.serialiseObject(s.meta)), s._attributes && s._attributes.length > 0 && - (o.attributes = this.serialiseObject(s.attributes)); + (o.attributes = this.serialiseObject(s.attributes))); const i = this.serialiseContent(s.content); - return void 0 !== i && (o.content = i), o; + return (void 0 !== i && (o.content = i), o); } deserialise(s) { if (!s.element) throw new Error('Given value is not an object containing an element name'); const o = new (this.namespace.getElementClass(s.element))(); - o.element !== s.element && (o.element = s.element), + (o.element !== s.element && (o.element = s.element), s.meta && this.deserialiseObject(s.meta, o.meta), - s.attributes && this.deserialiseObject(s.attributes, o.attributes); + s.attributes && this.deserialiseObject(s.attributes, o.attributes)); const i = this.deserialiseContent(s.content); - return (void 0 === i && null !== o.content) || (o.content = i), o; + return ((void 0 === i && null !== o.content) || (o.content = i), o); } serialiseContent(s) { if (s instanceof this.namespace.elements.Element) return this.serialise(s); if (s instanceof this.namespace.KeyValuePair) { const o = { key: this.serialise(s.key) }; - return s.value && (o.value = this.serialise(s.value)), o; + return (s.value && (o.value = this.serialise(s.value)), o); } if (s && s.map) { if (0 === s.length) return; @@ -14771,7 +14796,7 @@ if (s.element) return this.deserialise(s); if (s.key) { const o = new this.namespace.KeyValuePair(this.deserialise(s.key)); - return s.value && (o.value = this.deserialise(s.value)), o; + return (s.value && (o.value = this.deserialise(s.value)), o); } if (s.map) return s.map(this.deserialise, this); } @@ -14807,7 +14832,7 @@ function runTimeout(s) { if (o === setTimeout) return setTimeout(s, 0); if ((o === defaultSetTimout || !o) && setTimeout) - return (o = setTimeout), setTimeout(s, 0); + return ((o = setTimeout), setTimeout(s, 0)); try { return o(s, 0); } catch (i) { @@ -14843,14 +14868,14 @@ x = !0; for (var o = w.length; o; ) { for (_ = w, w = []; ++C < o; ) _ && _[C].run(); - (C = -1), (o = w.length); + ((C = -1), (o = w.length)); } - (_ = null), + ((_ = null), (x = !1), (function runClearTimeout(s) { if (i === clearTimeout) return clearTimeout(s); if ((i === defaultClearTimeout || !i) && clearTimeout) - return (i = clearTimeout), clearTimeout(s); + return ((i = clearTimeout), clearTimeout(s)); try { return i(s); } catch (o) { @@ -14860,18 +14885,18 @@ return i.call(this, s); } } - })(s); + })(s)); } } function Item(s, o) { - (this.fun = s), (this.array = o); + ((this.fun = s), (this.array = o)); } function noop() {} - (u.nextTick = function (s) { + ((u.nextTick = function (s) { var o = new Array(arguments.length - 1); if (arguments.length > 1) for (var i = 1; i < arguments.length; i++) o[i - 1] = arguments[i]; - w.push(new Item(s, o)), 1 !== w.length || x || runTimeout(drainQueue); + (w.push(new Item(s, o)), 1 !== w.length || x || runTimeout(drainQueue)); }), (Item.prototype.run = function () { this.fun.apply(null, this.array); @@ -14905,14 +14930,14 @@ }), (u.umask = function () { return 0; - }); + })); }, 2694: (s, o, i) => { 'use strict'; var u = i(6925); function emptyFunction() {} function emptyFunctionWithReset() {} - (emptyFunctionWithReset.resetWarningCache = emptyFunction), + ((emptyFunctionWithReset.resetWarningCache = emptyFunction), (s.exports = function () { function shim(s, o, i, _, w, x) { if (x !== u) { @@ -14949,8 +14974,8 @@ checkPropTypes: emptyFunctionWithReset, resetWarningCache: emptyFunction }; - return (s.PropTypes = s), s; - }); + return ((s.PropTypes = s), s); + })); }, 5556: (s, o, i) => { s.exports = i(2694)(); @@ -14976,7 +15001,7 @@ return null; } } - (o.stringify = function querystringify(s, o) { + ((o.stringify = function querystringify(s, o) { o = o || ''; var u, _, @@ -15001,7 +15026,7 @@ null === _ || null === w || _ in u || (u[_] = w); } return u; - }); + })); }, 41859: (s, o, i) => { const u = i(27096), @@ -15010,23 +15035,23 @@ s.exports = class RandExp { constructor(s, o) { if ((this._setDefaults(s), s instanceof RegExp)) - (this.ignoreCase = s.ignoreCase), (this.multiline = s.multiline), (s = s.source); + ((this.ignoreCase = s.ignoreCase), (this.multiline = s.multiline), (s = s.source)); else { if ('string' != typeof s) throw new Error('Expected a regexp or string'); - (this.ignoreCase = o && -1 !== o.indexOf('i')), - (this.multiline = o && -1 !== o.indexOf('m')); + ((this.ignoreCase = o && -1 !== o.indexOf('i')), + (this.multiline = o && -1 !== o.indexOf('m'))); } this.tokens = u(s); } _setDefaults(s) { - (this.max = + ((this.max = null != s.max ? s.max : null != RandExp.prototype.max ? RandExp.prototype.max : 100), (this.defaultRange = s.defaultRange ? s.defaultRange : this.defaultRange.clone()), - s.randInt && (this.randInt = s.randInt); + s.randInt && (this.randInt = s.randInt)); } gen() { return this._gen(this.tokens, []); @@ -15046,7 +15071,7 @@ x++ ) u += this._gen(i[x], o); - return s.remember && (o[s.groupNumber] = u), u; + return (s.remember && (o[s.groupNumber] = u), u); case w.POSITION: return ''; case w.SET: @@ -15172,7 +15197,7 @@ _typeof(s) ); } - Object.defineProperty(o, '__esModule', { value: !0 }), (o.CopyToClipboard = void 0); + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.CopyToClipboard = void 0)); var u = _interopRequireDefault(i(96540)), _ = _interopRequireDefault(i(17965)), w = ['text', 'onCopy', 'options', 'children']; @@ -15183,11 +15208,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -15216,25 +15241,25 @@ u, _ = {}, w = Object.keys(s); - for (u = 0; u < w.length; u++) (i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i]); + for (u = 0; u < w.length; u++) ((i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i])); return _; })(s, o); if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), + ((i = w[u]), o.indexOf(i) >= 0 || - (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; } function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _setPrototypeOf(s, o) { @@ -15242,7 +15267,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -15254,7 +15279,8 @@ if ('function' == typeof Proxy) return !0; try { return ( - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), !0 + Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), + !0 ); } catch (s) { return !1; @@ -15307,11 +15333,11 @@ !(function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), Object.defineProperty(s, 'prototype', { writable: !1 }), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(CopyToClipboard, s); var o = _createSuper(CopyToClipboard); function CopyToClipboard() { @@ -15333,8 +15359,8 @@ j = i.options, L = u.default.Children.only(C), B = (0, _.default)(w, j); - x && x(w, B), - L && L.props && 'function' == typeof L.props.onClick && L.props.onClick(o); + (x && x(w, B), + L && L.props && 'function' == typeof L.props.onClick && L.props.onClick(o)); } ), s @@ -15366,13 +15392,13 @@ CopyToClipboard ); })(u.default.PureComponent); - (o.CopyToClipboard = x), - _defineProperty(x, 'defaultProps', { onCopy: void 0, options: void 0 }); + ((o.CopyToClipboard = x), + _defineProperty(x, 'defaultProps', { onCopy: void 0, options: void 0 })); }, 59399: (s, o, i) => { 'use strict'; var u = i(25264).CopyToClipboard; - (u.CopyToClipboard = u), (s.exports = u); + ((u.CopyToClipboard = u), (s.exports = u)); }, 81214: (s, o, i) => { 'use strict'; @@ -15394,7 +15420,7 @@ _typeof(s) ); } - Object.defineProperty(o, '__esModule', { value: !0 }), (o.DebounceInput = void 0); + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.DebounceInput = void 0)); var u = _interopRequireDefault(i(96540)), _ = _interopRequireDefault(i(20181)), w = [ @@ -15422,15 +15448,15 @@ u, _ = {}, w = Object.keys(s); - for (u = 0; u < w.length; u++) (i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i]); + for (u = 0; u < w.length; u++) ((i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i])); return _; })(s, o); if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), + ((i = w[u]), o.indexOf(i) >= 0 || - (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; } @@ -15438,11 +15464,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -15464,10 +15490,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _setPrototypeOf(s, o) { @@ -15475,7 +15501,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -15487,7 +15513,8 @@ if ('function' == typeof Proxy) return !0; try { return ( - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), !0 + Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), + !0 ); } catch (s) { return !1; @@ -15540,16 +15567,16 @@ !(function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), Object.defineProperty(s, 'prototype', { writable: !1 }), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(DebounceInput, s); var o = _createSuper(DebounceInput); function DebounceInput(s) { var i; - !(function _classCallCheck(s, o) { + (!(function _classCallCheck(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, DebounceInput), _defineProperty( @@ -15598,17 +15625,17 @@ else if (0 === s) i.notify = i.doNotify; else { var o = (0, _.default)(function (s) { - (i.isDebouncing = !1), i.doNotify(s); + ((i.isDebouncing = !1), i.doNotify(s)); }, s); - (i.notify = function (s) { - (i.isDebouncing = !0), o(s); + ((i.notify = function (s) { + ((i.isDebouncing = !0), o(s)); }), (i.flush = function () { return o.flush(); }), (i.cancel = function () { - (i.isDebouncing = !1), o.cancel(); - }); + ((i.isDebouncing = !1), o.cancel()); + })); } }), _defineProperty(_assertThisInitialized(i), 'doNotify', function () { @@ -15632,9 +15659,9 @@ } }), (i.isDebouncing = !1), - (i.state = { value: void 0 === s.value || null === s.value ? '' : s.value }); + (i.state = { value: void 0 === s.value || null === s.value ? '' : s.value })); var u = i.props.debounceTimeout; - return i.createNotifier(u), i; + return (i.createNotifier(u), i); } return ( (function _createClass(s, o, i) { @@ -15655,8 +15682,8 @@ _ = s.debounceTimeout, w = s.value, x = this.state.value; - void 0 !== i && w !== i && x !== i && this.setState({ value: i }), - u !== _ && this.createNotifier(u); + (void 0 !== i && w !== i && x !== i && this.setState({ value: i }), + u !== _ && this.createNotifier(u)); } } }, @@ -15681,8 +15708,8 @@ B = i.inputRef, $ = _objectWithoutProperties(i, w), V = this.state.value; - (s = x ? { onKeyDown: this.onKeyDown } : j ? { onKeyDown: j } : {}), - (o = C ? { onBlur: this.onBlur } : L ? { onBlur: L } : {}); + ((s = x ? { onKeyDown: this.onKeyDown } : j ? { onKeyDown: j } : {}), + (o = C ? { onBlur: this.onBlur } : L ? { onBlur: L } : {})); var U = B ? { ref: B } : {}; return u.default.createElement( _, @@ -15705,7 +15732,7 @@ DebounceInput ); })(u.default.PureComponent); - (o.DebounceInput = x), + ((o.DebounceInput = x), _defineProperty(x, 'defaultProps', { element: 'input', type: 'text', @@ -15717,12 +15744,12 @@ forceNotifyByEnter: !0, forceNotifyOnBlur: !0, inputRef: void 0 - }); + })); }, 24677: (s, o, i) => { 'use strict'; var u = i(81214).DebounceInput; - (u.DebounceInput = u), (s.exports = u); + ((u.DebounceInput = u), (s.exports = u)); }, 22551: (s, o, i) => { 'use strict'; @@ -15746,7 +15773,7 @@ var w = new Set(), x = {}; function fa(s, o) { - ha(s, o), ha(s + 'Capture', o); + (ha(s, o), ha(s + 'Capture', o)); } function ha(s, o) { for (x[s] = o, s = 0; s < o.length; s++) w.add(o[s]); @@ -15762,17 +15789,17 @@ B = {}, $ = {}; function v(s, o, i, u, _, w, x) { - (this.acceptsBooleans = 2 === o || 3 === o || 4 === o), + ((this.acceptsBooleans = 2 === o || 3 === o || 4 === o), (this.attributeName = u), (this.attributeNamespace = _), (this.mustUseProperty = i), (this.propertyName = s), (this.type = o), (this.sanitizeURL = w), - (this.removeEmptyString = x); + (this.removeEmptyString = x)); } var V = {}; - 'children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style' + ('children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style' .split(' ') .forEach(function (s) { V[s] = new v(s, 0, !1, s, null, !1, !1); @@ -15810,7 +15837,7 @@ }), ['rowSpan', 'start'].forEach(function (s) { V[s] = new v(s, 5, !1, s.toLowerCase(), null, !1, !1); - }); + })); var U = /[\-:]([a-z])/g; function sa(s) { return s[1].toUpperCase(); @@ -15875,7 +15902,7 @@ : ((i = 3 === (_ = _.type) || (4 === _ && !0 === i) ? '' : '' + i), u ? s.setAttributeNS(u, o, i) : s.setAttribute(o, i)))); } - 'accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height' + ('accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height' .split(' ') .forEach(function (s) { var o = s.replace(U, sa); @@ -15905,7 +15932,7 @@ )), ['src', 'href', 'action', 'formAction'].forEach(function (s) { V[s] = new v(s, 1, !1, s.toLowerCase(), null, !0, !0); - }); + })); var z = u.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, Y = Symbol.for('react.element'), Z = Symbol.for('react.portal'), @@ -15919,11 +15946,11 @@ fe = Symbol.for('react.suspense_list'), ye = Symbol.for('react.memo'), be = Symbol.for('react.lazy'); - Symbol.for('react.scope'), Symbol.for('react.debug_trace_mode'); + (Symbol.for('react.scope'), Symbol.for('react.debug_trace_mode')); var _e = Symbol.for('react.offscreen'); - Symbol.for('react.legacy_hidden'), + (Symbol.for('react.legacy_hidden'), Symbol.for('react.cache'), - Symbol.for('react.tracing_marker'); + Symbol.for('react.tracing_marker')); var we = Symbol.iterator; function Ka(s) { return null === s || 'object' != typeof s @@ -15993,7 +16020,6 @@ x = _.length - 1, C = w.length - 1; 1 <= x && 0 <= C && _[x] !== w[C]; - ) C--; for (; 1 <= x && 0 <= C; x--, C--) @@ -16014,7 +16040,7 @@ } } } finally { - (Pe = !1), (Error.prepareStackTrace = i); + ((Pe = !1), (Error.prepareStackTrace = i)); } return (s = s ? s.displayName || s.name : '') ? Ma(s) : ''; } @@ -16077,7 +16103,7 @@ case ye: return null !== (o = s.displayName || null) ? o : Qa(s.type) || 'Memo'; case be: - (o = s._payload), (s = s._init); + ((o = s._payload), (s = s._init)); try { return Qa(s(o)); } catch (s) {} @@ -16176,7 +16202,7 @@ return _.call(this); }, set: function (s) { - (u = '' + s), w.call(this, s); + ((u = '' + s), w.call(this, s)); } }), Object.defineProperty(s, o, { enumerable: i.enumerable }), @@ -16188,7 +16214,7 @@ u = '' + s; }, stopTracking: function () { - (s._valueTracker = null), delete s[o]; + ((s._valueTracker = null), delete s[o]); } } ); @@ -16227,13 +16253,13 @@ function Za(s, o) { var i = null == o.defaultValue ? '' : o.defaultValue, u = null != o.checked ? o.checked : o.defaultChecked; - (i = Sa(null != o.value ? o.value : i)), + ((i = Sa(null != o.value ? o.value : i)), (s._wrapperState = { initialChecked: u, initialValue: i, controlled: 'checkbox' === o.type || 'radio' === o.type ? null != o.checked : null != o.value - }); + })); } function ab(s, o) { null != (o = o.checked) && ta(s, 'checked', o, !1); @@ -16247,25 +16273,25 @@ ? ((0 === i && '' === s.value) || s.value != i) && (s.value = '' + i) : s.value !== '' + i && (s.value = '' + i); else if ('submit' === u || 'reset' === u) return void s.removeAttribute('value'); - o.hasOwnProperty('value') + (o.hasOwnProperty('value') ? cb(s, o.type, i) : o.hasOwnProperty('defaultValue') && cb(s, o.type, Sa(o.defaultValue)), null == o.checked && null != o.defaultChecked && - (s.defaultChecked = !!o.defaultChecked); + (s.defaultChecked = !!o.defaultChecked)); } function db(s, o, i) { if (o.hasOwnProperty('value') || o.hasOwnProperty('defaultValue')) { var u = o.type; if (!(('submit' !== u && 'reset' !== u) || (void 0 !== o.value && null !== o.value))) return; - (o = '' + s._wrapperState.initialValue), + ((o = '' + s._wrapperState.initialValue), i || o === s.value || (s.value = o), - (s.defaultValue = o); + (s.defaultValue = o)); } - '' !== (i = s.name) && (s.name = ''), + ('' !== (i = s.name) && (s.name = ''), (s.defaultChecked = !!s._wrapperState.initialChecked), - '' !== i && (s.name = i); + '' !== i && (s.name = i)); } function cb(s, o, i) { ('number' === o && Xa(s.ownerDocument) === s) || @@ -16279,13 +16305,13 @@ o = {}; for (var _ = 0; _ < i.length; _++) o['$' + i[_]] = !0; for (i = 0; i < s.length; i++) - (_ = o.hasOwnProperty('$' + s[i].value)), + ((_ = o.hasOwnProperty('$' + s[i].value)), s[i].selected !== _ && (s[i].selected = _), - _ && u && (s[i].defaultSelected = !0); + _ && u && (s[i].defaultSelected = !0)); } else { for (i = '' + Sa(i), o = null, _ = 0; _ < s.length; _++) { if (s[_].value === i) - return (s[_].selected = !0), void (u && (s[_].defaultSelected = !0)); + return ((s[_].selected = !0), void (u && (s[_].defaultSelected = !0))); null !== o || s[_].disabled || (o = s[_]); } null !== o && (o.selected = !0); @@ -16310,17 +16336,17 @@ } o = i; } - null == o && (o = ''), (i = o); + (null == o && (o = ''), (i = o)); } s._wrapperState = { initialValue: Sa(i) }; } function ib(s, o) { var i = Sa(o.value), u = Sa(o.defaultValue); - null != i && + (null != i && ((i = '' + i) !== s.value && (s.value = i), null == o.defaultValue && s.defaultValue !== i && (s.defaultValue = i)), - null != u && (s.defaultValue = '' + u); + null != u && (s.defaultValue = '' + u)); } function jb(s) { var o = s.textContent; @@ -16355,7 +16381,6 @@ '' + o.valueOf().toString() + '', o = Re.firstChild; s.firstChild; - ) s.removeChild(s.firstChild); for (; o.firstChild; ) s.appendChild(o.firstChild); @@ -16433,12 +16458,12 @@ if (o.hasOwnProperty(i)) { var u = 0 === i.indexOf('--'), _ = rb(i, o[i], u); - 'float' === i && (i = 'cssFloat'), u ? s.setProperty(i, _) : (s[i] = _); + ('float' === i && (i = 'cssFloat'), u ? s.setProperty(i, _) : (s[i] = _)); } } Object.keys(ze).forEach(function (s) { We.forEach(function (o) { - (o = o + s.charAt(0).toUpperCase() + s.substring(1)), (ze[o] = ze[s]); + ((o = o + s.charAt(0).toUpperCase() + s.substring(1)), (ze[o] = ze[s])); }); }); var He = xe( @@ -16531,7 +16556,7 @@ try { return Gb(s, o, i); } finally { - (tt = !1), (null !== Qe || null !== et) && (Hb(), Fb()); + ((tt = !1), (null !== Qe || null !== et) && (Hb(), Fb())); } } function Kb(s, o) { @@ -16552,14 +16577,14 @@ case 'onMouseUp': case 'onMouseUpCapture': case 'onMouseEnter': - (u = !u.disabled) || + ((u = !u.disabled) || (u = !( 'button' === (s = s.type) || 'input' === s || 'select' === s || 'textarea' === s )), - (s = !u); + (s = !u)); break e; default: s = !1; @@ -16572,13 +16597,13 @@ if (C) try { var nt = {}; - Object.defineProperty(nt, 'passive', { + (Object.defineProperty(nt, 'passive', { get: function () { rt = !0; } }), window.addEventListener('test', nt, nt), - window.removeEventListener('test', nt, nt); + window.removeEventListener('test', nt, nt)); } catch (qe) { rt = !1; } @@ -16596,11 +16621,11 @@ at = null, lt = { onError: function (s) { - (st = !0), (ot = s); + ((st = !0), (ot = s)); } }; function Tb(s, o, i, u, _, w, x, C, j) { - (st = !1), (ot = null), Nb.apply(lt, arguments); + ((st = !1), (ot = null), Nb.apply(lt, arguments)); } function Vb(s) { var o = s, @@ -16609,7 +16634,7 @@ else { s = o; do { - !!(4098 & (o = s).flags) && (i = o.return), (s = o.return); + (!!(4098 & (o = s).flags) && (i = o.return), (s = o.return)); } while (s); } return 3 === o.tag ? i : null; @@ -16646,21 +16671,21 @@ } if (_.child === w.child) { for (w = _.child; w; ) { - if (w === i) return Xb(_), s; - if (w === u) return Xb(_), o; + if (w === i) return (Xb(_), s); + if (w === u) return (Xb(_), o); w = w.sibling; } throw Error(p(188)); } - if (i.return !== u.return) (i = _), (u = w); + if (i.return !== u.return) ((i = _), (u = w)); else { for (var x = !1, C = _.child; C; ) { if (C === i) { - (x = !0), (i = _), (u = w); + ((x = !0), (i = _), (u = w)); break; } if (C === u) { - (x = !0), (u = _), (i = w); + ((x = !0), (u = _), (i = w)); break; } C = C.sibling; @@ -16668,11 +16693,11 @@ if (!x) { for (C = w.child; C; ) { if (C === i) { - (x = !0), (i = w), (u = _); + ((x = !0), (i = w), (u = _)); break; } if (C === u) { - (x = !0), (u = w), (i = _); + ((x = !0), (u = w), (i = _)); break; } C = C.sibling; @@ -16713,7 +16738,7 @@ var St = Math.clz32 ? Math.clz32 : function nc(s) { - return (s >>>= 0), 0 === s ? 32 : (31 - ((xt(s) / kt) | 0)) | 0; + return ((s >>>= 0), 0 === s ? 32 : (31 - ((xt(s) / kt) | 0)) | 0); }, xt = Math.log, kt = Math.LN2; @@ -16789,7 +16814,7 @@ return o; if ((4 & u && (u |= 16 & i), 0 !== (o = s.entangledLanes))) for (s = s.entanglements, o &= u; 0 < o; ) - (_ = 1 << (i = 31 - St(o))), (u |= s[i]), (o &= ~_); + ((_ = 1 << (i = 31 - St(o))), (u |= s[i]), (o &= ~_)); return u; } function vc(s, o) { @@ -16827,23 +16852,23 @@ } function yc() { var s = Ct; - return !(4194240 & (Ct <<= 1)) && (Ct = 64), s; + return (!(4194240 & (Ct <<= 1)) && (Ct = 64), s); } function zc(s) { for (var o = [], i = 0; 31 > i; i++) o.push(s); return o; } function Ac(s, o, i) { - (s.pendingLanes |= o), + ((s.pendingLanes |= o), 536870912 !== o && ((s.suspendedLanes = 0), (s.pingedLanes = 0)), - ((s = s.eventTimes)[(o = 31 - St(o))] = i); + ((s = s.eventTimes)[(o = 31 - St(o))] = i)); } function Cc(s, o) { var i = (s.entangledLanes |= o); for (s = s.entanglements; i; ) { var u = 31 - St(i), _ = 1 << u; - (_ & o) | (s[u] & o) && (s[u] |= o), (i &= ~_); + ((_ & o) | (s[u] & o) && (s[u] |= o), (i &= ~_)); } } var At = 0; @@ -16928,9 +16953,9 @@ if (null !== s.blockedOn) return !1; for (var o = s.targetContainers; 0 < o.length; ) { var i = Yc(s.domEventName, s.eventSystemFlags, o[0], s.nativeEvent); - if (null !== i) return null !== (o = Cb(i)) && It(o), (s.blockedOn = i), !1; + if (null !== i) return (null !== (o = Cb(i)) && It(o), (s.blockedOn = i), !1); var u = new (i = s.nativeEvent).constructor(i.type, i); - (Ye = u), i.target.dispatchEvent(u), (Ye = null), o.shift(); + ((Ye = u), i.target.dispatchEvent(u), (Ye = null), o.shift()); } return !0; } @@ -16938,12 +16963,12 @@ Xc(s) && i.delete(o); } function $c() { - (Nt = !1), + ((Nt = !1), null !== Dt && Xc(Dt) && (Dt = null), null !== Lt && Xc(Lt) && (Lt = null), null !== Bt && Xc(Bt) && (Bt = null), Ft.forEach(Zc), - qt.forEach(Zc); + qt.forEach(Zc)); } function ad(s, o) { s.blockedOn === o && @@ -16973,7 +16998,7 @@ ) (i = $t[o]).blockedOn === s && (i.blockedOn = null); for (; 0 < $t.length && null === (o = $t[0]).blockedOn; ) - Vc(o), null === o.blockedOn && $t.shift(); + (Vc(o), null === o.blockedOn && $t.shift()); } var Ut = z.ReactCurrentBatchConfig, zt = !0; @@ -16982,9 +17007,9 @@ w = Ut.transition; Ut.transition = null; try { - (At = 1), fd(s, o, i, u); + ((At = 1), fd(s, o, i, u)); } finally { - (At = _), (Ut.transition = w); + ((At = _), (Ut.transition = w)); } } function gd(s, o, i, u) { @@ -16992,29 +17017,33 @@ w = Ut.transition; Ut.transition = null; try { - (At = 4), fd(s, o, i, u); + ((At = 4), fd(s, o, i, u)); } finally { - (At = _), (Ut.transition = w); + ((At = _), (Ut.transition = w)); } } function fd(s, o, i, u) { if (zt) { var _ = Yc(s, o, i, u); - if (null === _) hd(s, o, u, Wt, i), Sc(s, u); + if (null === _) (hd(s, o, u, Wt, i), Sc(s, u)); else if ( (function Uc(s, o, i, u, _) { switch (o) { case 'focusin': - return (Dt = Tc(Dt, s, o, i, u, _)), !0; + return ((Dt = Tc(Dt, s, o, i, u, _)), !0); case 'dragenter': - return (Lt = Tc(Lt, s, o, i, u, _)), !0; + return ((Lt = Tc(Lt, s, o, i, u, _)), !0); case 'mouseover': - return (Bt = Tc(Bt, s, o, i, u, _)), !0; + return ((Bt = Tc(Bt, s, o, i, u, _)), !0); case 'pointerover': var w = _.pointerId; - return Ft.set(w, Tc(Ft.get(w) || null, s, o, i, u, _)), !0; + return (Ft.set(w, Tc(Ft.get(w) || null, s, o, i, u, _)), !0); case 'gotpointercapture': - return (w = _.pointerId), qt.set(w, Tc(qt.get(w) || null, s, o, i, u, _)), !0; + return ( + (w = _.pointerId), + qt.set(w, Tc(qt.get(w) || null, s, o, i, u, _)), + !0 + ); } return !1; })(_, s, o, i, u) @@ -17047,7 +17076,7 @@ return 3 === o.tag ? o.stateNode.containerInfo : null; s = null; } else o !== s && (s = null); - return (Wt = s), null; + return ((Wt = s), null); } function jd(s) { switch (s) { @@ -17468,10 +17497,10 @@ return 'input' === o ? !!Ir[s.type] : 'textarea' === o; } function ne(s, o, i, u) { - Eb(u), + (Eb(u), 0 < (o = oe(o, 'onChange')).length && ((i = new Qt('onChange', 'change', null, i, u)), - s.push({ event: i, listeners: o })); + s.push({ event: i, listeners: o }))); } var Pr = null, Mr = null; @@ -17491,7 +17520,7 @@ var Rr = 'oninput' in document; if (!Rr) { var Dr = document.createElement('div'); - Dr.setAttribute('oninput', 'return;'), (Rr = 'function' == typeof Dr.oninput); + (Dr.setAttribute('oninput', 'return;'), (Rr = 'function' == typeof Dr.oninput)); } Nr = Rr; } else Nr = !1; @@ -17503,7 +17532,7 @@ function Be(s) { if ('value' === s.propertyName && te(Mr)) { var o = []; - ne(o, Mr, s, xb(s)), Jb(re, o); + (ne(o, Mr, s, xb(s)), Jb(re, o)); } } function Ce(s, o, i) { @@ -17609,16 +17638,16 @@ if (o !== i && i && i.ownerDocument && Le(i.ownerDocument.documentElement, i)) { if (null !== u && Ne(i)) if (((o = u.start), void 0 === (s = u.end) && (s = o), 'selectionStart' in i)) - (i.selectionStart = o), (i.selectionEnd = Math.min(s, i.value.length)); + ((i.selectionStart = o), (i.selectionEnd = Math.min(s, i.value.length))); else if ( (s = ((o = i.ownerDocument || document) && o.defaultView) || window).getSelection ) { s = s.getSelection(); var _ = i.textContent.length, w = Math.min(u.start, _); - (u = void 0 === u.end ? w : Math.min(u.end, _)), + ((u = void 0 === u.end ? w : Math.min(u.end, _)), !s.extend && w > u && ((_ = u), (u = w), (w = _)), - (_ = Ke(i, w)); + (_ = Ke(i, w))); var x = Ke(i, u); _ && x && @@ -17636,7 +17665,7 @@ for (o = [], s = i; (s = s.parentNode); ) 1 === s.nodeType && o.push({ element: s, left: s.scrollLeft, top: s.scrollTop }); for ('function' == typeof i.focus && i.focus(), i = 0; i < o.length; i++) - ((s = o[i]).element.scrollLeft = s.left), (s.element.scrollTop = s.top); + (((s = o[i]).element.scrollLeft = s.left), (s.element.scrollTop = s.top)); } } var Br = C && 'documentMode' in document && 11 >= document.documentMode, @@ -17709,13 +17738,13 @@ ' ' ); function ff(s, o) { - Yr.set(s, o), fa(o, [s]); + (Yr.set(s, o), fa(o, [s])); } for (var Zr = 0; Zr < Xr.length; Zr++) { var Qr = Xr[Zr]; ff(Qr.toLowerCase(), 'on' + (Qr[0].toUpperCase() + Qr.slice(1))); } - ff(Kr, 'onAnimationEnd'), + (ff(Kr, 'onAnimationEnd'), ff(Hr, 'onAnimationIteration'), ff(Jr, 'onAnimationStart'), ff('dblclick', 'onDoubleClick'), @@ -17748,7 +17777,7 @@ fa( 'onCompositionUpdate', 'compositionupdate focusout keydown keypress keyup mousedown'.split(' ') - ); + )); var en = 'abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting'.split( ' ' @@ -17756,15 +17785,15 @@ tn = new Set('cancel close invalid load scroll toggle'.split(' ').concat(en)); function nf(s, o, i) { var u = s.type || 'unknown-event'; - (s.currentTarget = i), + ((s.currentTarget = i), (function Ub(s, o, i, u, _, w, x, C, j) { if ((Tb.apply(this, arguments), st)) { if (!st) throw Error(p(198)); var L = ot; - (st = !1), (ot = null), it || ((it = !0), (at = L)); + ((st = !1), (ot = null), it || ((it = !0), (at = L))); } })(u, o, void 0, s), - (s.currentTarget = null); + (s.currentTarget = null)); } function se(s, o) { o = !!(4 & o); @@ -17780,7 +17809,7 @@ j = C.instance, L = C.currentTarget; if (((C = C.listener), j !== w && _.isPropagationStopped())) break e; - nf(_, C, L), (w = j); + (nf(_, C, L), (w = j)); } else for (x = 0; x < u.length; x++) { @@ -17791,7 +17820,7 @@ j !== w && _.isPropagationStopped()) ) break e; - nf(_, C, L), (w = j); + (nf(_, C, L), (w = j)); } } } @@ -17805,15 +17834,15 @@ } function qf(s, o, i) { var u = 0; - o && (u |= 4), pf(i, s, u, o); + (o && (u |= 4), pf(i, s, u, o)); } var rn = '_reactListening' + Math.random().toString(36).slice(2); function sf(s) { if (!s[rn]) { - (s[rn] = !0), + ((s[rn] = !0), w.forEach(function (o) { 'selectionchange' !== o && (tn.has(o) || qf(o, !1, s), qf(o, !0, s)); - }); + })); var o = 9 === s.nodeType ? s : s.ownerDocument; null === o || o[rn] || ((o[rn] = !0), qf('selectionchange', !1, o)); } @@ -17829,7 +17858,7 @@ default: _ = fd; } - (i = _.bind(null, o, i, s)), + ((i = _.bind(null, o, i, s)), (_ = void 0), !rt || ('touchstart' !== o && 'touchmove' !== o && 'wheel' !== o) || (_ = !0), u @@ -17838,7 +17867,7 @@ : s.addEventListener(o, i, !0) : void 0 !== _ ? s.addEventListener(o, i, { passive: _ }) - : s.addEventListener(o, i, !1); + : s.addEventListener(o, i, !1)); } function hd(s, o, i, u, _) { var w = u; @@ -17888,10 +17917,10 @@ j = gr; break; case 'focusin': - (L = 'focus'), (j = ir); + ((L = 'focus'), (j = ir)); break; case 'focusout': - (L = 'blur'), (j = ir); + ((L = 'blur'), (j = ir)); break; case 'beforeblur': case 'afterblur': @@ -18017,16 +18046,17 @@ e: { for (V = L, z = 0, U = B = j; U; U = vf(U)) z++; for (U = 0, Y = V; Y; Y = vf(Y)) U++; - for (; 0 < z - U; ) (B = vf(B)), z--; - for (; 0 < U - z; ) (V = vf(V)), U--; + for (; 0 < z - U; ) ((B = vf(B)), z--); + for (; 0 < U - z; ) ((V = vf(V)), U--); for (; z--; ) { if (B === V || (null !== V && B === V.alternate)) break e; - (B = vf(B)), (V = vf(V)); + ((B = vf(B)), (V = vf(V))); } B = null; } else B = null; - null !== j && wf(x, C, j, B, !1), null !== L && null !== $ && wf(x, $, L, B, !0); + (null !== j && wf(x, C, j, B, !1), + null !== L && null !== $ && wf(x, $, L, B, !0)); } if ( 'select' === @@ -18069,7 +18099,7 @@ case 'contextmenu': case 'mouseup': case 'dragend': - (Vr = !1), Ue(x, i, _); + ((Vr = !1), Ue(x, i, _)); break; case 'selectionchange': if (Br) break; @@ -18097,7 +18127,7 @@ jr ? ge(s, i) && (ae = 'onCompositionEnd') : 'keydown' === s && 229 === i.keyCode && (ae = 'onCompositionStart'); - ae && + (ae && (Cr && 'ko' !== i.locale && (jr || 'onCompositionStart' !== ae @@ -18142,7 +18172,7 @@ 0 < (u = oe(u, 'onBeforeInput')).length && ((_ = new ur('onBeforeInput', 'beforeinput', null, i, _)), x.push({ event: _, listeners: u }), - (_.data = ie)); + (_.data = ie))); } se(x, o); }); @@ -18154,12 +18184,12 @@ for (var i = o + 'Capture', u = []; null !== s; ) { var _ = s, w = _.stateNode; - 5 === _.tag && + (5 === _.tag && null !== w && ((_ = w), null != (w = Kb(s, i)) && u.unshift(tf(s, w, _)), null != (w = Kb(s, o)) && u.push(tf(s, w, _))), - (s = s.return); + (s = s.return)); } return u; } @@ -18176,13 +18206,13 @@ j = C.alternate, L = C.stateNode; if (null !== j && j === u) break; - 5 === C.tag && + (5 === C.tag && null !== L && ((C = L), _ ? null != (j = Kb(i, w)) && x.unshift(tf(i, j, C)) : _ || (null != (j = Kb(i, w)) && x.push(tf(i, j, C)))), - (i = i.return); + (i = i.return)); } 0 !== x.length && s.push({ event: o, listeners: x }); } @@ -18231,7 +18261,7 @@ var _ = i.nextSibling; if ((s.removeChild(i), _ && 8 === _.nodeType)) if ('/$' === (i = _.data)) { - if (0 === u) return s.removeChild(_), void bd(o); + if (0 === u) return (s.removeChild(_), void bd(o)); u--; } else ('$' !== i && '$?' !== i && '$!' !== i) || u++; i = _; @@ -18308,7 +18338,7 @@ 0 > _n || ((s.current = bn[_n]), (bn[_n] = null), _n--); } function G(s, o) { - _n++, (bn[_n] = s.current), (s.current = o); + (_n++, (bn[_n] = s.current), (s.current = o)); } var En = {}, wn = Uf(En), @@ -18334,11 +18364,11 @@ return null != (s = s.childContextTypes); } function $f() { - E(Sn), E(wn); + (E(Sn), E(wn)); } function ag(s, o, i) { if (wn.current !== En) throw Error(p(168)); - G(wn, o), G(Sn, i); + (G(wn, o), G(Sn, i)); } function bg(s, o, i) { var u = s.stateNode; @@ -18359,14 +18389,14 @@ function dg(s, o, i) { var u = s.stateNode; if (!u) throw Error(p(169)); - i + (i ? ((s = bg(s, o, xn)), (u.__reactInternalMemoizedMergedChildContext = s), E(Sn), E(wn), G(wn, s)) : E(Sn), - G(Sn, i); + G(Sn, i)); } var kn = null, Cn = !1, @@ -18387,11 +18417,11 @@ u = u(!0); } while (null !== u); } - (kn = null), (Cn = !1); + ((kn = null), (Cn = !1)); } catch (o) { throw (null !== kn && (kn = kn.slice(s + 1)), ct(gt, jg), o); } finally { - (At = o), (On = !1); + ((At = o), (On = !1)); } } return null; @@ -18406,36 +18436,36 @@ Rn = 1, Dn = ''; function tg(s, o) { - (An[jn++] = Pn), (An[jn++] = In), (In = s), (Pn = o); + ((An[jn++] = Pn), (An[jn++] = In), (In = s), (Pn = o)); } function ug(s, o, i) { - (Mn[Tn++] = Rn), (Mn[Tn++] = Dn), (Mn[Tn++] = Nn), (Nn = s); + ((Mn[Tn++] = Rn), (Mn[Tn++] = Dn), (Mn[Tn++] = Nn), (Nn = s)); var u = Rn; s = Dn; var _ = 32 - St(u) - 1; - (u &= ~(1 << _)), (i += 1); + ((u &= ~(1 << _)), (i += 1)); var w = 32 - St(o) + _; if (30 < w) { var x = _ - (_ % 5); - (w = (u & ((1 << x) - 1)).toString(32)), + ((w = (u & ((1 << x) - 1)).toString(32)), (u >>= x), (_ -= x), (Rn = (1 << (32 - St(o) + _)) | (i << _) | u), - (Dn = w + s); - } else (Rn = (1 << w) | (i << _) | u), (Dn = s); + (Dn = w + s)); + } else ((Rn = (1 << w) | (i << _) | u), (Dn = s)); } function vg(s) { null !== s.return && (tg(s, 1), ug(s, 1, 0)); } function wg(s) { - for (; s === In; ) (In = An[--jn]), (An[jn] = null), (Pn = An[--jn]), (An[jn] = null); + for (; s === In; ) ((In = An[--jn]), (An[jn] = null), (Pn = An[--jn]), (An[jn] = null)); for (; s === Nn; ) - (Nn = Mn[--Tn]), + ((Nn = Mn[--Tn]), (Mn[Tn] = null), (Dn = Mn[--Tn]), (Mn[Tn] = null), (Rn = Mn[--Tn]), - (Mn[Tn] = null); + (Mn[Tn] = null)); } var Ln = null, Bn = null, @@ -18443,10 +18473,10 @@ qn = null; function Ag(s, o) { var i = Bg(5, null, null, 0); - (i.elementType = 'DELETED'), + ((i.elementType = 'DELETED'), (i.stateNode = o), (i.return = s), - null === (o = s.deletions) ? ((s.deletions = [i]), (s.flags |= 16)) : o.push(i); + null === (o = s.deletions) ? ((s.deletions = [i]), (s.flags |= 16)) : o.push(i)); } function Cg(s, o) { switch (s.tag) { @@ -18498,7 +18528,7 @@ } } else { if (Dg(s)) throw Error(p(418)); - (s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Ln = s); + ((s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Ln = s)); } } } @@ -18509,7 +18539,7 @@ } function Gg(s) { if (s !== Ln) return !1; - if (!Fn) return Fg(s), (Fn = !0), !1; + if (!Fn) return (Fg(s), (Fn = !0), !1); var o; if ( ((o = 3 !== s.tag) && @@ -18518,7 +18548,7 @@ o && (o = Bn)) ) { if (Dg(s)) throw (Hg(), Error(p(418))); - for (; o; ) Ag(s, o), (o = Lf(o.nextSibling)); + for (; o; ) (Ag(s, o), (o = Lf(o.nextSibling))); } if ((Fg(s), 13 === s.tag)) { if (!(s = null !== (s = s.memoizedState) ? s.dehydrated : null)) throw Error(p(317)); @@ -18545,7 +18575,7 @@ for (var s = Bn; s; ) s = Lf(s.nextSibling); } function Ig() { - (Bn = Ln = null), (Fn = !1); + ((Bn = Ln = null), (Fn = !1)); } function Jg(s) { null === qn ? (qn = [s]) : qn.push(s); @@ -18580,7 +18610,7 @@ } function Mg(s, o) { throw ( - ((s = Object.prototype.toString.call(o)), + (s = Object.prototype.toString.call(o)), Error( p( 31, @@ -18588,7 +18618,7 @@ ? 'object with keys {' + Object.keys(o).join(', ') + '}' : s ) - )) + ) ); } function Ng(s) { @@ -18603,16 +18633,16 @@ } function c(o, i) { if (!s) return null; - for (; null !== i; ) b(o, i), (i = i.sibling); + for (; null !== i; ) (b(o, i), (i = i.sibling)); return null; } function d(s, o) { for (s = new Map(); null !== o; ) - null !== o.key ? s.set(o.key, o) : s.set(o.index, o), (o = o.sibling); + (null !== o.key ? s.set(o.key, o) : s.set(o.index, o), (o = o.sibling)); return s; } function e(s, o) { - return ((s = Pg(s, o)).index = 0), (s.sibling = null), s; + return (((s = Pg(s, o)).index = 0), (s.sibling = null), s); } function f(o, i, u) { return ( @@ -18627,7 +18657,7 @@ ); } function g(o) { - return s && null === o.alternate && (o.flags |= 2), o; + return (s && null === o.alternate && (o.flags |= 2), o); } function h(s, o, i, u) { return null === o || 6 !== o.tag @@ -18661,7 +18691,7 @@ } function q(s, o, i) { if (('string' == typeof o && '' !== o) || 'number' == typeof o) - return ((o = Qg('' + o, s.mode, i)).return = s), o; + return (((o = Qg('' + o, s.mode, i)).return = s), o); if ('object' == typeof o && null !== o) { switch (o.$$typeof) { case Y: @@ -18671,11 +18701,11 @@ i ); case Z: - return ((o = Sg(o, s.mode, i)).return = s), o; + return (((o = Sg(o, s.mode, i)).return = s), o); case be: return q(s, (0, o._init)(o._payload), i); } - if (Te(o) || Ka(o)) return ((o = Tg(o, s.mode, i, null)).return = s), o; + if (Te(o) || Ka(o)) return (((o = Tg(o, s.mode, i, null)).return = s), o); Mg(s, o); } return null; @@ -18727,18 +18757,18 @@ null === C && (C = L); break; } - s && C && null === B.alternate && b(o, C), + (s && C && null === B.alternate && b(o, C), (i = f(B, i, j)), null === x ? (w = B) : (x.sibling = B), (x = B), - (C = L); + (C = L)); } - if (j === u.length) return c(o, C), Fn && tg(o, j), w; + if (j === u.length) return (c(o, C), Fn && tg(o, j), w); if (null === C) { for (; j < u.length; j++) null !== (C = q(o, u[j], _)) && ((i = f(C, i, j)), null === x ? (w = C) : (x.sibling = C), (x = C)); - return Fn && tg(o, j), w; + return (Fn && tg(o, j), w); } for (C = d(o, C); j < u.length; j++) null !== (L = y(C, o, j, u[j], _)) && @@ -18770,18 +18800,18 @@ null === C && (C = L); break; } - s && C && null === $.alternate && b(o, C), + (s && C && null === $.alternate && b(o, C), (i = f($, i, j)), null === x ? (w = $) : (x.sibling = $), (x = $), - (C = L); + (C = L)); } - if (B.done) return c(o, C), Fn && tg(o, j), w; + if (B.done) return (c(o, C), Fn && tg(o, j), w); if (null === C) { for (; !B.done; j++, B = u.next()) null !== (B = q(o, B.value, _)) && ((i = f(B, i, j)), null === x ? (w = B) : (x.sibling = B), (x = B)); - return Fn && tg(o, j), w; + return (Fn && tg(o, j), w); } for (C = d(o, C); !B.done; j++, B = u.next()) null !== (B = y(C, o, j, B.value, _)) && @@ -18814,7 +18844,7 @@ if (w.key === _) { if ((_ = i.type) === ee) { if (7 === w.tag) { - c(s, w.sibling), ((o = e(w, i.props.children)).return = s), (s = o); + (c(s, w.sibling), ((o = e(w, i.props.children)).return = s), (s = o)); break e; } } else if ( @@ -18824,16 +18854,16 @@ _.$$typeof === be && Ng(_) === w.type) ) { - c(s, w.sibling), + (c(s, w.sibling), ((o = e(w, i.props)).ref = Lg(s, w, i)), (o.return = s), - (s = o); + (s = o)); break e; } c(s, w); break; } - b(s, w), (w = w.sibling); + (b(s, w), (w = w.sibling)); } i.type === ee ? (((o = Tg(i.props.children, s.mode, u, i.key)).return = s), (s = o)) @@ -18851,15 +18881,15 @@ o.stateNode.containerInfo === i.containerInfo && o.stateNode.implementation === i.implementation ) { - c(s, o.sibling), ((o = e(o, i.children || [])).return = s), (s = o); + (c(s, o.sibling), ((o = e(o, i.children || [])).return = s), (s = o)); break e; } c(s, o); break; } - b(s, o), (o = o.sibling); + (b(s, o), (o = o.sibling)); } - ((o = Sg(i, s.mode, u)).return = s), (s = o); + (((o = Sg(i, s.mode, u)).return = s), (s = o)); } return g(s); case be: @@ -18889,7 +18919,7 @@ } function ah(s) { var o = zn.current; - E(zn), (s._currentValue = o); + (E(zn), (s._currentValue = o)); } function bh(s, o, i) { for (; null !== s; ) { @@ -18905,18 +18935,18 @@ } } function ch(s, o) { - (Wn = s), + ((Wn = s), (Hn = Kn = null), null !== (s = s.dependencies) && null !== s.firstContext && - (!!(s.lanes & o) && (_s = !0), (s.firstContext = null)); + (!!(s.lanes & o) && (_s = !0), (s.firstContext = null))); } function eh(s) { var o = s._currentValue; if (Hn !== s) if (((s = { context: s, memoizedValue: o, next: null }), null === Kn)) { if (null === Wn) throw Error(p(308)); - (Kn = s), (Wn.dependencies = { lanes: 0, firstContext: s }); + ((Kn = s), (Wn.dependencies = { lanes: 0, firstContext: s })); } else Kn = Kn.next = s; return o; } @@ -18936,10 +18966,10 @@ s.lanes |= o; var i = s.alternate; for (null !== i && (i.lanes |= o), i = s, s = s.return; null !== s; ) - (s.childLanes |= o), + ((s.childLanes |= o), null !== (i = s.alternate) && (i.childLanes |= o), (i = s), - (s = s.return); + (s = s.return)); return 3 === i.tag ? i.stateNode : null; } var Gn = !1; @@ -18953,7 +18983,7 @@ }; } function lh(s, o) { - (s = s.updateQueue), + ((s = s.updateQueue), o.updateQueue === s && (o.updateQueue = { baseState: s.baseState, @@ -18961,7 +18991,7 @@ lastBaseUpdate: s.lastBaseUpdate, shared: s.shared, effects: s.effects - }); + })); } function mh(s, o) { return { eventTime: s, lane: o, tag: 0, payload: null, callback: null, next: null }; @@ -18988,7 +19018,7 @@ function oh(s, o, i) { if (null !== (o = o.updateQueue) && ((o = o.shared), 4194240 & i)) { var u = o.lanes; - (i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i); + ((i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i)); } } function ph(s, o) { @@ -19007,7 +19037,7 @@ callback: i.callback, next: null }; - null === w ? (_ = w = x) : (w = w.next = x), (i = i.next); + (null === w ? (_ = w = x) : (w = w.next = x), (i = i.next)); } while (null !== i); null === w ? (_ = w = o) : (w = w.next = o); } else _ = w = o; @@ -19022,8 +19052,8 @@ void (s.updateQueue = i) ); } - null === (s = i.lastBaseUpdate) ? (i.firstBaseUpdate = o) : (s.next = o), - (i.lastBaseUpdate = o); + (null === (s = i.lastBaseUpdate) ? (i.firstBaseUpdate = o) : (s.next = o), + (i.lastBaseUpdate = o)); } function qh(s, o, i, u) { var _ = s.updateQueue; @@ -19035,7 +19065,7 @@ _.shared.pending = null; var j = C, L = j.next; - (j.next = null), null === x ? (w = L) : (x.next = L), (x = j); + ((j.next = null), null === x ? (w = L) : (x.next = L), (x = j)); var B = s.alternate; null !== B && (C = (B = B.updateQueue).lastBaseUpdate) !== x && @@ -19085,7 +19115,7 @@ 0 !== C.lane && ((s.flags |= 64), null === (V = _.effects) ? (_.effects = [C]) : V.push(C)); } else - (U = { + ((U = { eventTime: U, lane: V, tag: C.tag, @@ -19094,13 +19124,13 @@ next: null }), null === B ? ((L = B = U), (j = $)) : (B = B.next = U), - (x |= V); + (x |= V)); if (null === (C = C.next)) { if (null === (C = _.shared.pending)) break; - (C = (V = C).next), + ((C = (V = C).next), (V.next = null), (_.lastBaseUpdate = V), - (_.shared.pending = null); + (_.shared.pending = null)); } } if ( @@ -19112,10 +19142,10 @@ ) { _ = o; do { - (x |= _.lane), (_ = _.next); + ((x |= _.lane), (_ = _.next)); } while (_ !== o); } else null === w && (_.shared.lanes = 0); - (Ks |= x), (s.lanes = x), (s.memoizedState = $); + ((Ks |= x), (s.lanes = x), (s.memoizedState = $)); } } function sh(s, o, i) { @@ -19150,10 +19180,10 @@ (s = s.tagName) ); } - E(Xn), G(Xn, o); + (E(Xn), G(Xn, o)); } function zh() { - E(Xn), E(Zn), E(Qn); + (E(Xn), E(Zn), E(Qn)); } function Ah(s) { xh(Qn.current); @@ -19177,7 +19207,7 @@ } else if (19 === o.tag && void 0 !== o.memoizedProps.revealOrder) { if (128 & o.flags) return o; } else if (null !== o.child) { - (o.child.return = o), (o = o.child); + ((o.child.return = o), (o = o.child)); continue; } if (o === s) break; @@ -19185,7 +19215,7 @@ if (null === o.return || o.return === s) return null; o = o.return; } - (o.sibling.return = o.return), (o = o.sibling); + ((o.sibling.return = o.return), (o = o.sibling)); } return null; } @@ -19226,11 +19256,11 @@ w = 0; do { if (((us = !1), (ps = 0), 25 <= w)) throw Error(p(301)); - (w += 1), + ((w += 1), (ls = as = null), (o.updateQueue = null), (rs.current = gs), - (s = i(u, _)); + (s = i(u, _))); } while (us); } if ( @@ -19246,7 +19276,7 @@ } function Sh() { var s = 0 !== ps; - return (ps = 0), s; + return ((ps = 0), s); } function Th() { var s = { @@ -19256,7 +19286,7 @@ queue: null, next: null }; - return null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s), ls; + return (null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s), ls); } function Uh() { if (null === as) { @@ -19264,17 +19294,17 @@ s = null !== s ? s.memoizedState : null; } else s = as.next; var o = null === ls ? os.memoizedState : ls.next; - if (null !== o) (ls = o), (as = s); + if (null !== o) ((ls = o), (as = s)); else { if (null === s) throw Error(p(310)); - (s = { + ((s = { memoizedState: (as = s).memoizedState, baseState: as.baseState, baseQueue: as.baseQueue, queue: as.queue, next: null }), - null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s); + null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s)); } return ls; } @@ -19292,19 +19322,19 @@ if (null !== w) { if (null !== _) { var x = _.next; - (_.next = w.next), (w.next = x); + ((_.next = w.next), (w.next = x)); } - (u.baseQueue = _ = w), (i.pending = null); + ((u.baseQueue = _ = w), (i.pending = null)); } if (null !== _) { - (w = _.next), (u = u.baseState); + ((w = _.next), (u = u.baseState)); var C = (x = null), j = null, L = w; do { var B = L.lane; if ((ss & B) === B) - null !== j && + (null !== j && (j = j.next = { lane: 0, @@ -19313,7 +19343,7 @@ eagerState: L.eagerState, next: null }), - (u = L.hasEagerState ? L.eagerState : s(u, L.action)); + (u = L.hasEagerState ? L.eagerState : s(u, L.action))); else { var $ = { lane: B, @@ -19322,23 +19352,23 @@ eagerState: L.eagerState, next: null }; - null === j ? ((C = j = $), (x = u)) : (j = j.next = $), + (null === j ? ((C = j = $), (x = u)) : (j = j.next = $), (os.lanes |= B), - (Ks |= B); + (Ks |= B)); } L = L.next; } while (null !== L && L !== w); - null === j ? (x = u) : (j.next = C), + (null === j ? (x = u) : (j.next = C), Lr(u, o.memoizedState) || (_s = !0), (o.memoizedState = u), (o.baseState = x), (o.baseQueue = j), - (i.lastRenderedState = u); + (i.lastRenderedState = u)); } if (null !== (s = i.interleaved)) { _ = s; do { - (w = _.lane), (os.lanes |= w), (Ks |= w), (_ = _.next); + ((w = _.lane), (os.lanes |= w), (Ks |= w), (_ = _.next)); } while (_ !== s); } else null === _ && (i.lanes = 0); return [o.memoizedState, i.dispatch]; @@ -19355,12 +19385,12 @@ i.pending = null; var x = (_ = _.next); do { - (w = s(w, x.action)), (x = x.next); + ((w = s(w, x.action)), (x = x.next)); } while (x !== _); - Lr(w, o.memoizedState) || (_s = !0), + (Lr(w, o.memoizedState) || (_s = !0), (o.memoizedState = w), null === o.baseQueue && (o.baseState = w), - (i.lastRenderedState = w); + (i.lastRenderedState = w)); } return [w, u]; } @@ -19383,16 +19413,16 @@ return _; } function di(s, o, i) { - (s.flags |= 16384), + ((s.flags |= 16384), (s = { getSnapshot: o, value: i }), null === (o = os.updateQueue) ? ((o = { lastEffect: null, stores: null }), (os.updateQueue = o), (o.stores = [s])) : null === (i = o.stores) ? (o.stores = [s]) - : i.push(s); + : i.push(s)); } function ci(s, o, i, u) { - (o.value = i), (o.getSnapshot = u), ei(o) && fi(s); + ((o.value = i), (o.getSnapshot = u), ei(o) && fi(s)); } function ai(s, o, i) { return i(function () { @@ -19449,7 +19479,7 @@ } function ki(s, o, i, u) { var _ = Th(); - (os.flags |= s), (_.memoizedState = bi(1 | o, i, void 0, void 0 === u ? null : u)); + ((os.flags |= s), (_.memoizedState = bi(1 | o, i, void 0, void 0 === u ? null : u))); } function li(s, o, i, u) { var _ = Uh(); @@ -19460,7 +19490,7 @@ if (((w = x.destroy), null !== u && Mh(u, x.deps))) return void (_.memoizedState = bi(o, i, w, u)); } - (os.flags |= s), (_.memoizedState = bi(1 | o, i, w, u)); + ((os.flags |= s), (_.memoizedState = bi(1 | o, i, w, u))); } function mi(s, o) { return ki(8390656, 8, s, o); @@ -19490,7 +19520,7 @@ : void 0; } function qi(s, o, i) { - return (i = null != i ? i.concat([s]) : null), li(4, 4, pi.bind(null, o, s), i); + return ((i = null != i ? i.concat([s]) : null), li(4, 4, pi.bind(null, o, s), i)); } function ri() {} function si(s, o) { @@ -19514,13 +19544,13 @@ } function vi(s, o) { var i = At; - (At = 0 !== i && 4 > i ? i : 4), s(!0); + ((At = 0 !== i && 4 > i ? i : 4), s(!0)); var u = ns.transition; ns.transition = {}; try { - s(!1), o(); + (s(!1), o()); } finally { - (At = i), (ns.transition = u); + ((At = i), (ns.transition = u)); } } function wi() { @@ -19533,7 +19563,7 @@ ) Ai(o, i); else if (null !== (i = hh(s, o, i, u))) { - gi(i, s, u, R()), Bi(i, o, u); + (gi(i, s, u, R()), Bi(i, o, u)); } } function ii(s, o, i) { @@ -19568,12 +19598,12 @@ function Ai(s, o) { us = cs = !0; var i = s.pending; - null === i ? (o.next = o) : ((o.next = i.next), (i.next = o)), (s.pending = o); + (null === i ? (o.next = o) : ((o.next = i.next), (i.next = o)), (s.pending = o)); } function Bi(s, o, i) { if (4194240 & i) { var u = o.lanes; - (i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i); + ((i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i)); } } var ds = { @@ -19599,13 +19629,14 @@ fs = { readContext: eh, useCallback: function (s, o) { - return (Th().memoizedState = [s, void 0 === o ? null : o]), s; + return ((Th().memoizedState = [s, void 0 === o ? null : o]), s); }, useContext: eh, useEffect: mi, useImperativeHandle: function (s, o, i) { return ( - (i = null != i ? i.concat([s]) : null), ki(4194308, 4, pi.bind(null, o, s), i) + (i = null != i ? i.concat([s]) : null), + ki(4194308, 4, pi.bind(null, o, s), i) ); }, useLayoutEffect: function (s, o) { @@ -19616,7 +19647,7 @@ }, useMemo: function (s, o) { var i = Th(); - return (o = void 0 === o ? null : o), (s = s()), (i.memoizedState = [s, o]), s; + return ((o = void 0 === o ? null : o), (s = s()), (i.memoizedState = [s, o]), s); }, useReducer: function (s, o, i) { var u = Th(); @@ -19637,7 +19668,7 @@ ); }, useRef: function (s) { - return (s = { current: s }), (Th().memoizedState = s); + return ((s = { current: s }), (Th().memoizedState = s)); }, useState: hi, useDebugValue: ri, @@ -19647,7 +19678,7 @@ useTransition: function () { var s = hi(!1), o = s[0]; - return (s = vi.bind(null, s[1])), (Th().memoizedState = s), [o, s]; + return ((s = vi.bind(null, s[1])), (Th().memoizedState = s), [o, s]); }, useMutableSource: function () {}, useSyncExternalStore: function (s, o, i) { @@ -19675,9 +19706,9 @@ o = Fs.identifierPrefix; if (Fn) { var i = Dn; - (o = ':' + o + 'R' + (i = (Rn & ~(1 << (32 - St(Rn) - 1))).toString(32) + i)), + ((o = ':' + o + 'R' + (i = (Rn & ~(1 << (32 - St(Rn) - 1))).toString(32) + i)), 0 < (i = ps++) && (o += 'H' + i.toString(32)), - (o += ':'); + (o += ':')); } else o = ':' + o + 'r' + (i = hs++).toString(32) + ':'; return (s.memoizedState = o); }, @@ -19745,9 +19776,9 @@ return o; } function Di(s, o, i, u) { - (i = null == (i = i(u, (o = s.memoizedState))) ? o : xe({}, o, i)), + ((i = null == (i = i(u, (o = s.memoizedState))) ? o : xe({}, o, i)), (s.memoizedState = i), - 0 === s.lanes && (s.updateQueue.baseState = i); + 0 === s.lanes && (s.updateQueue.baseState = i)); } var ys = { isMounted: function (s) { @@ -19758,28 +19789,28 @@ var u = R(), _ = yi(s), w = mh(u, _); - (w.payload = o), + ((w.payload = o), null != i && (w.callback = i), - null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _)); + null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _))); }, enqueueReplaceState: function (s, o, i) { s = s._reactInternals; var u = R(), _ = yi(s), w = mh(u, _); - (w.tag = 1), + ((w.tag = 1), (w.payload = o), null != i && (w.callback = i), - null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _)); + null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _))); }, enqueueForceUpdate: function (s, o) { s = s._reactInternals; var i = R(), u = yi(s), _ = mh(i, u); - (_.tag = 2), + ((_.tag = 2), null != o && (_.callback = o), - null !== (o = nh(s, _, u)) && (gi(o, s, u, i), oh(o, s, u)); + null !== (o = nh(s, _, u)) && (gi(o, s, u, i), oh(o, s, u))); } }; function Fi(s, o, i, u, _, w, x) { @@ -19808,17 +19839,17 @@ ); } function Hi(s, o, i, u) { - (s = o.state), + ((s = o.state), 'function' == typeof o.componentWillReceiveProps && o.componentWillReceiveProps(i, u), 'function' == typeof o.UNSAFE_componentWillReceiveProps && o.UNSAFE_componentWillReceiveProps(i, u), - o.state !== s && ys.enqueueReplaceState(o, o.state, null); + o.state !== s && ys.enqueueReplaceState(o, o.state, null)); } function Ii(s, o, i, u) { var _ = s.stateNode; - (_.props = i), (_.state = s.memoizedState), (_.refs = {}), kh(s); + ((_.props = i), (_.state = s.memoizedState), (_.refs = {}), kh(s)); var w = o.contextType; - 'object' == typeof w && null !== w + ('object' == typeof w && null !== w ? (_.context = eh(w)) : ((w = Zf(o) ? xn : wn.current), (_.context = Yf(s, w))), (_.state = s.memoizedState), @@ -19834,14 +19865,14 @@ o !== _.state && ys.enqueueReplaceState(_, _.state, null), qh(s, i, _, u), (_.state = s.memoizedState)), - 'function' == typeof _.componentDidMount && (s.flags |= 4194308); + 'function' == typeof _.componentDidMount && (s.flags |= 4194308)); } function Ji(s, o) { try { var i = '', u = o; do { - (i += Pa(u)), (u = u.return); + ((i += Pa(u)), (u = u.return)); } while (u); var _ = i; } catch (s) { @@ -19868,11 +19899,11 @@ } var vs = 'function' == typeof WeakMap ? WeakMap : Map; function Ni(s, o, i) { - ((i = mh(-1, i)).tag = 3), (i.payload = { element: null }); + (((i = mh(-1, i)).tag = 3), (i.payload = { element: null })); var u = o.value; return ( (i.callback = function () { - eo || ((eo = !0), (to = u)), Li(0, o); + (eo || ((eo = !0), (to = u)), Li(0, o)); }), i ); @@ -19882,20 +19913,21 @@ var u = s.type.getDerivedStateFromError; if ('function' == typeof u) { var _ = o.value; - (i.payload = function () { + ((i.payload = function () { return u(_); }), (i.callback = function () { Li(0, o); - }); + })); } var w = s.stateNode; return ( null !== w && 'function' == typeof w.componentDidCatch && (i.callback = function () { - Li(0, o), - 'function' != typeof u && (null === ro ? (ro = new Set([this])) : ro.add(this)); + (Li(0, o), + 'function' != typeof u && + (null === ro ? (ro = new Set([this])) : ro.add(this))); var s = o.stack; this.componentDidCatch(o.value, { componentStack: null !== s ? s : '' }); }), @@ -19977,14 +20009,14 @@ if ((i = null !== (i = i.compare) ? i : Ie)(x, u) && s.ref === o.ref) return Zi(s, o, _); } - return (o.flags |= 1), ((s = Pg(w, u)).ref = o.ref), (s.return = o), (o.child = s); + return ((o.flags |= 1), ((s = Pg(w, u)).ref = o.ref), (s.return = o), (o.child = s)); } function bj(s, o, i, u, _) { if (null !== s) { var w = s.memoizedProps; if (Ie(w, u) && s.ref === o.ref) { if (((_s = !1), (o.pendingProps = u = w), !(s.lanes & _))) - return (o.lanes = s.lanes), Zi(s, o, _); + return ((o.lanes = s.lanes), Zi(s, o, _)); 131072 & s.flags && (_s = !0); } } @@ -20006,19 +20038,19 @@ (Vs |= s), null ); - (o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + ((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), (u = null !== w ? w.baseLanes : i), G(Us, Vs), - (Vs |= u); + (Vs |= u)); } else - (o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + ((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), G(Us, Vs), - (Vs |= i); + (Vs |= i)); else - null !== w ? ((u = w.baseLanes | i), (o.memoizedState = null)) : (u = i), + (null !== w ? ((u = w.baseLanes | i), (o.memoizedState = null)) : (u = i), G(Us, Vs), - (Vs |= u); - return Xi(s, o, _, i), o.child; + (Vs |= u)); + return (Xi(s, o, _, i), o.child); } function gj(s, o) { var i = o.ref; @@ -20045,7 +20077,7 @@ var w = !0; cg(o); } else w = !1; - if ((ch(o, _), null === o.stateNode)) ij(s, o), Gi(o, i, u), Ii(o, i, u, _), (u = !0); + if ((ch(o, _), null === o.stateNode)) (ij(s, o), Gi(o, i, u), Ii(o, i, u, _), (u = !0)); else if (null === s) { var x = o.stateNode, C = o.memoizedProps; @@ -20057,13 +20089,13 @@ : (L = Yf(o, (L = Zf(i) ? xn : wn.current))); var B = i.getDerivedStateFromProps, $ = 'function' == typeof B || 'function' == typeof x.getSnapshotBeforeUpdate; - $ || + ($ || ('function' != typeof x.UNSAFE_componentWillReceiveProps && 'function' != typeof x.componentWillReceiveProps) || ((C !== u || j !== L) && Hi(o, x, u, L)), - (Gn = !1); + (Gn = !1)); var V = o.memoizedState; - (x.state = V), + ((x.state = V), qh(o, u, x, _), (j = o.memoizedState), C !== u || V !== j || Sn.current || Gn @@ -20083,9 +20115,9 @@ (x.state = j), (x.context = L), (u = C)) - : ('function' == typeof x.componentDidMount && (o.flags |= 4194308), (u = !1)); + : ('function' == typeof x.componentDidMount && (o.flags |= 4194308), (u = !1))); } else { - (x = o.stateNode), + ((x = o.stateNode), lh(s, o), (C = o.memoizedProps), (L = o.type === o.elementType ? C : Ci(o.type, C)), @@ -20094,16 +20126,16 @@ (V = x.context), 'object' == typeof (j = i.contextType) && null !== j ? (j = eh(j)) - : (j = Yf(o, (j = Zf(i) ? xn : wn.current))); + : (j = Yf(o, (j = Zf(i) ? xn : wn.current)))); var U = i.getDerivedStateFromProps; - (B = 'function' == typeof U || 'function' == typeof x.getSnapshotBeforeUpdate) || + ((B = 'function' == typeof U || 'function' == typeof x.getSnapshotBeforeUpdate) || ('function' != typeof x.UNSAFE_componentWillReceiveProps && 'function' != typeof x.componentWillReceiveProps) || ((C !== $ || V !== j) && Hi(o, x, u, j)), (Gn = !1), (V = o.memoizedState), (x.state = V), - qh(o, u, x, _); + qh(o, u, x, _)); var z = o.memoizedState; C !== $ || V !== z || Sn.current || Gn ? ('function' == typeof U && (Di(o, i, U, u), (z = o.memoizedState)), @@ -20142,8 +20174,8 @@ function jj(s, o, i, u, _, w) { gj(s, o); var x = !!(128 & o.flags); - if (!u && !x) return _ && dg(o, i, !1), Zi(s, o, w); - (u = o.stateNode), (bs.current = o); + if (!u && !x) return (_ && dg(o, i, !1), Zi(s, o, w)); + ((u = o.stateNode), (bs.current = o)); var C = x && 'function' != typeof i.getDerivedStateFromError ? null : u.render(); return ( (o.flags |= 1), @@ -20157,13 +20189,13 @@ } function kj(s) { var o = s.stateNode; - o.pendingContext + (o.pendingContext ? ag(0, o.pendingContext, o.pendingContext !== o.context) : o.context && ag(0, o.context, !1), - yh(s, o.containerInfo); + yh(s, o.containerInfo)); } function lj(s, o, i, u, _) { - return Ig(), Jg(_), (o.flags |= 256), Xi(s, o, i, u), o.child; + return (Ig(), Jg(_), (o.flags |= 256), Xi(s, o, i, u), o.child); } var Es, ws, @@ -20237,7 +20269,7 @@ if (!(1 & o.mode)) return sj(s, o, x, null); if ('$!' === _.data) { if ((u = _.nextSibling && _.nextSibling.dataset)) var C = u.dgst; - return (u = C), sj(s, o, x, (u = Ki((w = Error(p(419))), u, void 0))); + return ((u = C), sj(s, o, x, (u = Ki((w = Error(p(419))), u, void 0)))); } if (((C = !!(x & s.childLanes)), _s || C)) { if (null !== (u = Fs)) { @@ -20281,7 +20313,7 @@ _ !== w.retryLane && ((w.retryLane = _), ih(s, _), gi(u, s, _, -1)); } - return tj(), sj(s, o, x, (u = Ki(Error(p(421))))); + return (tj(), sj(s, o, x, (u = Ki(Error(p(421)))))); } return '$?' === _.data ? ((o.flags |= 128), @@ -20306,7 +20338,7 @@ o); })(s, o, C, _, u, w, i); if (x) { - (x = _.fallback), (C = o.mode), (u = (w = s.child).sibling); + ((x = _.fallback), (C = o.mode), (u = (w = s.child).sibling)); var j = { mode: 'hidden', children: _.children }; return ( 1 & C || o.child === w @@ -20360,7 +20392,7 @@ function vj(s, o, i) { s.lanes |= o; var u = s.alternate; - null !== u && (u.lanes |= o), bh(s.return, o, i); + (null !== u && (u.lanes |= o), bh(s.return, o, i)); } function wj(s, o, i, u, _) { var w = s.memoizedState; @@ -20385,14 +20417,14 @@ _ = u.revealOrder, w = u.tail; if ((Xi(s, o, u.children, i), 2 & (u = es.current))) - (u = (1 & u) | 2), (o.flags |= 128); + ((u = (1 & u) | 2), (o.flags |= 128)); else { if (null !== s && 128 & s.flags) e: for (s = o.child; null !== s; ) { if (13 === s.tag) null !== s.memoizedState && vj(s, i, o); else if (19 === s.tag) vj(s, i, o); else if (null !== s.child) { - (s.child.return = s), (s = s.child); + ((s.child.return = s), (s = s.child)); continue; } if (s === o) break e; @@ -20400,7 +20432,7 @@ if (null === s.return || s.return === o) break e; s = s.return; } - (s.sibling.return = s.return), (s = s.sibling); + ((s.sibling.return = s.return), (s = s.sibling)); } u &= 1; } @@ -20408,11 +20440,11 @@ switch (_) { case 'forwards': for (i = o.child, _ = null; null !== i; ) - null !== (s = i.alternate) && null === Ch(s) && (_ = i), (i = i.sibling); - null === (i = _) + (null !== (s = i.alternate) && null === Ch(s) && (_ = i), (i = i.sibling)); + (null === (i = _) ? ((_ = o.child), (o.child = null)) : ((_ = i.sibling), (i.sibling = null)), - wj(o, !1, _, i, w); + wj(o, !1, _, i, w)); break; case 'backwards': for (i = null, _ = o.child, o.child = null; null !== _; ) { @@ -20420,7 +20452,7 @@ o.child = _; break; } - (s = _.sibling), (_.sibling = i), (i = _), (_ = s); + ((s = _.sibling), (_.sibling = i), (i = _), (_ = s)); } wj(o, !0, i, null, w); break; @@ -20450,9 +20482,8 @@ for ( i = Pg((s = o.child), s.pendingProps), o.child = i, i.return = o; null !== s.sibling; - ) - (s = s.sibling), ((i = i.sibling = Pg(s, s.pendingProps)).return = o); + ((s = s.sibling), ((i = i.sibling = Pg(s, s.pendingProps)).return = o)); i.sibling = null; } return o.child; @@ -20462,12 +20493,14 @@ switch (s.tailMode) { case 'hidden': o = s.tail; - for (var i = null; null !== o; ) null !== o.alternate && (i = o), (o = o.sibling); + for (var i = null; null !== o; ) + (null !== o.alternate && (i = o), (o = o.sibling)); null === i ? (s.tail = null) : (i.sibling = null); break; case 'collapsed': i = s.tail; - for (var u = null; null !== i; ) null !== i.alternate && (u = i), (i = i.sibling); + for (var u = null; null !== i; ) + (null !== i.alternate && (u = i), (i = i.sibling)); null === u ? o || null === s.tail ? (s.tail = null) @@ -20481,19 +20514,19 @@ u = 0; if (o) for (var _ = s.child; null !== _; ) - (i |= _.lanes | _.childLanes), + ((i |= _.lanes | _.childLanes), (u |= 14680064 & _.subtreeFlags), (u |= 14680064 & _.flags), (_.return = s), - (_ = _.sibling); + (_ = _.sibling)); else for (_ = s.child; null !== _; ) - (i |= _.lanes | _.childLanes), + ((i |= _.lanes | _.childLanes), (u |= _.subtreeFlags), (u |= _.flags), (_.return = s), - (_ = _.sibling); - return (s.subtreeFlags |= u), (s.childLanes = i), o; + (_ = _.sibling)); + return ((s.subtreeFlags |= u), (s.childLanes = i), o); } function Ej(s, o, i) { var u = o.pendingProps; @@ -20508,10 +20541,10 @@ case 12: case 9: case 14: - return S(o), null; + return (S(o), null); case 1: case 17: - return Zf(o.type) && $f(), S(o), null; + return (Zf(o.type) && $f(), S(o), null); case 3: return ( (u = o.stateNode), @@ -20534,18 +20567,18 @@ Bh(o); var _ = xh(Qn.current); if (((i = o.type), null !== s && null != o.stateNode)) - Ss(s, o, i, u, _), s.ref !== o.ref && ((o.flags |= 512), (o.flags |= 2097152)); + (Ss(s, o, i, u, _), s.ref !== o.ref && ((o.flags |= 512), (o.flags |= 2097152))); else { if (!u) { if (null === o.stateNode) throw Error(p(166)); - return S(o), null; + return (S(o), null); } if (((s = xh(Xn.current)), Gg(o))) { - (u = o.stateNode), (i = o.type); + ((u = o.stateNode), (i = o.type)); var w = o.memoizedProps; switch (((u[dn] = o), (u[fn] = w), (s = !!(1 & o.mode)), i)) { case 'dialog': - D('cancel', u), D('close', u); + (D('cancel', u), D('close', u)); break; case 'iframe': case 'object': @@ -20562,19 +20595,19 @@ case 'img': case 'image': case 'link': - D('error', u), D('load', u); + (D('error', u), D('load', u)); break; case 'details': D('toggle', u); break; case 'input': - Za(u, w), D('invalid', u); + (Za(u, w), D('invalid', u)); break; case 'select': - (u._wrapperState = { wasMultiple: !!w.multiple }), D('invalid', u); + ((u._wrapperState = { wasMultiple: !!w.multiple }), D('invalid', u)); break; case 'textarea': - hb(u, w), D('invalid', u); + (hb(u, w), D('invalid', u)); } for (var C in (ub(i, w), (_ = null), w)) if (w.hasOwnProperty(C)) { @@ -20592,10 +20625,10 @@ } switch (i) { case 'input': - Va(u), db(u, w, !0); + (Va(u), db(u, w, !0)); break; case 'textarea': - Va(u), jb(u); + (Va(u), jb(u)); break; case 'select': case 'option': @@ -20603,9 +20636,9 @@ default: 'function' == typeof w.onClick && (u.onclick = Bf); } - (u = _), (o.updateQueue = u), null !== u && (o.flags |= 4); + ((u = _), (o.updateQueue = u), null !== u && (o.flags |= 4)); } else { - (C = 9 === _.nodeType ? _ : _.ownerDocument), + ((C = 9 === _.nodeType ? _ : _.ownerDocument), 'http://www.w3.org/1999/xhtml' === s && (s = kb(i)), 'http://www.w3.org/1999/xhtml' === s ? 'script' === i @@ -20621,16 +20654,16 @@ (s[dn] = o), (s[fn] = u), Es(s, o, !1, !1), - (o.stateNode = s); + (o.stateNode = s)); e: { switch (((C = vb(i, u)), i)) { case 'dialog': - D('cancel', s), D('close', s), (_ = u); + (D('cancel', s), D('close', s), (_ = u)); break; case 'iframe': case 'object': case 'embed': - D('load', s), (_ = u); + (D('load', s), (_ = u)); break; case 'video': case 'audio': @@ -20638,30 +20671,30 @@ _ = u; break; case 'source': - D('error', s), (_ = u); + (D('error', s), (_ = u)); break; case 'img': case 'image': case 'link': - D('error', s), D('load', s), (_ = u); + (D('error', s), D('load', s), (_ = u)); break; case 'details': - D('toggle', s), (_ = u); + (D('toggle', s), (_ = u)); break; case 'input': - Za(s, u), (_ = Ya(s, u)), D('invalid', s); + (Za(s, u), (_ = Ya(s, u)), D('invalid', s)); break; case 'option': default: _ = u; break; case 'select': - (s._wrapperState = { wasMultiple: !!u.multiple }), + ((s._wrapperState = { wasMultiple: !!u.multiple }), (_ = xe({}, u, { value: void 0 })), - D('invalid', s); + D('invalid', s)); break; case 'textarea': - hb(s, u), (_ = gb(s, u)), D('invalid', s); + (hb(s, u), (_ = gb(s, u)), D('invalid', s)); } for (w in (ub(i, _), (j = _))) if (j.hasOwnProperty(w)) { @@ -20683,19 +20716,19 @@ } switch (i) { case 'input': - Va(s), db(s, u, !1); + (Va(s), db(s, u, !1)); break; case 'textarea': - Va(s), jb(s); + (Va(s), jb(s)); break; case 'option': null != u.value && s.setAttribute('value', '' + Sa(u.value)); break; case 'select': - (s.multiple = !!u.multiple), + ((s.multiple = !!u.multiple), null != (w = u.value) ? fb(s, !!u.multiple, w, !1) - : null != u.defaultValue && fb(s, !!u.multiple, u.defaultValue, !0); + : null != u.defaultValue && fb(s, !!u.multiple, u.defaultValue, !0)); break; default: 'function' == typeof _.onClick && (s.onclick = Bf); @@ -20718,7 +20751,7 @@ } null !== o.ref && ((o.flags |= 512), (o.flags |= 2097152)); } - return S(o), null; + return (S(o), null); case 6: if (s && null != o.stateNode) xs(s, o, s.memoizedProps, u); else { @@ -20740,10 +20773,10 @@ } w && (o.flags |= 4); } else - ((u = (9 === i.nodeType ? i : i.ownerDocument).createTextNode(u))[dn] = o), - (o.stateNode = u); + (((u = (9 === i.nodeType ? i : i.ownerDocument).createTextNode(u))[dn] = o), + (o.stateNode = u)); } - return S(o), null; + return (S(o), null); case 13: if ( (E(es), @@ -20751,16 +20784,16 @@ null === s || (null !== s.memoizedState && null !== s.memoizedState.dehydrated)) ) { if (Fn && null !== Bn && 1 & o.mode && !(128 & o.flags)) - Hg(), Ig(), (o.flags |= 98560), (w = !1); + (Hg(), Ig(), (o.flags |= 98560), (w = !1)); else if (((w = Gg(o)), null !== u && null !== u.dehydrated)) { if (null === s) { if (!w) throw Error(p(318)); if (!(w = null !== (w = o.memoizedState) ? w.dehydrated : null)) throw Error(p(317)); w[dn] = o; - } else Ig(), !(128 & o.flags) && (o.memoizedState = null), (o.flags |= 4); - S(o), (w = !1); - } else null !== qn && (Fj(qn), (qn = null)), (w = !0); + } else (Ig(), !(128 & o.flags) && (o.memoizedState = null), (o.flags |= 4)); + (S(o), (w = !1)); + } else (null !== qn && (Fj(qn), (qn = null)), (w = !0)); if (!w) return 65536 & o.flags ? o : null; } return 128 & o.flags @@ -20773,11 +20806,11 @@ S(o), null); case 4: - return zh(), ws(s, o), null === s && sf(o.stateNode.containerInfo), S(o), null; + return (zh(), ws(s, o), null === s && sf(o.stateNode.containerInfo), S(o), null); case 10: - return ah(o.type._context), S(o), null; + return (ah(o.type._context), S(o), null); case 19: - if ((E(es), null === (w = o.memoizedState))) return S(o), null; + if ((E(es), null === (w = o.memoizedState))) return (S(o), null); if (((u = !!(128 & o.flags)), null === (C = w.rendering))) if (u) Dj(w, !1); else { @@ -20792,9 +20825,8 @@ u = i, i = o.child; null !== i; - ) - (s = u), + ((s = u), ((w = i).flags &= 14680066), null === (C = w.alternate) ? ((w.childLanes = 0), @@ -20820,8 +20852,8 @@ null === s ? null : { lanes: s.lanes, firstContext: s.firstContext })), - (i = i.sibling); - return G(es, (1 & es.current) | 2), o.child; + (i = i.sibling)); + return (G(es, (1 & es.current) | 2), o.child); } s = s.sibling; } @@ -20839,7 +20871,7 @@ Dj(w, !0), null === w.tail && 'hidden' === w.tailMode && !C.alternate && !Fn) ) - return S(o), null; + return (S(o), null); } else 2 * dt() - w.renderingStartTime > Zs && 1073741824 !== i && @@ -20891,7 +20923,7 @@ 65536 & (s = o.flags) && !(128 & s) ? ((o.flags = (-65537 & s) | 128), o) : null ); case 5: - return Bh(o), null; + return (Bh(o), null); case 13: if ((E(es), null !== (s = o.memoizedState) && null !== s.dehydrated)) { if (null === o.alternate) throw Error(p(340)); @@ -20899,23 +20931,23 @@ } return 65536 & (s = o.flags) ? ((o.flags = (-65537 & s) | 128), o) : null; case 19: - return E(es), null; + return (E(es), null); case 4: - return zh(), null; + return (zh(), null); case 10: - return ah(o.type._context), null; + return (ah(o.type._context), null); case 22: case 23: - return Hj(), null; + return (Hj(), null); default: return null; } } - (Es = function (s, o) { + ((Es = function (s, o) { for (var i = o.child; null !== i; ) { if (5 === i.tag || 6 === i.tag) s.appendChild(i.stateNode); else if (4 !== i.tag && null !== i.child) { - (i.child.return = i), (i = i.child); + ((i.child.return = i), (i = i.child)); continue; } if (i === o) break; @@ -20923,27 +20955,27 @@ if (null === i.return || i.return === o) return; i = i.return; } - (i.sibling.return = i.return), (i = i.sibling); + ((i.sibling.return = i.return), (i = i.sibling)); } }), (ws = function () {}), (Ss = function (s, o, i, u) { var _ = s.memoizedProps; if (_ !== u) { - (s = o.stateNode), xh(Xn.current); + ((s = o.stateNode), xh(Xn.current)); var w, C = null; switch (i) { case 'input': - (_ = Ya(s, _)), (u = Ya(s, u)), (C = []); + ((_ = Ya(s, _)), (u = Ya(s, u)), (C = [])); break; case 'select': - (_ = xe({}, _, { value: void 0 })), + ((_ = xe({}, _, { value: void 0 })), (u = xe({}, u, { value: void 0 })), - (C = []); + (C = [])); break; case 'textarea': - (_ = gb(s, _)), (u = gb(s, u)), (C = []); + ((_ = gb(s, _)), (u = gb(s, u)), (C = [])); break; default: 'function' != typeof _.onClick && @@ -20976,7 +21008,7 @@ (i || (i = {}), (i[w] = '')); for (w in L) L.hasOwnProperty(w) && j[w] !== L[w] && (i || (i = {}), (i[w] = L[w])); - } else i || (C || (C = []), C.push(B, i)), (i = L); + } else (i || (C || (C = []), C.push(B, i)), (i = L)); else 'dangerouslySetInnerHTML' === B ? ((L = L ? L.__html : void 0), @@ -20999,7 +21031,7 @@ }), (xs = function (s, o, i, u) { i !== u && (o.flags |= 4); - }); + })); var Cs = !1, Os = !1, As = 'function' == typeof WeakSet ? WeakSet : Set, @@ -21030,7 +21062,7 @@ do { if ((_.tag & s) === s) { var w = _.destroy; - (_.destroy = void 0), void 0 !== w && Mj(o, i, w); + ((_.destroy = void 0), void 0 !== w && Mj(o, i, w)); } _ = _.next; } while (_ !== u); @@ -21052,12 +21084,12 @@ var o = s.ref; if (null !== o) { var i = s.stateNode; - s.tag, (s = i), 'function' == typeof o ? o(s) : (o.current = s); + (s.tag, (s = i), 'function' == typeof o ? o(s) : (o.current = s)); } } function Sj(s) { var o = s.alternate; - null !== o && ((s.alternate = null), Sj(o)), + (null !== o && ((s.alternate = null), Sj(o)), (s.child = null), (s.deletions = null), (s.sibling = null), @@ -21071,7 +21103,7 @@ (s.memoizedState = null), (s.pendingProps = null), (s.stateNode = null), - (s.updateQueue = null); + (s.updateQueue = null)); } function Tj(s) { return 5 === s.tag || 3 === s.tag || 4 === s.tag; @@ -21085,11 +21117,10 @@ for ( s.sibling.return = s.return, s = s.sibling; 5 !== s.tag && 6 !== s.tag && 18 !== s.tag; - ) { if (2 & s.flags) continue e; if (null === s.child || 4 === s.tag) continue e; - (s.child.return = s), (s = s.child); + ((s.child.return = s), (s = s.child)); } if (!(2 & s.flags)) return s.stateNode; } @@ -21097,7 +21128,7 @@ function Vj(s, o, i) { var u = s.tag; if (5 === u || 6 === u) - (s = s.stateNode), + ((s = s.stateNode), o ? 8 === i.nodeType ? i.parentNode.insertBefore(s, o) @@ -21105,20 +21136,21 @@ : (8 === i.nodeType ? (o = i.parentNode).insertBefore(s, i) : (o = i).appendChild(s), - null != (i = i._reactRootContainer) || null !== o.onclick || (o.onclick = Bf)); + null != (i = i._reactRootContainer) || null !== o.onclick || (o.onclick = Bf))); else if (4 !== u && null !== (s = s.child)) - for (Vj(s, o, i), s = s.sibling; null !== s; ) Vj(s, o, i), (s = s.sibling); + for (Vj(s, o, i), s = s.sibling; null !== s; ) (Vj(s, o, i), (s = s.sibling)); } function Wj(s, o, i) { var u = s.tag; - if (5 === u || 6 === u) (s = s.stateNode), o ? i.insertBefore(s, o) : i.appendChild(s); + if (5 === u || 6 === u) + ((s = s.stateNode), o ? i.insertBefore(s, o) : i.appendChild(s)); else if (4 !== u && null !== (s = s.child)) - for (Wj(s, o, i), s = s.sibling; null !== s; ) Wj(s, o, i), (s = s.sibling); + for (Wj(s, o, i), s = s.sibling; null !== s; ) (Wj(s, o, i), (s = s.sibling)); } var Ps = null, Ms = !1; function Yj(s, o, i) { - for (i = i.child; null !== i; ) Zj(s, o, i), (i = i.sibling); + for (i = i.child; null !== i; ) (Zj(s, o, i), (i = i.sibling)); } function Zj(s, o, i) { if (wt && 'function' == typeof wt.onCommitFiberUnmount) @@ -21131,7 +21163,7 @@ case 6: var u = Ps, _ = Ms; - (Ps = null), + ((Ps = null), Yj(s, o, i), (Ms = _), null !== (Ps = u) && @@ -21139,7 +21171,7 @@ ? ((s = Ps), (i = i.stateNode), 8 === s.nodeType ? s.parentNode.removeChild(i) : s.removeChild(i)) - : Ps.removeChild(i.stateNode)); + : Ps.removeChild(i.stateNode))); break; case 18: null !== Ps && @@ -21151,13 +21183,13 @@ : Kf(Ps, i.stateNode)); break; case 4: - (u = Ps), + ((u = Ps), (_ = Ms), (Ps = i.stateNode.containerInfo), (Ms = !0), Yj(s, o, i), (Ps = u), - (Ms = _); + (Ms = _)); break; case 0: case 11: @@ -21168,7 +21200,7 @@ do { var w = _, x = w.destroy; - (w = w.tag), void 0 !== x && (2 & w || 4 & w) && Mj(i, o, x), (_ = _.next); + ((w = w.tag), void 0 !== x && (2 & w || 4 & w) && Mj(i, o, x), (_ = _.next)); } while (_ !== u); } Yj(s, o, i); @@ -21176,9 +21208,9 @@ case 1: if (!Os && (Lj(i, o), 'function' == typeof (u = i.stateNode).componentWillUnmount)) try { - (u.props = i.memoizedProps), + ((u.props = i.memoizedProps), (u.state = i.memoizedState), - u.componentWillUnmount(); + u.componentWillUnmount()); } catch (s) { W(i, o, s); } @@ -21201,11 +21233,11 @@ if (null !== o) { s.updateQueue = null; var i = s.stateNode; - null === i && (i = s.stateNode = new As()), + (null === i && (i = s.stateNode = new As()), o.forEach(function (o) { var u = bk.bind(null, s, o); i.has(o) || (i.add(o), o.then(u, u)); - }); + })); } } function ck(s, o) { @@ -21220,24 +21252,24 @@ e: for (; null !== C; ) { switch (C.tag) { case 5: - (Ps = C.stateNode), (Ms = !1); + ((Ps = C.stateNode), (Ms = !1)); break e; case 3: case 4: - (Ps = C.stateNode.containerInfo), (Ms = !0); + ((Ps = C.stateNode.containerInfo), (Ms = !0)); break e; } C = C.return; } if (null === Ps) throw Error(p(160)); - Zj(w, x, _), (Ps = null), (Ms = !1); + (Zj(w, x, _), (Ps = null), (Ms = !1)); var j = _.alternate; - null !== j && (j.return = null), (_.return = null); + (null !== j && (j.return = null), (_.return = null)); } catch (s) { W(_, o, s); } } - if (12854 & o.subtreeFlags) for (o = o.child; null !== o; ) dk(o, s), (o = o.sibling); + if (12854 & o.subtreeFlags) for (o = o.child; null !== o; ) (dk(o, s), (o = o.sibling)); } function dk(s, o) { var i = s.alternate, @@ -21249,7 +21281,7 @@ case 15: if ((ck(o, s), ek(s), 4 & u)) { try { - Pj(3, s, s.return), Qj(3, s); + (Pj(3, s, s.return), Qj(3, s)); } catch (o) { W(s, s.return, o); } @@ -21261,7 +21293,7 @@ } break; case 1: - ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return); + (ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return)); break; case 5: if ((ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return), 32 & s.flags)) { @@ -21279,7 +21311,7 @@ j = s.updateQueue; if (((s.updateQueue = null), null !== j)) try { - 'input' === C && 'radio' === w.type && null != w.name && ab(_, w), vb(C, x); + ('input' === C && 'radio' === w.type && null != w.name && ab(_, w), vb(C, x)); var L = vb(C, w); for (x = 0; x < j.length; x += 2) { var B = j[x], @@ -21319,7 +21351,7 @@ case 6: if ((ck(o, s), ek(s), 4 & u)) { if (null === s.stateNode) throw Error(p(162)); - (_ = s.stateNode), (w = s.memoizedProps); + ((_ = s.stateNode), (w = s.memoizedProps)); try { _.nodeValue = w; } catch (o) { @@ -21337,10 +21369,10 @@ break; case 4: default: - ck(o, s), ek(s); + (ck(o, s), ek(s)); break; case 13: - ck(o, s), + (ck(o, s), ek(s), 8192 & (_ = s.child).flags && ((w = null !== _.memoizedState), @@ -21348,7 +21380,7 @@ !w || (null !== _.alternate && null !== _.alternate.memoizedState) || (Xs = dt())), - 4 & u && ak(s); + 4 & u && ak(s)); break; case 22: if ( @@ -21373,12 +21405,12 @@ Lj(V, V.return); var z = V.stateNode; if ('function' == typeof z.componentWillUnmount) { - (u = V), (i = V.return); + ((u = V), (i = V.return)); try { - (o = u), + ((o = u), (z.props = o.memoizedProps), (z.state = o.memoizedState), - z.componentWillUnmount(); + z.componentWillUnmount()); } catch (s) { W(u, i, s); } @@ -21402,7 +21434,7 @@ if (null === B) { B = $; try { - (_ = $.stateNode), + ((_ = $.stateNode), L ? 'function' == typeof (w = _.style).setProperty ? w.setProperty('display', 'none', 'important') @@ -21412,7 +21444,7 @@ null != (j = $.memoizedProps.style) && j.hasOwnProperty('display') ? j.display : null), - (C.style.display = rb('display', x))); + (C.style.display = rb('display', x)))); } catch (o) { W(s, s.return, o); } @@ -21428,20 +21460,20 @@ ((22 !== $.tag && 23 !== $.tag) || null === $.memoizedState || $ === s) && null !== $.child ) { - ($.child.return = $), ($ = $.child); + (($.child.return = $), ($ = $.child)); continue; } if ($ === s) break e; for (; null === $.sibling; ) { if (null === $.return || $.return === s) break e; - B === $ && (B = null), ($ = $.return); + (B === $ && (B = null), ($ = $.return)); } - B === $ && (B = null), ($.sibling.return = $.return), ($ = $.sibling); + (B === $ && (B = null), ($.sibling.return = $.return), ($ = $.sibling)); } } break; case 19: - ck(o, s), ek(s), 4 & u && ak(s); + (ck(o, s), ek(s), 4 & u && ak(s)); case 21: } } @@ -21462,7 +21494,7 @@ switch (u.tag) { case 5: var _ = u.stateNode; - 32 & u.flags && (ob(_, ''), (u.flags &= -33)), Wj(s, Uj(s), _); + (32 & u.flags && (ob(_, ''), (u.flags &= -33)), Wj(s, Uj(s), _)); break; case 3: case 4: @@ -21480,7 +21512,7 @@ 4096 & o && (s.flags &= -4097); } function hk(s, o, i) { - (js = s), ik(s, o, i); + ((js = s), ik(s, o, i)); } function ik(s, o, i) { for (var u = !!(1 & s.mode); null !== js; ) { @@ -21495,14 +21527,14 @@ var L = Os; if (((Cs = x), (Os = j) && !L)) for (js = _; null !== js; ) - (j = (x = js).child), + ((j = (x = js).child), 22 === x.tag && null !== x.memoizedState ? jk(_) : null !== j ? ((j.return = x), (js = j)) - : jk(_); - for (; null !== w; ) (js = w), ik(w, o, i), (w = w.sibling); - (js = _), (Cs = C), (Os = L); + : jk(_)); + for (; null !== w; ) ((js = w), ik(w, o, i), (w = w.sibling)); + ((js = _), (Cs = C), (Os = L)); } kk(s); } else 8772 & _.subtreeFlags && null !== w ? ((w.return = _), (js = w)) : kk(s); @@ -21603,7 +21635,7 @@ break; } if (null !== (i = o.sibling)) { - (i.return = o.return), (js = i); + ((i.return = o.return), (js = i)); break; } js = o.return; @@ -21618,7 +21650,7 @@ } var i = o.sibling; if (null !== i) { - (i.return = o.return), (js = i); + ((i.return = o.return), (js = i)); break; } js = o.return; @@ -21673,7 +21705,7 @@ } var C = o.sibling; if (null !== C) { - (C.return = o.return), (js = C); + ((C.return = o.return), (js = C)); break; } js = o.return; @@ -21726,11 +21758,11 @@ } function gi(s, o, i, u) { if (50 < io) throw ((io = 0), (ao = null), Error(p(185))); - Ac(s, i, u), + (Ac(s, i, u), (2 & Bs && s === Fs) || (s === Fs && (!(2 & Bs) && (Hs |= i), 4 === zs && Ck(s, $s)), Dk(s, u), - 1 === i && 0 === Bs && !(1 & o.mode) && ((Zs = dt() + 500), Cn && jg())); + 1 === i && 0 === Bs && !(1 & o.mode) && ((Zs = dt() + 500), Cn && jg()))); } function Dk(s, o) { var i = s.callbackNode; @@ -21741,30 +21773,29 @@ _ = s.expirationTimes, w = s.pendingLanes; 0 < w; - ) { var x = 31 - St(w), C = 1 << x, j = _[x]; - -1 === j + (-1 === j ? (C & i && !(C & u)) || (_[x] = vc(C, o)) : j <= o && (s.expiredLanes |= C), - (w &= ~C); + (w &= ~C)); } })(s, o); var u = uc(s, s === Fs ? $s : 0); - if (0 === u) null !== i && ut(i), (s.callbackNode = null), (s.callbackPriority = 0); + if (0 === u) (null !== i && ut(i), (s.callbackNode = null), (s.callbackPriority = 0)); else if (((o = u & -u), s.callbackPriority !== o)) { if ((null != i && ut(i), 1 === o)) - 0 === s.tag + (0 === s.tag ? (function ig(s) { - (Cn = !0), hg(s); + ((Cn = !0), hg(s)); })(Ek.bind(null, s)) : hg(Ek.bind(null, s)), pn(function () { !(6 & Bs) && jg(); }), - (i = null); + (i = null)); else { switch (Dc(u)) { case 1: @@ -21782,7 +21813,7 @@ } i = Fk(i, Gk.bind(null, s)); } - (s.callbackPriority = o), (s.callbackNode = i); + ((s.callbackPriority = o), (s.callbackNode = i)); } } function Gk(s, o) { @@ -21804,10 +21835,10 @@ } catch (o) { Mk(s, o); } - $g(), + ($g(), (Rs.current = w), (Bs = _), - null !== qs ? (o = 0) : ((Fs = null), ($s = 0), (o = zs)); + null !== qs ? (o = 0) : ((Fs = null), ($s = 0), (o = zs))); } if (0 !== o) { if ((2 === o && 0 !== (_ = xc(s)) && ((u = _), (o = Nk(s, _))), 1 === o)) @@ -21835,14 +21866,14 @@ } } if (((i = o.child), 16384 & o.subtreeFlags && null !== i)) - (i.return = o), (o = i); + ((i.return = o), (o = i)); else { if (o === s) break; for (; null === o.sibling; ) { if (null === o.return || o.return === s) return !0; o = o.return; } - (o.sibling.return = o.return), (o = o.sibling); + ((o.sibling.return = o.return), (o = o.sibling)); } } return !0; @@ -21865,7 +21896,7 @@ if ((Ck(s, u), (130023424 & u) === u && 10 < (o = Xs + 500 - dt()))) { if (0 !== uc(s, 0)) break; if (((_ = s.suspendedLanes) & u) !== u) { - R(), (s.pingedLanes |= s.suspendedLanes & _); + (R(), (s.pingedLanes |= s.suspendedLanes & _)); break; } s.timeoutHandle = ln(Pk.bind(null, s, Ys, Qs), o); @@ -21877,7 +21908,7 @@ if ((Ck(s, u), (4194240 & u) === u)) break; for (o = s.eventTimes, _ = -1; 0 < u; ) { var x = 31 - St(u); - (w = 1 << x), (x = o[x]) > _ && (_ = x), (u &= ~w); + ((w = 1 << x), (x = o[x]) > _ && (_ = x), (u &= ~w)); } if ( ((u = _), @@ -21907,7 +21938,7 @@ } } } - return Dk(s, dt()), s.callbackNode === i ? Gk.bind(null, s) : null; + return (Dk(s, dt()), s.callbackNode === i ? Gk.bind(null, s) : null); } function Nk(s, o) { var i = Gs; @@ -21924,18 +21955,17 @@ for ( o &= ~Js, o &= ~Hs, s.suspendedLanes |= o, s.pingedLanes &= ~o, s = s.expirationTimes; 0 < o; - ) { var i = 31 - St(o), u = 1 << i; - (s[i] = -1), (o &= ~u); + ((s[i] = -1), (o &= ~u)); } } function Ek(s) { if (6 & Bs) throw Error(p(327)); Hk(); var o = uc(s, 0); - if (!(1 & o)) return Dk(s, dt()), null; + if (!(1 & o)) return (Dk(s, dt()), null); var i = Ik(s, o); if (0 !== s.tag && 2 === i) { var u = xc(s); @@ -21969,14 +21999,14 @@ try { if (((Ls.transition = null), (At = 1), s)) return s(); } finally { - (At = u), (Ls.transition = i), !(6 & (Bs = o)) && jg(); + ((At = u), (Ls.transition = i), !(6 & (Bs = o)) && jg()); } } function Hj() { - (Vs = Us.current), E(Us); + ((Vs = Us.current), E(Us)); } function Kk(s, o) { - (s.finishedWork = null), (s.finishedLanes = 0); + ((s.finishedWork = null), (s.finishedLanes = 0)); var i = s.timeoutHandle; if ((-1 !== i && ((s.timeoutHandle = -1), cn(i)), null !== qs)) for (i = qs.return; null !== i; ) { @@ -21986,7 +22016,7 @@ null != (u = u.type.childContextTypes) && $f(); break; case 3: - zh(), E(Sn), E(wn), Eh(); + (zh(), E(Sn), E(wn), Eh()); break; case 5: Bh(u); @@ -22024,7 +22054,7 @@ w = i.pending; if (null !== w) { var x = w.next; - (w.next = _), (u.next = x); + ((w.next = _), (u.next = x)); } i.pending = u; } @@ -22039,7 +22069,7 @@ if (($g(), (rs.current = ds), cs)) { for (var u = os.memoizedState; null !== u; ) { var _ = u.queue; - null !== _ && (_.pending = null), (u = u.next); + (null !== _ && (_.pending = null), (u = u.next)); } cs = !1; } @@ -22051,7 +22081,7 @@ (Ds.current = null), null === i || null === i.return) ) { - (zs = 1), (Ws = o), (qs = null); + ((zs = 1), (Ws = o), (qs = null)); break; } e: { @@ -22077,34 +22107,34 @@ } var U = Ui(x); if (null !== U) { - (U.flags &= -257), Vi(U, x, C, 0, o), 1 & U.mode && Si(w, L, o), (j = L); + ((U.flags &= -257), Vi(U, x, C, 0, o), 1 & U.mode && Si(w, L, o), (j = L)); var z = (o = U).updateQueue; if (null === z) { var Y = new Set(); - Y.add(j), (o.updateQueue = Y); + (Y.add(j), (o.updateQueue = Y)); } else z.add(j); break e; } if (!(1 & o)) { - Si(w, L, o), tj(); + (Si(w, L, o), tj()); break e; } j = Error(p(426)); } else if (Fn && 1 & C.mode) { var Z = Ui(x); if (null !== Z) { - !(65536 & Z.flags) && (Z.flags |= 256), Vi(Z, x, C, 0, o), Jg(Ji(j, C)); + (!(65536 & Z.flags) && (Z.flags |= 256), Vi(Z, x, C, 0, o), Jg(Ji(j, C))); break e; } } - (w = j = Ji(j, C)), + ((w = j = Ji(j, C)), 4 !== zs && (zs = 2), null === Gs ? (Gs = [w]) : Gs.push(w), - (w = x); + (w = x)); do { switch (w.tag) { case 3: - (w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Ni(0, j, o)); + ((w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Ni(0, j, o))); break e; case 1: C = j; @@ -22119,7 +22149,7 @@ (null !== ro && ro.has(ie)))) ) ) { - (w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Qi(w, C, o)); + ((w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Qi(w, C, o))); break e; } } @@ -22128,7 +22158,7 @@ } Sk(i); } catch (s) { - (o = s), qs === i && null !== i && (qs = i = i.return); + ((o = s), qs === i && null !== i && (qs = i = i.return)); continue; } break; @@ -22136,11 +22166,11 @@ } function Jk() { var s = Rs.current; - return (Rs.current = ds), null === s ? ds : s; + return ((Rs.current = ds), null === s ? ds : s); } function tj() { - (0 !== zs && 3 !== zs && 2 !== zs) || (zs = 4), - null === Fs || (!(268435455 & Ks) && !(268435455 & Hs)) || Ck(Fs, $s); + ((0 !== zs && 3 !== zs && 2 !== zs) || (zs = 4), + null === Fs || (!(268435455 & Ks) && !(268435455 & Hs)) || Ck(Fs, $s)); } function Ik(s, o) { var i = Bs; @@ -22154,7 +22184,7 @@ Mk(s, o); } if (($g(), (Bs = i), (Rs.current = u), null !== qs)) throw Error(p(261)); - return (Fs = null), ($s = 0), zs; + return ((Fs = null), ($s = 0), zs); } function Tk() { for (; null !== qs; ) Uk(qs); @@ -22164,16 +22194,18 @@ } function Uk(s) { var o = Ts(s.alternate, s, Vs); - (s.memoizedProps = s.pendingProps), null === o ? Sk(s) : (qs = o), (Ds.current = null); + ((s.memoizedProps = s.pendingProps), + null === o ? Sk(s) : (qs = o), + (Ds.current = null)); } function Sk(s) { var o = s; do { var i = o.alternate; if (((s = o.return), 32768 & o.flags)) { - if (null !== (i = Ij(i, o))) return (i.flags &= 32767), void (qs = i); - if (null === s) return (zs = 6), void (qs = null); - (s.flags |= 32768), (s.subtreeFlags = 0), (s.deletions = null); + if (null !== (i = Ij(i, o))) return ((i.flags &= 32767), void (qs = i)); + if (null === s) return ((zs = 6), void (qs = null)); + ((s.flags |= 32768), (s.subtreeFlags = 0), (s.deletions = null)); } else if (null !== (i = Ej(i, o, Vs))) return void (qs = i); if (null !== (o = o.sibling)) return void (qs = o); qs = o = s; @@ -22184,7 +22216,7 @@ var u = At, _ = Ls.transition; try { - (Ls.transition = null), + ((Ls.transition = null), (At = 1), (function Wk(s, o, i, u) { do { @@ -22196,23 +22228,23 @@ if (null === i) return null; if (((s.finishedWork = null), (s.finishedLanes = 0), i === s.current)) throw Error(p(177)); - (s.callbackNode = null), (s.callbackPriority = 0); + ((s.callbackNode = null), (s.callbackPriority = 0)); var w = i.lanes | i.childLanes; if ( ((function Bc(s, o) { var i = s.pendingLanes & ~o; - (s.pendingLanes = o), + ((s.pendingLanes = o), (s.suspendedLanes = 0), (s.pingedLanes = 0), (s.expiredLanes &= o), (s.mutableReadLanes &= o), (s.entangledLanes &= o), - (o = s.entanglements); + (o = s.entanglements)); var u = s.eventTimes; for (s = s.expirationTimes; 0 < i; ) { var _ = 31 - St(i), w = 1 << _; - (o[_] = 0), (u[_] = -1), (s[_] = -1), (i &= ~w); + ((o[_] = 0), (u[_] = -1), (s[_] = -1), (i &= ~w)); } })(s, w), s === Fs && ((qs = Fs = null), ($s = 0)), @@ -22220,16 +22252,16 @@ no || ((no = !0), Fk(vt, function () { - return Hk(), null; + return (Hk(), null); })), (w = !!(15990 & i.flags)), !!(15990 & i.subtreeFlags) || w) ) { - (w = Ls.transition), (Ls.transition = null); + ((w = Ls.transition), (Ls.transition = null)); var x = At; At = 1; var C = Bs; - (Bs |= 4), + ((Bs |= 4), (Ds.current = null), (function Oj(s, o) { if (((on = zt), Ne((s = Me())))) { @@ -22246,7 +22278,7 @@ w = u.focusNode; u = u.focusOffset; try { - i.nodeType, w.nodeType; + (i.nodeType, w.nodeType); } catch (s) { i = null; break e; @@ -22265,9 +22297,8 @@ $ !== w || (0 !== u && 3 !== $.nodeType) || (j = x + u), 3 === $.nodeType && (x += $.nodeValue.length), null !== (U = $.firstChild); - ) - (V = $), ($ = U); + ((V = $), ($ = U)); for (;;) { if ($ === s) break t; if ( @@ -22288,10 +22319,9 @@ for ( an = { focusedElem: s, selectionRange: i }, zt = !1, js = o; null !== js; - ) if (((s = (o = js).child), 1028 & o.subtreeFlags && null !== s)) - (s.return = o), (js = s); + ((s.return = o), (js = s)); else for (; null !== js; ) { o = js; @@ -22334,12 +22364,12 @@ W(o, o.return, s); } if (null !== (s = o.sibling)) { - (s.return = o.return), (js = s); + ((s.return = o.return), (js = s)); break; } js = o.return; } - return (z = Is), (Is = !1), z; + return ((z = Is), (Is = !1), z); })(s, i), dk(i, s), Oe(an), @@ -22350,7 +22380,7 @@ ht(), (Bs = C), (At = x), - (Ls.transition = w); + (Ls.transition = w)); } else s.current = i; if ( (no && ((no = !1), (so = s), (oo = _)), @@ -22366,7 +22396,7 @@ null !== o) ) for (u = s.onRecoverableError, i = 0; i < o.length; i++) - (_ = o[i]), u(_.value, { componentStack: _.stack, digest: _.digest }); + ((_ = o[i]), u(_.value, { componentStack: _.stack, digest: _.digest })); if (eo) throw ((eo = !1), (s = to), (to = null), s); return ( !!(1 & oo) && 0 !== s.tag && Hk(), @@ -22375,9 +22405,9 @@ jg(), null ); - })(s, o, i, u); + })(s, o, i, u)); } finally { - (Ls.transition = _), (At = u); + ((Ls.transition = _), (At = u)); } return null; } @@ -22408,7 +22438,7 @@ Pj(8, B, w); } var $ = B.child; - if (null !== $) ($.return = B), (js = $); + if (null !== $) (($.return = B), (js = $)); else for (; null !== js; ) { var V = (B = js).sibling, @@ -22418,7 +22448,7 @@ break; } if (null !== V) { - (V.return = U), (js = V); + ((V.return = U), (js = V)); break; } js = U; @@ -22432,14 +22462,14 @@ z.child = null; do { var Z = Y.sibling; - (Y.sibling = null), (Y = Z); + ((Y.sibling = null), (Y = Z)); } while (null !== Y); } } js = w; } } - if (2064 & w.subtreeFlags && null !== x) (x.return = w), (js = x); + if (2064 & w.subtreeFlags && null !== x) ((x.return = w), (js = x)); else e: for (; null !== js; ) { if (2048 & (w = js).flags) @@ -22451,7 +22481,7 @@ } var ee = w.sibling; if (null !== ee) { - (ee.return = w.return), (js = ee); + ((ee.return = w.return), (js = ee)); break e; } js = w.return; @@ -22460,7 +22490,7 @@ var ie = s.current; for (js = ie; null !== js; ) { var ae = (x = js).child; - if (2064 & x.subtreeFlags && null !== ae) (ae.return = x), (js = ae); + if (2064 & x.subtreeFlags && null !== ae) ((ae.return = x), (js = ae)); else e: for (x = ie; null !== js; ) { if (2048 & (C = js).flags) @@ -22480,7 +22510,7 @@ } var le = C.sibling; if (null !== le) { - (le.return = C.return), (js = le); + ((le.return = C.return), (js = le)); break e; } js = C.return; @@ -22494,15 +22524,15 @@ } return u; } finally { - (At = i), (Ls.transition = o); + ((At = i), (Ls.transition = o)); } } return !1; } function Xk(s, o, i) { - (s = nh(s, (o = Ni(0, (o = Ji(i, o)), 1)), 1)), + ((s = nh(s, (o = Ni(0, (o = Ji(i, o)), 1)), 1)), (o = R()), - null !== s && (Ac(s, 1, o), Dk(s, o)); + null !== s && (Ac(s, 1, o), Dk(s, o))); } function W(s, o, i) { if (3 === s.tag) Xk(s, s, i); @@ -22518,9 +22548,9 @@ 'function' == typeof o.type.getDerivedStateFromError || ('function' == typeof u.componentDidCatch && (null === ro || !ro.has(u))) ) { - (o = nh(o, (s = Qi(o, (s = Ji(i, s)), 1)), 1)), + ((o = nh(o, (s = Qi(o, (s = Ji(i, s)), 1)), 1)), (s = R()), - null !== o && (Ac(o, 1, s), Dk(o, s)); + null !== o && (Ac(o, 1, s), Dk(o, s))); break; } } @@ -22529,7 +22559,7 @@ } function Ti(s, o, i) { var u = s.pingCache; - null !== u && u.delete(o), + (null !== u && u.delete(o), (o = R()), (s.pingedLanes |= s.suspendedLanes & i), Fs === s && @@ -22537,7 +22567,7 @@ (4 === zs || (3 === zs && (130023424 & $s) === $s && 500 > dt() - Xs) ? Kk(s, 0) : (Js |= i)), - Dk(s, o); + Dk(s, o)); } function Yk(s, o) { 0 === o && @@ -22548,7 +22578,7 @@ function uj(s) { var o = s.memoizedState, i = 0; - null !== o && (i = o.retryLane), Yk(s, i); + (null !== o && (i = o.retryLane), Yk(s, i)); } function bk(s, o) { var i = 0; @@ -22564,13 +22594,13 @@ default: throw Error(p(314)); } - null !== u && u.delete(o), Yk(s, i); + (null !== u && u.delete(o), Yk(s, i)); } function Fk(s, o) { return ct(s, o); } function $k(s, o, i, u) { - (this.tag = s), + ((this.tag = s), (this.key = i), (this.sibling = this.child = @@ -22591,7 +22621,7 @@ (this.subtreeFlags = this.flags = 0), (this.deletions = null), (this.childLanes = this.lanes = 0), - (this.alternate = null); + (this.alternate = null)); } function Bg(s, o, i, u) { return new $k(s, o, i, u); @@ -22638,14 +22668,14 @@ case ee: return Tg(i.children, _, w, o); case ie: - (x = 8), (_ |= 8); + ((x = 8), (_ |= 8)); break; case ae: - return ((s = Bg(12, i, o, 2 | _)).elementType = ae), (s.lanes = w), s; + return (((s = Bg(12, i, o, 2 | _)).elementType = ae), (s.lanes = w), s); case de: - return ((s = Bg(13, i, o, _)).elementType = de), (s.lanes = w), s; + return (((s = Bg(13, i, o, _)).elementType = de), (s.lanes = w), s); case fe: - return ((s = Bg(19, i, o, _)).elementType = fe), (s.lanes = w), s; + return (((s = Bg(19, i, o, _)).elementType = fe), (s.lanes = w), s); case _e: return pj(i, _, w, o); default: @@ -22664,15 +22694,15 @@ x = 14; break e; case be: - (x = 16), (u = null); + ((x = 16), (u = null)); break e; } throw Error(p(130, null == s ? s : typeof s, '')); } - return ((o = Bg(x, i, o, _)).elementType = s), (o.type = u), (o.lanes = w), o; + return (((o = Bg(x, i, o, _)).elementType = s), (o.type = u), (o.lanes = w), o); } function Tg(s, o, i, u) { - return ((s = Bg(7, s, u, o)).lanes = i), s; + return (((s = Bg(7, s, u, o)).lanes = i), s); } function pj(s, o, i, u) { return ( @@ -22683,7 +22713,7 @@ ); } function Qg(s, o, i) { - return ((s = Bg(6, s, null, o)).lanes = i), s; + return (((s = Bg(6, s, null, o)).lanes = i), s); } function Sg(s, o, i) { return ( @@ -22697,7 +22727,7 @@ ); } function al(s, o, i, u, _) { - (this.tag = o), + ((this.tag = o), (this.containerInfo = s), (this.finishedWork = this.pingCache = this.current = this.pendingChildren = null), (this.timeoutHandle = -1), @@ -22716,7 +22746,7 @@ (this.entanglements = zc(0)), (this.identifierPrefix = u), (this.onRecoverableError = _), - (this.mutableSourceEagerHydrationData = null); + (this.mutableSourceEagerHydrationData = null)); } function bl(s, o, i, u, _, w, x, C, j) { return ( @@ -22797,7 +22827,7 @@ } } function il(s, o) { - hl(s, o), (s = s.alternate) && hl(s, o); + (hl(s, o), (s = s.alternate) && hl(s, o)); } Ts = function (s, o, i) { if (null !== s) @@ -22809,7 +22839,7 @@ (function yj(s, o, i) { switch (o.tag) { case 3: - kj(o), Ig(); + (kj(o), Ig()); break; case 5: Ah(o); @@ -22823,7 +22853,7 @@ case 10: var u = o.type._context, _ = o.memoizedProps.value; - G(zn, u._currentValue), (u._currentValue = _); + (G(zn, u._currentValue), (u._currentValue = _)); break; case 13: if (null !== (u = o.memoizedState)) @@ -22850,20 +22880,20 @@ return null; case 22: case 23: - return (o.lanes = 0), dj(s, o, i); + return ((o.lanes = 0), dj(s, o, i)); } return Zi(s, o, i); })(s, o, i) ); _s = !!(131072 & s.flags); } - else (_s = !1), Fn && 1048576 & o.flags && ug(o, Pn, o.index); + else ((_s = !1), Fn && 1048576 & o.flags && ug(o, Pn, o.index)); switch (((o.lanes = 0), o.tag)) { case 2: var u = o.type; - ij(s, o), (s = o.pendingProps); + (ij(s, o), (s = o.pendingProps)); var _ = Yf(o, wn.current); - ch(o, i), (_ = Nh(null, o, u, s, _, i)); + (ch(o, i), (_ = Nh(null, o, u, s, _, i))); var w = Sh(); return ( (o.flags |= 1), @@ -22936,10 +22966,10 @@ case 3: e: { if ((kj(o), null === s)) throw Error(p(387)); - (u = o.pendingProps), + ((u = o.pendingProps), (_ = (w = o.memoizedState).element), lh(s, o), - qh(o, u, null, i); + qh(o, u, null, i)); var x = o.memoizedState; if (((u = x.element), w.isDehydrated)) { if ( @@ -22969,9 +22999,8 @@ i = Un(o, null, u, i), o.child = i; i; - ) - (i.flags = (-3 & i.flags) | 4096), (i = i.sibling); + ((i.flags = (-3 & i.flags) | 4096), (i = i.sibling)); } else { if ((Ig(), u === _)) { o = Zi(s, o, i); @@ -22996,7 +23025,7 @@ o.child ); case 6: - return null === s && Eg(o), null; + return (null === s && Eg(o), null); case 13: return oj(s, o, i); case 4: @@ -23013,10 +23042,10 @@ Yi(s, o, u, (_ = o.elementType === u ? _ : Ci(u, _)), i) ); case 7: - return Xi(s, o, o.pendingProps, i), o.child; + return (Xi(s, o, o.pendingProps, i), o.child); case 8: case 12: - return Xi(s, o, o.pendingProps.children, i), o.child; + return (Xi(s, o, o.pendingProps.children, i), o.child); case 10: e: { if ( @@ -23045,14 +23074,14 @@ var L = w.updateQueue; if (null !== L) { var B = (L = L.shared).pending; - null === B ? (j.next = j) : ((j.next = B.next), (B.next = j)), - (L.pending = j); + (null === B ? (j.next = j) : ((j.next = B.next), (B.next = j)), + (L.pending = j)); } } - (w.lanes |= i), + ((w.lanes |= i), null !== (j = w.alternate) && (j.lanes |= i), bh(w.return, i, o), - (C.lanes |= i); + (C.lanes |= i)); break; } j = j.next; @@ -23060,10 +23089,10 @@ } else if (10 === w.tag) x = w.type === o.type ? null : w.child; else if (18 === w.tag) { if (null === (x = w.return)) throw Error(p(341)); - (x.lanes |= i), + ((x.lanes |= i), null !== (C = x.alternate) && (C.lanes |= i), bh(x, i, o), - (x = w.sibling); + (x = w.sibling)); } else x = w.child; if (null !== x) x.return = w; else @@ -23073,14 +23102,14 @@ break; } if (null !== (w = x.sibling)) { - (w.return = x.return), (x = w); + ((w.return = x.return), (x = w)); break; } x = x.return; } w = x; } - Xi(s, o, _.children, i), (o = o.child); + (Xi(s, o, _.children, i), (o = o.child)); } return o; case 9: @@ -23094,7 +23123,10 @@ o.child ); case 14: - return (_ = Ci((u = o.type), o.pendingProps)), $i(s, o, u, (_ = Ci(u.type, _)), i); + return ( + (_ = Ci((u = o.type), o.pendingProps)), + $i(s, o, u, (_ = Ci(u.type, _)), i) + ); case 15: return bj(s, o, o.type, o.pendingProps, i); case 17: @@ -23194,7 +23226,7 @@ })(i, o, s, _, u); return gl(x); } - (ml.prototype.render = ll.prototype.render = + ((ml.prototype.render = ll.prototype.render = function (s) { var o = this._internalRoot; if (null === o) throw Error(p(409)); @@ -23206,10 +23238,10 @@ if (null !== s) { this._internalRoot = null; var o = s.containerInfo; - Rk(function () { + (Rk(function () { fl(null, s, null, null); }), - (o[mn] = null); + (o[mn] = null)); } }), (ml.prototype.unstable_scheduleHydration = function (s) { @@ -23217,7 +23249,7 @@ var o = Mt(); s = { blockedOn: null, target: s, priority: o }; for (var i = 0; i < $t.length && 0 !== o && o < $t[i].priority; i++); - $t.splice(i, 0, s), 0 === i && Vc(s); + ($t.splice(i, 0, s), 0 === i && Vc(s)); } }), (jt = function (s) { @@ -23230,14 +23262,14 @@ } break; case 13: - Rk(function () { + (Rk(function () { var o = ih(s, 1); if (null !== o) { var i = R(); gi(o, s, 1, i); } }), - il(s, 1); + il(s, 1)); } }), (It = function (s) { @@ -23261,7 +23293,7 @@ (Tt = function (s, o) { var i = At; try { - return (At = s), o(); + return ((At = s), o()); } finally { At = i; } @@ -23283,7 +23315,7 @@ if (u !== s && u.form === s.form) { var _ = Db(u); if (!_) throw Error(p(90)); - Wa(u), bb(u, _); + (Wa(u), bb(u, _)); } } } @@ -23296,7 +23328,7 @@ } }), (Gb = Qk), - (Hb = Rk); + (Hb = Rk)); var po = { usingClientEntryPoint: !1, Events: [Cb, ue, Db, Eb, Fb, Qk] }, ho = { findFiberByHostInstance: Wc, @@ -23338,10 +23370,10 @@ var mo = __REACT_DEVTOOLS_GLOBAL_HOOK__; if (!mo.isDisabled && mo.supportsFiber) try { - (Et = mo.inject(fo)), (wt = mo); + ((Et = mo.inject(fo)), (wt = mo)); } catch (qe) {} } - (o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = po), + ((o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = po), (o.createPortal = function (s, o) { var i = 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null; if (!nl(o)) throw Error(p(200)); @@ -23406,10 +23438,10 @@ u) ) for (s = 0; s < u.length; s++) - (_ = (_ = (i = u[s])._getVersion)(i._source)), + ((_ = (_ = (i = u[s])._getVersion)(i._source)), null == o.mutableSourceEagerHydrationData ? (o.mutableSourceEagerHydrationData = [i, _]) - : o.mutableSourceEagerHydrationData.push(i, _); + : o.mutableSourceEagerHydrationData.push(i, _)); return new ml(o); }), (o.render = function (s, o, i) { @@ -23422,7 +23454,7 @@ !!s._reactRootContainer && (Rk(function () { rl(null, null, s, !1, function () { - (s._reactRootContainer = null), (s[mn] = null); + ((s._reactRootContainer = null), (s[mn] = null)); }); }), !0) @@ -23434,11 +23466,11 @@ if (null == s || void 0 === s._reactInternals) throw Error(p(38)); return rl(s, o, i, !1, u); }), - (o.version = '18.3.1-next-f1338f8080-20240426'); + (o.version = '18.3.1-next-f1338f8080-20240426')); }, 40961: (s, o, i) => { 'use strict'; - !(function checkDCE() { + (!(function checkDCE() { if ( 'undefined' != typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && 'function' == typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE @@ -23449,7 +23481,7 @@ console.error(s); } })(), - (s.exports = i(22551)); + (s.exports = i(22551))); }, 2209: (s, o, i) => { 'use strict'; @@ -23487,7 +23519,7 @@ ); } var o = checkType.bind(null, !1); - return (o.isRequired = checkType.bind(null, !0)), o; + return ((o.isRequired = checkType.bind(null, !0)), o); } function createIterableSubclassTypeChecker(s, o) { return (function createImmutableTypeChecker(s, o) { @@ -23515,7 +23547,7 @@ return _.Iterable.isIterable(s) && o(s); }); } - ((u = { + (((u = { listOf: x, mapOf: x, orderedMapOf: x, @@ -23539,7 +23571,7 @@ iterable: w }).iterable.indexed = createIterableSubclassTypeChecker('Indexed', _.Iterable.isIndexed)), (u.iterable.keyed = createIterableSubclassTypeChecker('Keyed', _.Iterable.isKeyed)), - (s.exports = u); + (s.exports = u)); }, 15287: (s, o) => { 'use strict'; @@ -23566,13 +23598,13 @@ Y = Object.assign, Z = {}; function E(s, o, i) { - (this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z); + ((this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z)); } function F() {} function G(s, o, i) { - (this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z); + ((this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z)); } - (E.prototype.isReactComponent = {}), + ((E.prototype.isReactComponent = {}), (E.prototype.setState = function (s, o) { if ('object' != typeof s && 'function' != typeof s && null != s) throw Error( @@ -23583,9 +23615,9 @@ (E.prototype.forceUpdate = function (s) { this.updater.enqueueForceUpdate(this, s, 'forceUpdate'); }), - (F.prototype = E.prototype); + (F.prototype = E.prototype)); var ee = (G.prototype = new F()); - (ee.constructor = G), Y(ee, E.prototype), (ee.isPureReactComponent = !0); + ((ee.constructor = G), Y(ee, E.prototype), (ee.isPureReactComponent = !0)); var ie = Array.isArray, ae = Object.prototype.hasOwnProperty, le = { current: null }, @@ -23694,14 +23726,14 @@ j += R((C = C.value), o, _, (B = w + Q(C, L++)), x); else if ('object' === C) throw ( - ((o = String(s)), + (o = String(s)), Error( 'Objects are not valid as a React child (found: ' + ('[object Object]' === o ? 'object with keys {' + Object.keys(s).join(', ') + '}' : o) + '). If you meant to render a collection of children, use an array instead.' - )) + ) ); return j; } @@ -23719,7 +23751,7 @@ function T(s) { if (-1 === s._status) { var o = s._result; - (o = o()).then( + ((o = o()).then( function (o) { (0 !== s._status && -1 !== s._status) || ((s._status = 1), (s._result = o)); }, @@ -23727,7 +23759,7 @@ (0 !== s._status && -1 !== s._status) || ((s._status = 2), (s._result = o)); } ), - -1 === s._status && ((s._status = 0), (s._result = o)); + -1 === s._status && ((s._status = 0), (s._result = o))); } if (1 === s._status) return s._result.default; throw s._result; @@ -23738,7 +23770,7 @@ function X() { throw Error('act(...) is not supported in production builds of React.'); } - (o.Children = { + ((o.Children = { map: S, forEach: function (s, o, i) { S( @@ -23831,7 +23863,7 @@ (o.createElement = M), (o.createFactory = function (s) { var o = M.bind(null, s); - return (o.type = s), o; + return ((o.type = s), o); }), (o.createRef = function () { return { current: null }; @@ -23899,7 +23931,7 @@ (o.useTransition = function () { return de.current.useTransition(); }), - (o.version = '18.3.1'); + (o.version = '18.3.1')); }, 96540: (s, o, i) => { 'use strict'; @@ -23923,14 +23955,14 @@ } return ( (function _inheritsLoose(s, o) { - (s.prototype = Object.create(o.prototype)), + ((s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s), - (s.__proto__ = o); + (s.__proto__ = o)); })(NodeError, s), NodeError ); })(u); - (_.prototype.name = u.name), (_.prototype.code = s), (o[s] = _); + ((_.prototype.name = u.name), (_.prototype.code = s), (o[s] = _)); } function oneOf(s, o) { if (Array.isArray(s)) { @@ -23949,7 +23981,7 @@ } return 'of '.concat(o, ' ').concat(String(s)); } - createErrorType( + (createErrorType( 'ERR_INVALID_OPT_VALUE', function (s, o) { return 'The value "' + o + '" is invalid for option "' + s + '"'; @@ -24021,7 +24053,7 @@ 'ERR_STREAM_UNSHIFT_AFTER_END_EVENT', 'stream.unshift() after end event' ), - (s.exports.F = o); + (s.exports.F = o)); }, 25382: (s, o, i) => { 'use strict'; @@ -24043,13 +24075,13 @@ } function Duplex(s) { if (!(this instanceof Duplex)) return new Duplex(s); - w.call(this, s), + (w.call(this, s), x.call(this, s), (this.allowHalfOpen = !0), s && (!1 === s.readable && (this.readable = !1), !1 === s.writable && (this.writable = !1), - !1 === s.allowHalfOpen && ((this.allowHalfOpen = !1), this.once('end', onend))); + !1 === s.allowHalfOpen && ((this.allowHalfOpen = !1), this.once('end', onend)))); } function onend() { this._writableState.ended || u.nextTick(onEndNT, this); @@ -24057,7 +24089,7 @@ function onEndNT(s) { s.end(); } - Object.defineProperty(Duplex.prototype, 'writableHighWaterMark', { + (Object.defineProperty(Duplex.prototype, 'writableHighWaterMark', { enumerable: !1, get: function get() { return this._writableState.highWaterMark; @@ -24090,7 +24122,7 @@ void 0 !== this._writableState && ((this._readableState.destroyed = s), (this._writableState.destroyed = s)); } - }); + })); }, 63600: (s, o, i) => { 'use strict'; @@ -24100,16 +24132,16 @@ if (!(this instanceof PassThrough)) return new PassThrough(s); u.call(this, s); } - i(56698)(PassThrough, u), + (i(56698)(PassThrough, u), (PassThrough.prototype._transform = function (s, o, i) { i(null, s); - }); + })); }, 45412: (s, o, i) => { 'use strict'; var u, _ = i(65606); - (s.exports = Readable), (Readable.ReadableState = ReadableState); + ((s.exports = Readable), (Readable.ReadableState = ReadableState)); i(37007).EventEmitter; var w = function EElistenerCount(s, o) { return s.listeners(o).length; @@ -24143,7 +24175,7 @@ var pe = Y.errorOrDestroy, de = ['error', 'close', 'destroy', 'pause', 'resume']; function ReadableState(s, o, _) { - (u = u || i(25382)), + ((u = u || i(25382)), (s = s || {}), 'boolean' != typeof _ && (_ = o instanceof u), (this.objectMode = !!s.objectMode), @@ -24174,36 +24206,36 @@ s.encoding && ($ || ($ = i(83141).I), (this.decoder = new $(s.encoding)), - (this.encoding = s.encoding)); + (this.encoding = s.encoding))); } function Readable(s) { if (((u = u || i(25382)), !(this instanceof Readable))) return new Readable(s); var o = this instanceof u; - (this._readableState = new ReadableState(s, this, o)), + ((this._readableState = new ReadableState(s, this, o)), (this.readable = !0), s && ('function' == typeof s.read && (this._read = s.read), 'function' == typeof s.destroy && (this._destroy = s.destroy)), - x.call(this); + x.call(this)); } function readableAddChunk(s, o, i, u, _) { L('readableAddChunk', o); var w, x = s._readableState; if (null === o) - (x.reading = !1), + ((x.reading = !1), (function onEofChunk(s, o) { if ((L('onEofChunk'), o.ended)) return; if (o.decoder) { var i = o.decoder.end(); i && i.length && (o.buffer.push(i), (o.length += o.objectMode ? 1 : i.length)); } - (o.ended = !0), + ((o.ended = !0), o.sync ? emitReadable(s) : ((o.needReadable = !1), - o.emittedReadable || ((o.emittedReadable = !0), emitReadable_(s))); - })(s, x); + o.emittedReadable || ((o.emittedReadable = !0), emitReadable_(s)))); + })(s, x)); else if ( (_ || (w = (function chunkInvalid(s, o) { @@ -24234,24 +24266,24 @@ else if (x.ended) pe(s, new ae()); else { if (x.destroyed) return !1; - (x.reading = !1), + ((x.reading = !1), x.decoder && !i ? ((o = x.decoder.write(o)), x.objectMode || 0 !== o.length ? addChunk(s, x, o, !1) : maybeReadMore(s, x)) - : addChunk(s, x, o, !1); + : addChunk(s, x, o, !1)); } else u || ((x.reading = !1), maybeReadMore(s, x)); return !x.ended && (x.length < x.highWaterMark || 0 === x.length); } function addChunk(s, o, i, u) { - o.flowing && 0 === o.length && !o.sync + (o.flowing && 0 === o.length && !o.sync ? ((o.awaitDrain = 0), s.emit('data', i)) : ((o.length += o.objectMode ? 1 : i.length), u ? o.buffer.unshift(i) : o.buffer.push(i), o.needReadable && emitReadable(s)), - maybeReadMore(s, o); + maybeReadMore(s, o)); } - Object.defineProperty(Readable.prototype, 'destroyed', { + (Object.defineProperty(Readable.prototype, 'destroyed', { enumerable: !1, get: function get() { return void 0 !== this._readableState && this._readableState.destroyed; @@ -24286,17 +24318,17 @@ (Readable.prototype.setEncoding = function (s) { $ || ($ = i(83141).I); var o = new $(s); - (this._readableState.decoder = o), - (this._readableState.encoding = this._readableState.decoder.encoding); + ((this._readableState.decoder = o), + (this._readableState.encoding = this._readableState.decoder.encoding)); for (var u = this._readableState.buffer.head, _ = ''; null !== u; ) - (_ += o.write(u.data)), (u = u.next); + ((_ += o.write(u.data)), (u = u.next)); return ( this._readableState.buffer.clear(), '' !== _ && this._readableState.buffer.push(_), (this._readableState.length = _.length), this ); - }); + })); var fe = 1073741824; function howMuchToRead(s, o) { return s <= 0 || (0 === o.length && o.ended) @@ -24326,21 +24358,21 @@ } function emitReadable(s) { var o = s._readableState; - L('emitReadable', o.needReadable, o.emittedReadable), + (L('emitReadable', o.needReadable, o.emittedReadable), (o.needReadable = !1), o.emittedReadable || (L('emitReadable', o.flowing), (o.emittedReadable = !0), - _.nextTick(emitReadable_, s)); + _.nextTick(emitReadable_, s))); } function emitReadable_(s) { var o = s._readableState; - L('emitReadable_', o.destroyed, o.length, o.ended), + (L('emitReadable_', o.destroyed, o.length, o.ended), o.destroyed || (!o.length && !o.ended) || (s.emit('readable'), (o.emittedReadable = !1)), (o.needReadable = !o.flowing && !o.ended && o.length <= o.highWaterMark), - flow(s); + flow(s)); } function maybeReadMore(s, o) { o.readingMore || ((o.readingMore = !0), _.nextTick(maybeReadMore_, s, o)); @@ -24351,7 +24383,6 @@ !o.reading && !o.ended && (o.length < o.highWaterMark || (o.flowing && 0 === o.length)); - ) { var i = o.length; if ((L('maybeReadMore read 0'), s.read(0), i === o.length)) break; @@ -24360,21 +24391,21 @@ } function updateReadableListening(s) { var o = s._readableState; - (o.readableListening = s.listenerCount('readable') > 0), + ((o.readableListening = s.listenerCount('readable') > 0), o.resumeScheduled && !o.paused ? (o.flowing = !0) - : s.listenerCount('data') > 0 && s.resume(); + : s.listenerCount('data') > 0 && s.resume()); } function nReadingNextTick(s) { - L('readable nexttick read 0'), s.read(0); + (L('readable nexttick read 0'), s.read(0)); } function resume_(s, o) { - L('resume', o.reading), + (L('resume', o.reading), o.reading || s.read(0), (o.resumeScheduled = !1), s.emit('resume'), flow(s), - o.flowing && !o.reading && s.read(0); + o.flowing && !o.reading && s.read(0)); } function flow(s) { var o = s._readableState; @@ -24398,8 +24429,8 @@ } function endReadable(s) { var o = s._readableState; - L('endReadable', o.endEmitted), - o.endEmitted || ((o.ended = !0), _.nextTick(endReadableNT, o, s)); + (L('endReadable', o.endEmitted), + o.endEmitted || ((o.ended = !0), _.nextTick(endReadableNT, o, s))); } function endReadableNT(s, o) { if ( @@ -24416,8 +24447,8 @@ for (var i = 0, u = s.length; i < u; i++) if (s[i] === o) return i; return -1; } - (Readable.prototype.read = function (s) { - L('read', s), (s = parseInt(s, 10)); + ((Readable.prototype.read = function (s) { + (L('read', s), (s = parseInt(s, 10))); var o = this._readableState, i = s; if ( @@ -24432,7 +24463,7 @@ null ); if (0 === (s = howMuchToRead(s, o)) && o.ended) - return 0 === o.length && endReadable(this), null; + return (0 === o.length && endReadable(this), null); var u, _ = o.needReadable; return ( @@ -24474,16 +24505,16 @@ default: u.pipes.push(s); } - (u.pipesCount += 1), L('pipe count=%d opts=%j', u.pipesCount, o); + ((u.pipesCount += 1), L('pipe count=%d opts=%j', u.pipesCount, o)); var x = (!o || !1 !== o.end) && s !== _.stdout && s !== _.stderr ? onend : unpipe; function onunpipe(o, _) { - L('onunpipe'), + (L('onunpipe'), o === i && _ && !1 === _.hasUnpiped && ((_.hasUnpiped = !0), (function cleanup() { - L('cleanup'), + (L('cleanup'), s.removeListener('close', onclose), s.removeListener('finish', onfinish), s.removeListener('drain', C), @@ -24493,19 +24524,19 @@ i.removeListener('end', unpipe), i.removeListener('data', ondata), (j = !0), - !u.awaitDrain || (s._writableState && !s._writableState.needDrain) || C(); - })()); + !u.awaitDrain || (s._writableState && !s._writableState.needDrain) || C()); + })())); } function onend() { - L('onend'), s.end(); + (L('onend'), s.end()); } - u.endEmitted ? _.nextTick(x) : i.once('end', x), s.on('unpipe', onunpipe); + (u.endEmitted ? _.nextTick(x) : i.once('end', x), s.on('unpipe', onunpipe)); var C = (function pipeOnDrain(s) { return function pipeOnDrainFunctionResult() { var o = s._readableState; - L('pipeOnDrain', o.awaitDrain), + (L('pipeOnDrain', o.awaitDrain), o.awaitDrain && o.awaitDrain--, - 0 === o.awaitDrain && w(s, 'data') && ((o.flowing = !0), flow(s)); + 0 === o.awaitDrain && w(s, 'data') && ((o.flowing = !0), flow(s))); }; })(i); s.on('drain', C); @@ -24513,28 +24544,28 @@ function ondata(o) { L('ondata'); var _ = s.write(o); - L('dest.write', _), + (L('dest.write', _), !1 === _ && (((1 === u.pipesCount && u.pipes === s) || (u.pipesCount > 1 && -1 !== indexOf(u.pipes, s))) && !j && (L('false write response, pause', u.awaitDrain), u.awaitDrain++), - i.pause()); + i.pause())); } function onerror(o) { - L('onerror', o), + (L('onerror', o), unpipe(), s.removeListener('error', onerror), - 0 === w(s, 'error') && pe(s, o); + 0 === w(s, 'error') && pe(s, o)); } function onclose() { - s.removeListener('finish', onfinish), unpipe(); + (s.removeListener('finish', onfinish), unpipe()); } function onfinish() { - L('onfinish'), s.removeListener('close', onclose), unpipe(); + (L('onfinish'), s.removeListener('close', onclose), unpipe()); } function unpipe() { - L('unpipe'), i.unpipe(s); + (L('unpipe'), i.unpipe(s)); } return ( i.on('data', ondata), @@ -24570,7 +24601,7 @@ if (!s) { var u = o.pipes, _ = o.pipesCount; - (o.pipes = null), (o.pipesCount = 0), (o.flowing = !1); + ((o.pipes = null), (o.pipesCount = 0), (o.flowing = !1)); for (var w = 0; w < _; w++) u[w].emit('unpipe', this, { hasUnpiped: !1 }); return this; } @@ -24607,12 +24638,13 @@ (Readable.prototype.addListener = Readable.prototype.on), (Readable.prototype.removeListener = function (s, o) { var i = x.prototype.removeListener.call(this, s, o); - return 'readable' === s && _.nextTick(updateReadableListening, this), i; + return ('readable' === s && _.nextTick(updateReadableListening, this), i); }), (Readable.prototype.removeAllListeners = function (s) { var o = x.prototype.removeAllListeners.apply(this, arguments); return ( - ('readable' !== s && void 0 !== s) || _.nextTick(updateReadableListening, this), o + ('readable' !== s && void 0 !== s) || _.nextTick(updateReadableListening, this), + o ); }), (Readable.prototype.resume = function () { @@ -24665,14 +24697,14 @@ for (var w = 0; w < de.length; w++) s.on(de[w], this.emit.bind(this, de[w])); return ( (this._read = function (o) { - L('wrapped _read', o), u && ((u = !1), s.resume()); + (L('wrapped _read', o), u && ((u = !1), s.resume())); }), this ); }), 'function' == typeof Symbol && (Readable.prototype[Symbol.asyncIterator] = function () { - return void 0 === V && (V = i(2955)), V(this); + return (void 0 === V && (V = i(2955)), V(this)); }), Object.defineProperty(Readable.prototype, 'readableHighWaterMark', { enumerable: !1, @@ -24704,8 +24736,8 @@ }), 'function' == typeof Symbol && (Readable.from = function (s, o) { - return void 0 === U && (U = i(55157)), U(Readable, s, o); - }); + return (void 0 === U && (U = i(55157)), U(Readable, s, o)); + })); }, 74610: (s, o, i) => { 'use strict'; @@ -24721,14 +24753,14 @@ i.transforming = !1; var u = i.writecb; if (null === u) return this.emit('error', new w()); - (i.writechunk = null), (i.writecb = null), null != o && this.push(o), u(s); + ((i.writechunk = null), (i.writecb = null), null != o && this.push(o), u(s)); var _ = this._readableState; - (_.reading = !1), - (_.needReadable || _.length < _.highWaterMark) && this._read(_.highWaterMark); + ((_.reading = !1), + (_.needReadable || _.length < _.highWaterMark) && this._read(_.highWaterMark)); } function Transform(s) { if (!(this instanceof Transform)) return new Transform(s); - j.call(this, s), + (j.call(this, s), (this._transformState = { afterTransform: afterTransform.bind(this), needTransform: !1, @@ -24742,7 +24774,7 @@ s && ('function' == typeof s.transform && (this._transform = s.transform), 'function' == typeof s.flush && (this._flush = s.flush)), - this.on('prefinish', prefinish); + this.on('prefinish', prefinish)); } function prefinish() { var s = this; @@ -24758,9 +24790,9 @@ if (s._transformState.transforming) throw new x(); return s.push(null); } - i(56698)(Transform, j), + (i(56698)(Transform, j), (Transform.prototype.push = function (s, o) { - return (this._transformState.needTransform = !1), j.prototype.push.call(this, s, o); + return ((this._transformState.needTransform = !1), j.prototype.push.call(this, s, o)); }), (Transform.prototype._transform = function (s, o, i) { i(new _('_transform()')); @@ -24784,7 +24816,7 @@ j.prototype._destroy.call(this, s, function (s) { o(s); }); - }); + })); }, 16708: (s, o, i) => { 'use strict'; @@ -24792,7 +24824,7 @@ _ = i(65606); function CorkedRequest(s) { var o = this; - (this.next = null), + ((this.next = null), (this.entry = null), (this.finish = function () { !(function onCorkedFinish(s, o, i) { @@ -24800,13 +24832,13 @@ s.entry = null; for (; u; ) { var _ = u.callback; - o.pendingcb--, _(i), (u = u.next); + (o.pendingcb--, _(i), (u = u.next)); } o.corkedRequestsFree.next = s; })(o, s); - }); + })); } - (s.exports = Writable), (Writable.WritableState = WritableState); + ((s.exports = Writable), (Writable.WritableState = WritableState)); var w = { deprecate: i(94643) }, x = i(40345), C = i(48287).Buffer, @@ -24834,7 +24866,7 @@ ce = B.errorOrDestroy; function nop() {} function WritableState(s, o, w) { - (u = u || i(25382)), + ((u = u || i(25382)), (s = s || {}), 'boolean' != typeof w && (w = o instanceof u), (this.objectMode = !!s.objectMode), @@ -24845,9 +24877,9 @@ (this.ending = !1), (this.ended = !1), (this.finished = !1), - (this.destroyed = !1); + (this.destroyed = !1)); var x = !1 === s.decodeStrings; - (this.decodeStrings = !x), + ((this.decodeStrings = !x), (this.defaultEncoding = s.defaultEncoding || 'utf8'), (this.length = 0), (this.writing = !1), @@ -24862,15 +24894,15 @@ if ('function' != typeof w) throw new Y(); if ( ((function onwriteStateUpdate(s) { - (s.writing = !1), + ((s.writing = !1), (s.writecb = null), (s.length -= s.writelen), - (s.writelen = 0); + (s.writelen = 0)); })(i), o) ) !(function onwriteError(s, o, i, u, w) { - --o.pendingcb, + (--o.pendingcb, i ? (_.nextTick(w, u), _.nextTick(finishMaybe, s, o), @@ -24879,12 +24911,12 @@ : (w(u), (s._writableState.errorEmitted = !0), ce(s, u), - finishMaybe(s, o)); + finishMaybe(s, o))); })(s, i, u, o, w); else { var x = needFinish(i) || s.destroyed; - x || i.corked || i.bufferProcessing || !i.bufferedRequest || clearBuffer(s, i), - u ? _.nextTick(afterWrite, s, i, x, w) : afterWrite(s, i, x, w); + (x || i.corked || i.bufferProcessing || !i.bufferedRequest || clearBuffer(s, i), + u ? _.nextTick(afterWrite, s, i, x, w) : afterWrite(s, i, x, w)); } })(o, s); }), @@ -24898,22 +24930,22 @@ (this.emitClose = !1 !== s.emitClose), (this.autoDestroy = !!s.autoDestroy), (this.bufferedRequestCount = 0), - (this.corkedRequestsFree = new CorkedRequest(this)); + (this.corkedRequestsFree = new CorkedRequest(this))); } function Writable(s) { var o = this instanceof (u = u || i(25382)); if (!o && !L.call(Writable, this)) return new Writable(s); - (this._writableState = new WritableState(s, this, o)), + ((this._writableState = new WritableState(s, this, o)), (this.writable = !0), s && ('function' == typeof s.write && (this._write = s.write), 'function' == typeof s.writev && (this._writev = s.writev), 'function' == typeof s.destroy && (this._destroy = s.destroy), 'function' == typeof s.final && (this._final = s.final)), - x.call(this); + x.call(this)); } function doWrite(s, o, i, u, _, w, x) { - (o.writelen = u), + ((o.writelen = u), (o.writecb = x), (o.writing = !0), (o.sync = !0), @@ -24922,16 +24954,16 @@ : i ? s._writev(_, o.onwrite) : s._write(_, w, o.onwrite), - (o.sync = !1); + (o.sync = !1)); } function afterWrite(s, o, i, u) { - i || + (i || (function onwriteDrain(s, o) { 0 === o.length && o.needDrain && ((o.needDrain = !1), s.emit('drain')); })(s, o), o.pendingcb--, u(), - finishMaybe(s, o); + finishMaybe(s, o)); } function clearBuffer(s, o) { o.bufferProcessing = !0; @@ -24941,15 +24973,16 @@ _ = new Array(u), w = o.corkedRequestsFree; w.entry = i; - for (var x = 0, C = !0; i; ) (_[x] = i), i.isBuf || (C = !1), (i = i.next), (x += 1); - (_.allBuffers = C), + for (var x = 0, C = !0; i; ) + ((_[x] = i), i.isBuf || (C = !1), (i = i.next), (x += 1)); + ((_.allBuffers = C), doWrite(s, o, !0, o.length, _, '', w.finish), o.pendingcb++, (o.lastBufferedRequest = null), w.next ? ((o.corkedRequestsFree = w.next), (w.next = null)) : (o.corkedRequestsFree = new CorkedRequest(o)), - (o.bufferedRequestCount = 0); + (o.bufferedRequestCount = 0)); } else { for (; i; ) { var j = i.chunk, @@ -24965,7 +24998,7 @@ } null === i && (o.lastBufferedRequest = null); } - (o.bufferedRequest = i), (o.bufferProcessing = !1); + ((o.bufferedRequest = i), (o.bufferProcessing = !1)); } function needFinish(s) { return ( @@ -24974,11 +25007,11 @@ } function callFinal(s, o) { s._final(function (i) { - o.pendingcb--, + (o.pendingcb--, i && ce(s, i), (o.prefinished = !0), s.emit('prefinish'), - finishMaybe(s, o); + finishMaybe(s, o)); }); } function finishMaybe(s, o) { @@ -24999,9 +25032,9 @@ } return i; } - i(56698)(Writable, x), + (i(56698)(Writable, x), (WritableState.prototype.getBuffer = function getBuffer() { - for (var s = this.bufferedRequest, o = []; s; ) o.push(s), (s = s.next); + for (var s = this.bufferedRequest, o = []; s; ) (o.push(s), (s = s.next)); return o; }), (function () { @@ -25055,7 +25088,7 @@ u.ending ? (function writeAfterEnd(s, o) { var i = new ae(); - ce(s, i), _.nextTick(o, i); + (ce(s, i), _.nextTick(o, i)); })(this, i) : (x || (function validChunk(s, o, i, u) { @@ -25087,7 +25120,7 @@ L || (o.needDrain = !0); if (o.writing || o.corked) { var B = o.lastBufferedRequest; - (o.lastBufferedRequest = { + ((o.lastBufferedRequest = { chunk: u, encoding: _, isBuf: i, @@ -25097,7 +25130,7 @@ B ? (B.next = o.lastBufferedRequest) : (o.bufferedRequest = o.lastBufferedRequest), - (o.bufferedRequestCount += 1); + (o.bufferedRequestCount += 1)); } else doWrite(s, o, !1, j, u, _, w); return L; })(this, u, x, s, o, i))), @@ -25137,7 +25170,7 @@ )) ) throw new le(s); - return (this._writableState.defaultEncoding = s), this; + return ((this._writableState.defaultEncoding = s), this); }), Object.defineProperty(Writable.prototype, 'writableBuffer', { enumerable: !1, @@ -25165,10 +25198,10 @@ u.corked && ((u.corked = 1), this.uncork()), u.ending || (function endWritable(s, o, i) { - (o.ending = !0), + ((o.ending = !0), finishMaybe(s, o), - i && (o.finished ? _.nextTick(i) : s.once('finish', i)); - (o.ended = !0), (s.writable = !1); + i && (o.finished ? _.nextTick(i) : s.once('finish', i))); + ((o.ended = !0), (s.writable = !1)); })(this, u, i), this ); @@ -25192,7 +25225,7 @@ (Writable.prototype._undestroy = B.undestroy), (Writable.prototype._destroy = function (s, o) { o(s); - }); + })); }, 2955: (s, o, i) => { 'use strict'; @@ -25280,7 +25313,7 @@ if (null !== w) return Promise.resolve(createIterResult(w, !1)); i = new Promise(this[$]); } - return (this[B] = i), i; + return ((this[B] = i), i); } }), Symbol.asyncIterator, @@ -25330,9 +25363,9 @@ ); } var u = i[x]; - null !== u && + (null !== u && ((i[B] = null), (i[x] = null), (i[C] = null), u(createIterResult(void 0, !0))), - (i[L] = !0); + (i[L] = !0)); }), s.on('readable', onReadable.bind(null, i)), i @@ -25345,11 +25378,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -25384,10 +25417,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, _toPropertyKey(u.key), u); + Object.defineProperty(s, _toPropertyKey(u.key), u)); } } function _toPropertyKey(s) { @@ -25408,12 +25441,12 @@ w = (_ && _.custom) || 'inspect'; s.exports = (function () { function BufferList() { - !(function _classCallCheck(s, o) { + (!(function _classCallCheck(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, BufferList), (this.head = null), (this.tail = null), - (this.length = 0); + (this.length = 0)); } return ( (function _createClass(s, o, i) { @@ -25428,16 +25461,16 @@ key: 'push', value: function push(s) { var o = { data: s, next: null }; - this.length > 0 ? (this.tail.next = o) : (this.head = o), + (this.length > 0 ? (this.tail.next = o) : (this.head = o), (this.tail = o), - ++this.length; + ++this.length); } }, { key: 'unshift', value: function unshift(s) { var o = { data: s, next: this.head }; - 0 === this.length && (this.tail = o), (this.head = o), ++this.length; + (0 === this.length && (this.tail = o), (this.head = o), ++this.length); } }, { @@ -25458,7 +25491,7 @@ { key: 'clear', value: function clear() { - (this.head = this.tail = null), (this.length = 0); + ((this.head = this.tail = null), (this.length = 0)); } }, { @@ -25474,12 +25507,12 @@ value: function concat(s) { if (0 === this.length) return u.alloc(0); for (var o, i, _, w = u.allocUnsafe(s >>> 0), x = this.head, C = 0; x; ) - (o = x.data), + ((o = x.data), (i = w), (_ = C), u.prototype.copy.call(o, i, _), (C += x.data.length), - (x = x.next); + (x = x.next)); return w; } }, @@ -25524,7 +25557,7 @@ } ++i; } - return (this.length -= i), u; + return ((this.length -= i), u); } }, { @@ -25544,7 +25577,7 @@ } ++_; } - return (this.length -= _), o; + return ((this.length -= _), o); } }, { @@ -25565,7 +25598,7 @@ 'use strict'; var u = i(65606); function emitErrorAndCloseNT(s, o) { - emitErrorNT(s, o), emitCloseNT(s); + (emitErrorNT(s, o), emitCloseNT(s)); } function emitCloseNT(s) { (s._writableState && !s._writableState.emitClose) || @@ -25607,7 +25640,7 @@ this); }, undestroy: function undestroy() { - this._readableState && + (this._readableState && ((this._readableState.destroyed = !1), (this._readableState.reading = !1), (this._readableState.ended = !1), @@ -25619,7 +25652,7 @@ (this._writableState.finalCalled = !1), (this._writableState.prefinished = !1), (this._writableState.finished = !1), - (this._writableState.errorEmitted = !1)); + (this._writableState.errorEmitted = !1))); }, errorOrDestroy: function errorOrDestroy(s, o) { var i = s._readableState, @@ -25634,7 +25667,7 @@ function noop() {} s.exports = function eos(s, o, i) { if ('function' == typeof o) return eos(s, null, o); - o || (o = {}), + (o || (o = {}), (i = (function once(s) { var o = !1; return function () { @@ -25645,7 +25678,7 @@ s.apply(this, u); } }; - })(i || noop)); + })(i || noop))); var _ = o.readable || (!1 !== o.readable && s.readable), w = o.writable || (!1 !== o.writable && s.writable), x = function onlegacyfinish() { @@ -25653,11 +25686,11 @@ }, C = s._writableState && s._writableState.finished, j = function onfinish() { - (w = !1), (C = !0), _ || i.call(s); + ((w = !1), (C = !0), _ || i.call(s)); }, L = s._readableState && s._readableState.endEmitted, B = function onend() { - (_ = !1), (L = !0), w || i.call(s); + ((_ = !1), (L = !0), w || i.call(s)); }, $ = function onerror(o) { i.call(s, o); @@ -25684,7 +25717,7 @@ !1 !== o.error && s.on('error', $), s.on('close', V), function () { - s.removeListener('complete', j), + (s.removeListener('complete', j), s.removeListener('abort', V), s.removeListener('request', U), s.req && s.req.removeListener('finish', j), @@ -25693,7 +25726,7 @@ s.removeListener('finish', j), s.removeListener('end', B), s.removeListener('error', $), - s.removeListener('close', V); + s.removeListener('close', V)); } ); }; @@ -25735,14 +25768,14 @@ }; })(w); var C = !1; - s.on('close', function () { + (s.on('close', function () { C = !0; }), void 0 === u && (u = i(86238)), u(s, { readable: o, writable: _ }, function (s) { if (s) return w(s); - (C = !0), w(); - }); + ((C = !0), w()); + })); var j = !1; return function (o) { if (!C && !j) @@ -25758,7 +25791,7 @@ ); }; })(s, w, _ > 0, function (s) { - C || (C = s), s && L.forEach(call), w || (L.forEach(call), j(C)); + (C || (C = s), s && L.forEach(call), w || (L.forEach(call), j(C))); }); }); return o.reduce(pipe); @@ -25791,7 +25824,7 @@ return s && s.__esModule ? s : { default: s }; })(i(9404)), _ = i(55674); - (o.default = function (s) { + ((o.default = function (s) { var o = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : u.default.Map, i = Object.keys(s); return function () { @@ -25800,12 +25833,12 @@ return u.withMutations(function (o) { i.forEach(function (i) { var u = (0, s[i])(o.get(i), w); - (0, _.validateNextState)(u, i, w), o.set(i, u); + ((0, _.validateNextState)(u, i, w), o.set(i, u)); }); }); }; }), - (s.exports = o.default); + (s.exports = o.default)); }, 89593: (s, o, i) => { 'use strict'; @@ -25817,13 +25850,13 @@ }, 48590: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.default = function (s) { return s && '@@redux/INIT' === s.type ? 'initialState argument passed to createStore' : 'previous state received by the reducer'; }), - (s.exports = o.default); + (s.exports = o.default)); }, 82261: (s, o, i) => { 'use strict'; @@ -25833,7 +25866,7 @@ function _interopRequireDefault(s) { return s && s.__esModule ? s : { default: s }; } - (o.default = function (s, o, i) { + ((o.default = function (s, o, i) { var w = Object.keys(o); if (!w.length) return 'Store does not have a valid reducer. Make sure the argument passed to combineReducers is an object whose values are reducers.'; @@ -25867,28 +25900,28 @@ '". Unexpected properties will be ignored.' : null; }), - (s.exports = o.default); + (s.exports = o.default)); }, 55674: (s, o, i) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.validateNextState = o.getUnexpectedInvocationParameterMessage = o.getStateName = - void 0); + void 0)); var u = _interopRequireDefault(i(48590)), _ = _interopRequireDefault(i(82261)), w = _interopRequireDefault(i(27374)); function _interopRequireDefault(s) { return s && s.__esModule ? s : { default: s }; } - (o.getStateName = u.default), + ((o.getStateName = u.default), (o.getUnexpectedInvocationParameterMessage = _.default), - (o.validateNextState = w.default); + (o.validateNextState = w.default)); }, 27374: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.default = function (s, o, i) { if (void 0 === s) throw new Error( @@ -25899,7 +25932,7 @@ '" action. To ignore an action, you must explicitly return the previous state.' ); }), - (s.exports = o.default); + (s.exports = o.default)); }, 75208: (s) => { 'use strict'; @@ -25910,9 +25943,9 @@ if (1 === u) return s; if (2 === u) return s + s; var _ = s.length * u; - if (o !== s || void 0 === o) (o = s), (i = ''); + if (o !== s || void 0 === o) ((o = s), (i = '')); else if (i.length >= _) return i.substr(0, _); - for (; _ > i.length && u > 1; ) 1 & u && (i += s), (u >>= 1), (s += s); + for (; _ > i.length && u > 1; ) (1 & u && (i += s), (u >>= 1), (s += s)); return (i = (i += s).substr(0, _)); }; }, @@ -25942,7 +25975,7 @@ _ = i(6205), w = i(10023), x = i(8048); - (s.exports = (s) => { + ((s.exports = (s) => { var o, i, C = 0, @@ -25998,14 +26031,14 @@ var U; '^' === V[C] ? ((U = !0), C++) : (U = !1); var z = u.tokenizeClass(V.slice(C), s); - (C += z[1]), B.push({ type: _.SET, set: z[0], not: U }); + ((C += z[1]), B.push({ type: _.SET, set: z[0], not: U })); break; case '.': B.push(w.anyChar()); break; case '(': var Y = { type: _.GROUP, stack: [], remember: !0 }; - '?' === (i = V[C]) && + ('?' === (i = V[C]) && ((i = V[C + 1]), (C += 2), '=' === i @@ -26021,16 +26054,16 @@ B.push(Y), $.push(L), (L = Y), - (B = Y.stack); + (B = Y.stack)); break; case ')': - 0 === $.length && u.error(s, 'Unmatched ) at column ' + (C - 1)), - (B = (L = $.pop()).options ? L.options[L.options.length - 1] : L.stack); + (0 === $.length && u.error(s, 'Unmatched ) at column ' + (C - 1)), + (B = (L = $.pop()).options ? L.options[L.options.length - 1] : L.stack)); break; case '|': L.options || ((L.options = [L.stack]), delete L.stack); var Z = []; - L.options.push(Z), (B = Z); + (L.options.push(Z), (B = Z)); break; case '{': var ee, @@ -26045,30 +26078,30 @@ : B.push({ type: _.CHAR, value: 123 }); break; case '?': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 0, max: 1, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 0, max: 1, value: B.pop() })); break; case '+': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 1, max: 1 / 0, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 1, max: 1 / 0, value: B.pop() })); break; case '*': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 0, max: 1 / 0, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 0, max: 1 / 0, value: B.pop() })); break; default: B.push({ type: _.CHAR, value: i.charCodeAt(0) }); } - return 0 !== $.length && u.error(s, 'Unterminated group'), j; + return (0 !== $.length && u.error(s, 'Unterminated group'), j); }), - (s.exports.types = _); + (s.exports.types = _)); }, 8048: (s, o, i) => { const u = i(6205); - (o.wordBoundary = () => ({ type: u.POSITION, value: 'b' })), + ((o.wordBoundary = () => ({ type: u.POSITION, value: 'b' })), (o.nonWordBoundary = () => ({ type: u.POSITION, value: 'B' })), (o.begin = () => ({ type: u.POSITION, value: '^' })), - (o.end = () => ({ type: u.POSITION, value: '$' })); + (o.end = () => ({ type: u.POSITION, value: '$' }))); }, 10023: (s, o, i) => { const u = i(6205), @@ -26096,7 +26129,7 @@ { type: u.CHAR, value: 12288 }, { type: u.CHAR, value: 65279 } ]; - (o.words = () => ({ type: u.SET, set: WORDS(), not: !1 })), + ((o.words = () => ({ type: u.SET, set: WORDS(), not: !1 })), (o.notWords = () => ({ type: u.SET, set: WORDS(), not: !0 })), (o.ints = () => ({ type: u.SET, set: INTS(), not: !1 })), (o.notInts = () => ({ type: u.SET, set: INTS(), not: !0 })), @@ -26111,7 +26144,7 @@ { type: u.CHAR, value: 8233 } ], not: !0 - })); + }))); }, 6205: (s) => { s.exports = { @@ -26129,7 +26162,7 @@ const u = i(6205), _ = i(10023), w = { 0: 0, t: 9, n: 10, v: 11, f: 12, r: 13 }; - (o.strToChars = function (s) { + ((o.strToChars = function (s) { return (s = s.replace( /(\[\\b\])|(\\)?\\(?:u([A-F0-9]{4})|x([A-F0-9]{2})|(0?[0-7]{2})|c([@A-Z[\\\]^?])|([0tnvfr]))/g, function (s, o, i, u, _, x, C, j) { @@ -26146,7 +26179,7 @@ ? '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^ ?'.indexOf(C) : w[j], B = String.fromCharCode(L); - return /[[\]{}^$.|?*+()]/.test(B) && (B = '\\' + B), B; + return (/[[\]{}^$.|?*+()]/.test(B) && (B = '\\' + B), B); } )); }), @@ -26158,7 +26191,6 @@ j = /\\(?:(w)|(d)|(s)|(W)|(D)|(S))|((?:(?:\\)(.)|([^\]\\]))-(?:\\)?([^\]]))|(\])|(?:\\)?([^])/g; null != (w = j.exec(s)); - ) if (w[1]) C.push(_.words()); else if (w[2]) C.push(_.ints()); @@ -26180,7 +26212,7 @@ }), (o.error = (s, o) => { throw new SyntaxError('Invalid regular expression: /' + s + '/: ' + o); - }); + })); }, 92861: (s, o, i) => { var u = i(48287), @@ -26191,7 +26223,7 @@ function SafeBuffer(s, o, i) { return _(s, o, i); } - _.from && _.alloc && _.allocUnsafe && _.allocUnsafeSlow + (_.from && _.alloc && _.allocUnsafe && _.allocUnsafeSlow ? (s.exports = u) : (copyProps(u, o), (o.Buffer = SafeBuffer)), (SafeBuffer.prototype = Object.create(_.prototype)), @@ -26204,7 +26236,8 @@ if ('number' != typeof s) throw new TypeError('Argument must be a number'); var u = _(s); return ( - void 0 !== o ? ('string' == typeof i ? u.fill(o, i) : u.fill(o)) : u.fill(0), u + void 0 !== o ? ('string' == typeof i ? u.fill(o, i) : u.fill(o)) : u.fill(0), + u ); }), (SafeBuffer.allocUnsafe = function (s) { @@ -26214,7 +26247,7 @@ (SafeBuffer.allocUnsafeSlow = function (s) { if ('number' != typeof s) throw new TypeError('Argument must be a number'); return u.SlowBuffer(s); - }); + })); }, 29844: (s, o) => { 'use strict'; @@ -26225,7 +26258,7 @@ var u = (i - 1) >>> 1, _ = s[u]; if (!(0 < g(_, o))) break e; - (s[u] = o), (s[i] = _), (i = u); + ((s[u] = o), (s[i] = _), (i = u)); } } function h(s) { @@ -26248,7 +26281,7 @@ : ((s[u] = C), (s[x] = i), (u = x)); else { if (!(j < _ && 0 > g(L, i))) break e; - (s[u] = L), (s[j] = i), (u = j); + ((s[u] = L), (s[j] = i), (u = j)); } } } @@ -26286,42 +26319,42 @@ if (null === o.callback) k(x); else { if (!(o.startTime <= s)) break; - k(x), (o.sortIndex = o.expirationTime), f(w, o); + (k(x), (o.sortIndex = o.expirationTime), f(w, o)); } o = h(x); } } function H(s) { if (((V = !1), G(s), !$)) - if (null !== h(w)) ($ = !0), I(J); + if (null !== h(w)) (($ = !0), I(J)); else { var o = h(x); null !== o && K(H, o.startTime - s); } } function J(s, i) { - ($ = !1), V && ((V = !1), z(ae), (ae = -1)), (B = !0); + (($ = !1), V && ((V = !1), z(ae), (ae = -1)), (B = !0)); var u = L; try { for (G(i), j = h(w); null !== j && (!(j.expirationTime > i) || (s && !M())); ) { var _ = j.callback; if ('function' == typeof _) { - (j.callback = null), (L = j.priorityLevel); + ((j.callback = null), (L = j.priorityLevel)); var C = _(j.expirationTime <= i); - (i = o.unstable_now()), + ((i = o.unstable_now()), 'function' == typeof C ? (j.callback = C) : j === h(w) && k(w), - G(i); + G(i)); } else k(w); j = h(w); } if (null !== j) var U = !0; else { var Y = h(x); - null !== Y && K(H, Y.startTime - i), (U = !1); + (null !== Y && K(H, Y.startTime - i), (U = !1)); } return U; } finally { - (j = null), (L = u), (B = !1); + ((j = null), (L = u), (B = !1)); } } 'undefined' != typeof navigator && @@ -26356,23 +26389,23 @@ else if ('undefined' != typeof MessageChannel) { var pe = new MessageChannel(), de = pe.port2; - (pe.port1.onmessage = R), + ((pe.port1.onmessage = R), (Z = function () { de.postMessage(null); - }); + })); } else Z = function () { U(R, 0); }; function I(s) { - (ie = s), ee || ((ee = !0), Z()); + ((ie = s), ee || ((ee = !0), Z())); } function K(s, i) { ae = U(function () { s(o.unstable_now()); }, i); } - (o.unstable_IdlePriority = 5), + ((o.unstable_IdlePriority = 5), (o.unstable_ImmediatePriority = 1), (o.unstable_LowPriority = 4), (o.unstable_NormalPriority = 3), @@ -26488,7 +26521,7 @@ L = i; } }; - }); + })); }, 69982: (s, o, i) => { 'use strict'; @@ -26499,13 +26532,13 @@ var u = i(48287).Buffer; class NonError extends Error { constructor(s) { - super(NonError._prepareSuperMessage(s)), + (super(NonError._prepareSuperMessage(s)), Object.defineProperty(this, 'name', { value: 'NonError', configurable: !0, writable: !0 }), - Error.captureStackTrace && Error.captureStackTrace(this, NonError); + Error.captureStackTrace && Error.captureStackTrace(this, NonError)); } static _prepareSuperMessage(s) { try { @@ -26536,7 +26569,7 @@ return ((s) => { s[w] = !0; const o = s.toJSON(); - return delete s[w], o; + return (delete s[w], o); })(s); for (const [i, _] of Object.entries(s)) 'function' == typeof u && u.isBuffer(_) @@ -26578,7 +26611,7 @@ if (s instanceof Error) return s; if ('object' == typeof s && null !== s && !Array.isArray(s)) { const o = new Error(); - return destroyCircular({ from: s, seen: [], to_: o, maxDepth: i, depth: 0 }), o; + return (destroyCircular({ from: s, seen: [], to_: o, maxDepth: i, depth: 0 }), o); } return new NonError(s); } @@ -26587,36 +26620,35 @@ 90392: (s, o, i) => { var u = i(92861).Buffer; function Hash(s, o) { - (this._block = u.alloc(s)), + ((this._block = u.alloc(s)), (this._finalSize = o), (this._blockSize = s), - (this._len = 0); + (this._len = 0)); } - (Hash.prototype.update = function (s, o) { + ((Hash.prototype.update = function (s, o) { 'string' == typeof s && ((o = o || 'utf8'), (s = u.from(s, o))); for ( var i = this._block, _ = this._blockSize, w = s.length, x = this._len, C = 0; C < w; - ) { for (var j = x % _, L = Math.min(w - C, _ - j), B = 0; B < L; B++) i[j + B] = s[C + B]; - (C += L), (x += L) % _ == 0 && this._update(i); + ((C += L), (x += L) % _ == 0 && this._update(i)); } - return (this._len += w), this; + return ((this._len += w), this); }), (Hash.prototype.digest = function (s) { var o = this._len % this._blockSize; - (this._block[o] = 128), + ((this._block[o] = 128), this._block.fill(0, o + 1), - o >= this._finalSize && (this._update(this._block), this._block.fill(0)); + o >= this._finalSize && (this._update(this._block), this._block.fill(0))); var i = 8 * this._len; if (i <= 4294967295) this._block.writeUInt32BE(i, this._blockSize - 4); else { var u = (4294967295 & i) >>> 0, _ = (i - u) / 4294967296; - this._block.writeUInt32BE(_, this._blockSize - 8), - this._block.writeUInt32BE(u, this._blockSize - 4); + (this._block.writeUInt32BE(_, this._blockSize - 8), + this._block.writeUInt32BE(u, this._blockSize - 4)); } this._update(this._block); var w = this._hash(); @@ -26625,7 +26657,7 @@ (Hash.prototype._update = function () { throw new Error('_update must be implemented by subclass'); }), - (s.exports = Hash); + (s.exports = Hash)); }, 62802: (s, o, i) => { var u = (s.exports = function SHA(s) { @@ -26634,12 +26666,12 @@ if (!o) throw new Error(s + ' is not supported (we accept pull requests)'); return new o(); }); - (u.sha = i(27816)), + ((u.sha = i(27816)), (u.sha1 = i(63737)), (u.sha224 = i(26710)), (u.sha256 = i(24107)), (u.sha384 = i(32827)), - (u.sha512 = i(82890)); + (u.sha512 = i(82890))); }, 27816: (s, o, i) => { var u = i(56698), @@ -26648,7 +26680,7 @@ x = [1518500249, 1859775393, -1894007588, -899497514], C = new Array(80); function Sha() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function rotl30(s) { return (s << 30) | (s >>> 2); @@ -26656,7 +26688,7 @@ function ft(s, o, i, u) { return 0 === s ? (o & i) | (~o & u) : 2 === s ? (o & i) | (o & u) | (i & u) : o ^ i ^ u; } - u(Sha, _), + (u(Sha, _), (Sha.prototype.init = function () { return ( (this._a = 1732584193), @@ -26685,13 +26717,13 @@ for (var B = 0; B < 80; ++B) { var $ = ~~(B / 20), V = 0 | ((((o = u) << 5) | (o >>> 27)) + ft($, _, w, C) + j + i[B] + x[$]); - (j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V); + ((j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), - (this._e = (j + this._e) | 0); + (this._e = (j + this._e) | 0)); }), (Sha.prototype._hash = function () { var s = w.allocUnsafe(20); @@ -26704,7 +26736,7 @@ s ); }), - (s.exports = Sha); + (s.exports = Sha)); }, 63737: (s, o, i) => { var u = i(56698), @@ -26713,7 +26745,7 @@ x = [1518500249, 1859775393, -1894007588, -899497514], C = new Array(80); function Sha1() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function rotl5(s) { return (s << 5) | (s >>> 27); @@ -26724,7 +26756,7 @@ function ft(s, o, i, u) { return 0 === s ? (o & i) | (~o & u) : 2 === s ? (o & i) | (o & u) | (i & u) : o ^ i ^ u; } - u(Sha1, _), + (u(Sha1, _), (Sha1.prototype.init = function () { return ( (this._a = 1732584193), @@ -26754,13 +26786,13 @@ for (var B = 0; B < 80; ++B) { var $ = ~~(B / 20), V = (rotl5(u) + ft($, _, w, C) + j + i[B] + x[$]) | 0; - (j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V); + ((j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), - (this._e = (j + this._e) | 0); + (this._e = (j + this._e) | 0)); }), (Sha1.prototype._hash = function () { var s = w.allocUnsafe(20); @@ -26773,7 +26805,7 @@ s ); }), - (s.exports = Sha1); + (s.exports = Sha1)); }, 26710: (s, o, i) => { var u = i(56698), @@ -26782,9 +26814,9 @@ x = i(92861).Buffer, C = new Array(64); function Sha224() { - this.init(), (this._w = C), w.call(this, 64, 56); + (this.init(), (this._w = C), w.call(this, 64, 56)); } - u(Sha224, _), + (u(Sha224, _), (Sha224.prototype.init = function () { return ( (this._a = 3238371032), @@ -26811,7 +26843,7 @@ s ); }), - (s.exports = Sha224); + (s.exports = Sha224)); }, 24107: (s, o, i) => { var u = i(56698), @@ -26831,7 +26863,7 @@ ], C = new Array(64); function Sha256() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function ch(s, o, i) { return i ^ (s & (o ^ i)); @@ -26848,7 +26880,7 @@ function gamma0(s) { return ((s >>> 7) | (s << 25)) ^ ((s >>> 18) | (s << 14)) ^ (s >>> 3); } - u(Sha256, _), + (u(Sha256, _), (Sha256.prototype.init = function () { return ( (this._a = 1779033703), @@ -26889,23 +26921,23 @@ for (var U = 0; U < 64; ++U) { var z = ($ + sigma1(j) + ch(j, L, B) + x[U] + i[U]) | 0, Y = (sigma0(u) + maj(u, _, w)) | 0; - ($ = B), + (($ = B), (B = L), (L = j), (j = (C + z) | 0), (C = w), (w = _), (_ = u), - (u = (z + Y) | 0); + (u = (z + Y) | 0)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), (this._e = (j + this._e) | 0), (this._f = (L + this._f) | 0), (this._g = (B + this._g) | 0), - (this._h = ($ + this._h) | 0); + (this._h = ($ + this._h) | 0)); }), (Sha256.prototype._hash = function () { var s = w.allocUnsafe(32); @@ -26921,7 +26953,7 @@ s ); }), - (s.exports = Sha256); + (s.exports = Sha256)); }, 32827: (s, o, i) => { var u = i(56698), @@ -26930,9 +26962,9 @@ x = i(92861).Buffer, C = new Array(160); function Sha384() { - this.init(), (this._w = C), w.call(this, 128, 112); + (this.init(), (this._w = C), w.call(this, 128, 112)); } - u(Sha384, _), + (u(Sha384, _), (Sha384.prototype.init = function () { return ( (this._ah = 3418070365), @@ -26957,7 +26989,7 @@ (Sha384.prototype._hash = function () { var s = x.allocUnsafe(48); function writeInt64BE(o, i, u) { - s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4); + (s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4)); } return ( writeInt64BE(this._ah, this._al, 0), @@ -26969,7 +27001,7 @@ s ); }), - (s.exports = Sha384); + (s.exports = Sha384)); }, 82890: (s, o, i) => { var u = i(56698), @@ -27002,7 +27034,7 @@ ], C = new Array(160); function Sha512() { - this.init(), (this._w = C), _.call(this, 128, 112); + (this.init(), (this._w = C), _.call(this, 128, 112)); } function Ch(s, o, i) { return i ^ (s & (o ^ i)); @@ -27031,7 +27063,7 @@ function getCarry(s, o) { return s >>> 0 < o >>> 0 ? 1 : 0; } - u(Sha512, _), + (u(Sha512, _), (Sha512.prototype.init = function () { return ( (this._ah = 1779033703), @@ -27076,7 +27108,7 @@ ae < 32; ae += 2 ) - (o[ae] = s.readInt32BE(4 * ae)), (o[ae + 1] = s.readInt32BE(4 * ae + 4)); + ((o[ae] = s.readInt32BE(4 * ae)), (o[ae + 1] = s.readInt32BE(4 * ae + 4))); for (; ae < 160; ae += 2) { var le = o[ae - 30], ce = o[ae - 30 + 1], @@ -27090,16 +27122,16 @@ Se = o[ae - 32 + 1], xe = (de + _e) | 0, Pe = (pe + be + getCarry(xe, de)) | 0; - (Pe = + ((Pe = ((Pe = (Pe + fe + getCarry((xe = (xe + ye) | 0), ye)) | 0) + we + getCarry((xe = (xe + Se) | 0), Se)) | 0), (o[ae] = Pe), - (o[ae + 1] = xe); + (o[ae + 1] = xe)); } for (var Te = 0; Te < 160; Te += 2) { - (Pe = o[Te]), (xe = o[Te + 1]); + ((Pe = o[Te]), (xe = o[Te + 1])); var Re = maj(i, u, _), qe = maj($, V, U), $e = sigma0(i, $), @@ -27123,7 +27155,7 @@ 0; var nt = (ze + qe) | 0, st = ($e + Re + getCarry(nt, ze)) | 0; - (B = L), + ((B = L), (ie = ee), (L = j), (ee = Z), @@ -27136,9 +27168,9 @@ (U = V), (u = i), (V = $), - (i = (rt + st + getCarry(($ = (tt + nt) | 0), tt)) | 0); + (i = (rt + st + getCarry(($ = (tt + nt) | 0), tt)) | 0)); } - (this._al = (this._al + $) | 0), + ((this._al = (this._al + $) | 0), (this._bl = (this._bl + V) | 0), (this._cl = (this._cl + U) | 0), (this._dl = (this._dl + z) | 0), @@ -27153,12 +27185,12 @@ (this._eh = (this._eh + C + getCarry(this._el, Y)) | 0), (this._fh = (this._fh + j + getCarry(this._fl, Z)) | 0), (this._gh = (this._gh + L + getCarry(this._gl, ee)) | 0), - (this._hh = (this._hh + B + getCarry(this._hl, ie)) | 0); + (this._hh = (this._hh + B + getCarry(this._hl, ie)) | 0)); }), (Sha512.prototype._hash = function () { var s = w.allocUnsafe(64); function writeInt64BE(o, i, u) { - s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4); + (s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4)); } return ( writeInt64BE(this._ah, this._al, 0), @@ -27172,7 +27204,7 @@ s ); }), - (s.exports = Sha512); + (s.exports = Sha512)); }, 8068: (s) => { 'use strict'; @@ -27193,7 +27225,8 @@ return s; }, __publicField = (s, o, i) => ( - __defNormalProp(s, 'symbol' != typeof o ? o + '' : o, i), i + __defNormalProp(s, 'symbol' != typeof o ? o + '' : o, i), + i ), x = {}; ((o, i) => { @@ -27203,7 +27236,7 @@ j = { dictionary: 'alphanum', shuffle: !0, debug: !1, length: C, counter: 0 }, L = class _ShortUniqueId { constructor(s = {}) { - __publicField(this, 'counter'), + (__publicField(this, 'counter'), __publicField(this, 'debug'), __publicField(this, 'dict'), __publicField(this, 'version'), @@ -27273,7 +27306,7 @@ if (s && Array.isArray(s) && s.length > 1) i = s; else { let o; - (i = []), (this.dictIndex = o = 0); + ((i = []), (this.dictIndex = o = 0)); const u = `_${s}_dict_ranges`, _ = this._dict_ranges[u]; Object.keys(_).forEach((s) => { @@ -27299,9 +27332,9 @@ return i; }), __publicField(this, 'setDictionary', (s, o) => { - (this.dict = this._normalizeDictionary(s, o)), + ((this.dict = this._normalizeDictionary(s, o)), (this.dictLength = this.dict.length), - this.setCounter(0); + this.setCounter(0)); }), __publicField(this, 'seq', () => this.sequentialUUID()), __publicField(this, 'sequentialUUID', () => { @@ -27310,21 +27343,21 @@ i = ''; s = this.counter; do { - (o = s % this.dictLength), + ((o = s % this.dictLength), (s = Math.trunc(s / this.dictLength)), - (i += this.dict[o]); + (i += this.dict[o])); } while (0 !== s); - return (this.counter += 1), i; + return ((this.counter += 1), i); }), __publicField(this, 'rnd', (s = this.uuidLength || C) => this.randomUUID(s)), __publicField(this, 'randomUUID', (s = this.uuidLength || C) => { let o, i, u; if (null == s || s < 1) throw new Error('Invalid UUID Length Provided'); for (o = '', u = 0; u < s; u += 1) - (i = + ((i = parseInt((Math.random() * this.dictLength).toFixed(0), 10) % this.dictLength), - (o += this.dict[i]); + (o += this.dict[i])); return o; }), __publicField(this, 'fmt', (s, o) => this.formattedUUID(s, o)), @@ -27415,9 +27448,12 @@ __publicField(this, 'validate', (s, o) => { const i = o ? this._normalizeDictionary(o) : this.dict; return s.split('').every((s) => i.includes(s)); - }); + })); const o = __spreadValues(__spreadValues({}, j), s); - (this.counter = 0), (this.debug = !1), (this.dict = []), (this.version = '5.2.0'); + ((this.counter = 0), + (this.debug = !1), + (this.dict = []), + (this.version = '5.2.0')); const { dictionary: i, shuffle: u, length: _, counter: w } = o; return ( (this.uuidLength = _), @@ -27463,7 +27499,7 @@ })(s({}, '__esModule', { value: !0 }), B) ); })(); - (s.exports = o.default), 'undefined' != typeof window && (o = o.default); + ((s.exports = o.default), 'undefined' != typeof window && (o = o.default)); }, 88310: (s, o, i) => { s.exports = Stream; @@ -27471,7 +27507,7 @@ function Stream() { u.call(this); } - i(56698)(Stream, u), + (i(56698)(Stream, u), (Stream.Readable = i(45412)), (Stream.Writable = i(16708)), (Stream.Duplex = i(25382)), @@ -27488,9 +27524,9 @@ function ondrain() { i.readable && i.resume && i.resume(); } - i.on('data', ondata), + (i.on('data', ondata), s.on('drain', ondrain), - s._isStdio || (o && !1 === o.end) || (i.on('end', onend), i.on('close', onclose)); + s._isStdio || (o && !1 === o.end) || (i.on('end', onend), i.on('close', onclose))); var _ = !1; function onend() { _ || ((_ = !0), s.end()); @@ -27502,7 +27538,7 @@ if ((cleanup(), 0 === u.listenerCount(this, 'error'))) throw s; } function cleanup() { - i.removeListener('data', ondata), + (i.removeListener('data', ondata), s.removeListener('drain', ondrain), i.removeListener('end', onend), i.removeListener('close', onclose), @@ -27510,7 +27546,7 @@ s.removeListener('error', onerror), i.removeListener('end', cleanup), i.removeListener('close', cleanup), - s.removeListener('close', cleanup); + s.removeListener('close', cleanup)); } return ( i.on('error', onerror), @@ -27521,7 +27557,7 @@ s.emit('pipe', i), s ); - }); + })); }, 83141: (s, o, i) => { 'use strict'; @@ -27571,7 +27607,7 @@ return s; default: if (o) return; - (s = ('' + s).toLowerCase()), (o = !0); + ((s = ('' + s).toLowerCase()), (o = !0)); } })(s); if ('string' != typeof o && (u.isEncoding === _ || !_(s))) @@ -27581,18 +27617,18 @@ this.encoding) ) { case 'utf16le': - (this.text = utf16Text), (this.end = utf16End), (o = 4); + ((this.text = utf16Text), (this.end = utf16End), (o = 4)); break; case 'utf8': - (this.fillLast = utf8FillLast), (o = 4); + ((this.fillLast = utf8FillLast), (o = 4)); break; case 'base64': - (this.text = base64Text), (this.end = base64End), (o = 3); + ((this.text = base64Text), (this.end = base64End), (o = 3)); break; default: - return (this.write = simpleWrite), void (this.end = simpleEnd); + return ((this.write = simpleWrite), void (this.end = simpleEnd)); } - (this.lastNeed = 0), (this.lastTotal = 0), (this.lastChar = u.allocUnsafe(o)); + ((this.lastNeed = 0), (this.lastTotal = 0), (this.lastChar = u.allocUnsafe(o))); } function utf8CheckByte(s) { return s <= 127 @@ -27610,11 +27646,11 @@ function utf8FillLast(s) { var o = this.lastTotal - this.lastNeed, i = (function utf8CheckExtraBytes(s, o, i) { - if (128 != (192 & o[0])) return (s.lastNeed = 0), '�'; + if (128 != (192 & o[0])) return ((s.lastNeed = 0), '�'); if (s.lastNeed > 1 && o.length > 1) { - if (128 != (192 & o[1])) return (s.lastNeed = 1), '�'; + if (128 != (192 & o[1])) return ((s.lastNeed = 1), '�'); if (s.lastNeed > 2 && o.length > 2 && 128 != (192 & o[2])) - return (s.lastNeed = 2), '�'; + return ((s.lastNeed = 2), '�'); } })(this, s); return void 0 !== i @@ -27676,13 +27712,13 @@ function simpleEnd(s) { return s && s.length ? this.write(s) : ''; } - (o.I = StringDecoder), + ((o.I = StringDecoder), (StringDecoder.prototype.write = function (s) { if (0 === s.length) return ''; var o, i; if (this.lastNeed) { if (void 0 === (o = this.fillLast(s))) return ''; - (i = this.lastNeed), (this.lastNeed = 0); + ((i = this.lastNeed), (this.lastNeed = 0)); } else i = 0; return i < s.length ? (o ? o + this.text(s, i) : this.text(s, i)) : o || ''; }), @@ -27695,18 +27731,18 @@ var u = o.length - 1; if (u < i) return 0; var _ = utf8CheckByte(o[u]); - if (_ >= 0) return _ > 0 && (s.lastNeed = _ - 1), _; + if (_ >= 0) return (_ > 0 && (s.lastNeed = _ - 1), _); if (--u < i || -2 === _) return 0; - if (((_ = utf8CheckByte(o[u])), _ >= 0)) return _ > 0 && (s.lastNeed = _ - 2), _; + if (((_ = utf8CheckByte(o[u])), _ >= 0)) return (_ > 0 && (s.lastNeed = _ - 2), _); if (--u < i || -2 === _) return 0; if (((_ = utf8CheckByte(o[u])), _ >= 0)) - return _ > 0 && (2 === _ ? (_ = 0) : (s.lastNeed = _ - 3)), _; + return (_ > 0 && (2 === _ ? (_ = 0) : (s.lastNeed = _ - 3)), _); return 0; })(this, s, o); if (!this.lastNeed) return s.toString('utf8', o); this.lastTotal = i; var u = s.length - (i - this.lastNeed); - return s.copy(this.lastChar, 0, u), s.toString('utf8', o, u); + return (s.copy(this.lastChar, 0, u), s.toString('utf8', o, u)); }), (StringDecoder.prototype.fillLast = function (s) { if (this.lastNeed <= s.length) @@ -27714,13 +27750,13 @@ s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, this.lastNeed), this.lastChar.toString(this.encoding, 0, this.lastTotal) ); - s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, s.length), - (this.lastNeed -= s.length); - }); + (s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, s.length), + (this.lastNeed -= s.length)); + })); }, 69883: (s, o) => { 'use strict'; - (o.parse = function parse(s, o) { + ((o.parse = function parse(s, o) { if ('string' != typeof s) throw new TypeError('argument str must be a string'); var i = {}, _ = s.length; @@ -27819,7 +27855,7 @@ } } return B; - }); + })); var i = Object.prototype.toString, u = Object.prototype.hasOwnProperty, _ = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/, @@ -27869,12 +27905,12 @@ return ( s.removeAllRanges(), function () { - 'Caret' === s.type && s.removeAllRanges(), + ('Caret' === s.type && s.removeAllRanges(), s.rangeCount || i.forEach(function (o) { s.addRange(o); }), - o && o.focus(); + o && o.focus()); } ); }; @@ -27937,7 +27973,7 @@ ); } function extractProtocol(s, o) { - (s = (s = trimLeft(s)).replace(x, '')), (o = o || {}); + ((s = (s = trimLeft(s)).replace(x, '')), (o = o || {})); var i, u = L.exec(s), _ = u[1] ? u[1].toLowerCase() : '', @@ -28002,7 +28038,7 @@ (Z[U] = Z[U] || (w && L[3] && o[U]) || ''), L[4] && (Z[U] = Z[U].toLowerCase())) : (s = L(s, Z)); - i && (Z.query = i(Z.query)), + (i && (Z.query = i(Z.query)), w && o.slashes && '/' !== Z.pathname.charAt(0) && @@ -28016,14 +28052,13 @@ w = !1, x = 0; u--; - ) '.' === i[u] ? i.splice(u, 1) : '..' === i[u] ? (i.splice(u, 1), x++) : x && (0 === u && (w = !0), i.splice(u, 1), x--); - return w && i.unshift(''), ('.' !== _ && '..' !== _) || i.push(''), i.join('/'); + return (w && i.unshift(''), ('.' !== _ && '..' !== _) || i.push(''), i.join('/')); })(Z.pathname, o.pathname)), '/' !== Z.pathname.charAt(0) && isSpecial(Z.protocol) && @@ -28042,32 +28077,32 @@ 'file:' !== Z.protocol && isSpecial(Z.protocol) && Z.host ? Z.protocol + '//' + Z.host : 'null'), - (Z.href = Z.toString()); + (Z.href = Z.toString())); } - (Url.prototype = { + ((Url.prototype = { set: function set(s, o, i) { var w = this; switch (s) { case 'query': - 'string' == typeof o && o.length && (o = (i || _.parse)(o)), (w[s] = o); + ('string' == typeof o && o.length && (o = (i || _.parse)(o)), (w[s] = o)); break; case 'port': - (w[s] = o), + ((w[s] = o), u(o, w.protocol) ? o && (w.host = w.hostname + ':' + o) - : ((w.host = w.hostname), (w[s] = '')); + : ((w.host = w.hostname), (w[s] = ''))); break; case 'hostname': - (w[s] = o), w.port && (o += ':' + w.port), (w.host = o); + ((w[s] = o), w.port && (o += ':' + w.port), (w.host = o)); break; case 'host': - (w[s] = o), + ((w[s] = o), j.test(o) ? ((o = o.split(':')), (w.port = o.pop()), (w.hostname = o.join(':'))) - : ((w.hostname = o), (w.port = '')); + : ((w.hostname = o), (w.port = ''))); break; case 'protocol': - (w.protocol = o.toLowerCase()), (w.slashes = !i); + ((w.protocol = o.toLowerCase()), (w.slashes = !i)); break; case 'pathname': case 'hash': @@ -28134,7 +28169,7 @@ (Url.location = lolcation), (Url.trimLeft = trimLeft), (Url.qs = _), - (s.exports = Url); + (s.exports = Url)); }, 77154: (s, o, i) => { 'use strict'; @@ -28191,7 +28226,7 @@ return ( C( function () { - (V.hasValue = !0), (V.value = U); + ((V.hasValue = !0), (V.value = U)); }, [U] ), @@ -28220,7 +28255,7 @@ return function deprecated() { if (!i) { if (config('throwDeprecation')) throw new Error(o); - config('traceDeprecation') ? console.trace(o) : console.warn(o), (i = !0); + (config('traceDeprecation') ? console.trace(o) : console.warn(o), (i = !0)); } return s.apply(this, arguments); }; @@ -28337,7 +28372,7 @@ switch (typeof x) { case 'object': if (null === x) break; - x._attr && get_attributes(x._attr), + (x._attr && get_attributes(x._attr), x._cdata && L.push(('/g, ']]]]>') + ']]>'), x.forEach && @@ -28350,7 +28385,7 @@ : L.push(resolve(s, o, i + 1)) : (L.pop(), (C = !0), L.push(_(s))); }), - C || L.push('')); + C || L.push(''))); break; default: L.push(_(x)); @@ -28376,13 +28411,13 @@ format(s, _); } } - s( + (s( !1, (u > 1 ? o.indents : '') + (o.name ? '' : '') + (o.indent && !i ? '\n' : '') ), - i && i(); + i && i()); } function interrupt(o) { return ( @@ -28408,7 +28443,7 @@ return s(!1, o.indent ? '\n' : ''); interrupt(o) || proceed(); } - (s.exports = function xml(s, o) { + ((s.exports = function xml(s, o) { 'object' != typeof o && (o = { indent: o }); var i = o.stream ? new w() : null, _ = '', @@ -28421,10 +28456,10 @@ function append(s, o) { if ((void 0 !== o && (_ += o), s && !x && ((i = i || new w()), (x = !0)), s && x)) { var u = _; - delay(function () { + (delay(function () { i.emit('data', u); }), - (_ = ''); + (_ = '')); } } function add(s, o) { @@ -28434,7 +28469,7 @@ if (i) { var s = _; delay(function () { - i.emit('data', s), i.emit('end'), (i.readable = !1), i.emit('close'); + (i.emit('data', s), i.emit('end'), (i.readable = !1), i.emit('close')); }); } } @@ -28445,14 +28480,14 @@ o.declaration && (function addXmlDeclaration(s) { var o = { version: '1.0', encoding: s.encoding || 'UTF-8' }; - s.standalone && (o.standalone = s.standalone), + (s.standalone && (o.standalone = s.standalone), add({ '?xml': { _attr: o } }), - (_ = _.replace('/>', '?>')); + (_ = _.replace('/>', '?>'))); })(o.declaration), s && s.forEach ? s.forEach(function (o, i) { var u; - i + 1 === s.length && (u = end), add(o, u); + (i + 1 === s.length && (u = end), add(o, u)); }) : add(s, end), i ? ((i.readable = !0), i) : _ @@ -28475,15 +28510,15 @@ ); }, close: function (s) { - void 0 !== s && this.push(s), this.end && this.end(); + (void 0 !== s && this.push(s), this.end && this.end()); } }; return s; - }); + })); }, 86215: function (s, o) { var i, u, _; - (u = []), + ((u = []), (i = (function () { 'use strict'; var isNativeSmoothScrollEnabledOn = function (s) { @@ -28496,12 +28531,12 @@ if ('undefined' == typeof window || !('document' in window)) return {}; var makeScroller = function (s, o, i) { var u; - (o = o || 999), i || 0 === i || (i = 9); + ((o = o || 999), i || 0 === i || (i = 9)); var setScrollTimeoutId = function (s) { u = s; }, stopScroll = function () { - clearTimeout(u), setScrollTimeoutId(0); + (clearTimeout(u), setScrollTimeoutId(0)); }, getTopWithEdgeOffset = function (o) { return Math.max(0, s.getTopOf(o) - i); @@ -28511,12 +28546,12 @@ (stopScroll(), 0 === u || (u && u < 0) || isNativeSmoothScrollEnabledOn(s.body)) ) - s.toY(i), _ && _(); + (s.toY(i), _ && _()); else { var w = s.getY(), x = Math.max(0, i) - w, C = new Date().getTime(); - (u = u || Math.min(Math.abs(x), o)), + ((u = u || Math.min(Math.abs(x), o)), (function loopScroll() { setScrollTimeoutId( setTimeout(function () { @@ -28525,13 +28560,13 @@ 0, Math.floor(w + x * (o < 0.5 ? 2 * o * o : o * (4 - 2 * o) - 1)) ); - s.toY(i), + (s.toY(i), o < 1 && s.getHeight() + i < s.body.scrollHeight ? loopScroll() - : (setTimeout(stopScroll, 99), _ && _()); + : (setTimeout(stopScroll, 99), _ && _())); }, 9) ); - })(); + })()); } }, scrollToElem = function (s, o, i) { @@ -28626,11 +28661,11 @@ ) { var i = 'history' in window && 'pushState' in history, u = i && 'scrollRestoration' in history; - u && (history.scrollRestoration = 'auto'), + (u && (history.scrollRestoration = 'auto'), window.addEventListener( 'load', function () { - u && + (u && (setTimeout(function () { history.scrollRestoration = 'manual'; }, 9), @@ -28652,10 +28687,10 @@ 0 <= _ && _ < 9 && window.scrollTo(0, u); } } - }, 9); + }, 9)); }, !1 - ); + )); var _ = new RegExp('(^|\\s)noZensmooth(\\s|$)'); window.addEventListener( 'click', @@ -28685,13 +28720,13 @@ window.location = C; }, B = o.setup().edgeOffset; - B && + (B && ((j = Math.max(0, j - B)), i && (onDone = function () { history.pushState({}, '', C); })), - o.toY(j, null, onDone); + o.toY(j, null, onDone)); } } }, @@ -28700,7 +28735,7 @@ } return o; })()), - void 0 === (_ = 'function' == typeof i ? i.apply(o, u) : i) || (s.exports = _); + void 0 === (_ = 'function' == typeof i ? i.apply(o, u) : i) || (s.exports = _)); }, 15340: () => {}, 79838: () => {}, @@ -28728,7 +28763,7 @@ _extends.apply(null, arguments) ); } - (s.exports = _extends), (s.exports.__esModule = !0), (s.exports.default = s.exports); + ((s.exports = _extends), (s.exports.__esModule = !0), (s.exports.default = s.exports)); }, 46942: (s, o) => { var i; @@ -28783,7 +28818,7 @@ }, 37257: (s, o, i) => { 'use strict'; - i(96605), i(64502), i(36371), i(99363), i(7057); + (i(96605), i(64502), i(36371), i(99363), i(7057)); var u = i(92046); s.exports = u.AggregateError; }, @@ -28959,7 +28994,10 @@ var u = i(98828); s.exports = !u(function () { function F() {} - return (F.prototype.constructor = null), Object.getPrototypeOf(new F()) !== F.prototype; + return ( + (F.prototype.constructor = null), + Object.getPrototypeOf(new F()) !== F.prototype + ); }); }, 59550: (s) => { @@ -28978,7 +29016,7 @@ return _.f(s, o, w(1, i)); } : function (s, o, i) { - return (s[o] = i), s; + return ((s[o] = i), s); }; }, 75817: (s) => { @@ -28991,7 +29029,7 @@ 'use strict'; var u = i(61626); s.exports = function (s, o, i, _) { - return _ && _.enumerable ? (s[o] = i) : u(s, o, i), s; + return (_ && _.enumerable ? (s[o] = i) : u(s, o, i), s); }; }, 2532: (s, o, i) => { @@ -29095,13 +29133,13 @@ j = w.Deno, L = (C && C.versions) || (j && j.version), B = L && L.v8; - B && (_ = (u = B.split('.'))[0] > 0 && u[0] < 4 ? 1 : +(u[0] + u[1])), + (B && (_ = (u = B.split('.'))[0] > 0 && u[0] < 4 ? 1 : +(u[0] + u[1])), !_ && x && (!(u = x.match(/Edge\/(\d+)/)) || u[1] >= 74) && (u = x.match(/Chrome\/(\d+)/)) && (_ = +u[1]), - (s.exports = _); + (s.exports = _)); }, 85762: (s, o, i) => { 'use strict'; @@ -29163,7 +29201,7 @@ } return _(s, this, arguments); }; - return (Wrapper.prototype = s.prototype), Wrapper; + return ((Wrapper.prototype = s.prototype), Wrapper); }; s.exports = function (s, o) { var i, @@ -29183,7 +29221,7 @@ ye = ce ? L : L[le] || $(L, le, {})[le], be = ye.prototype; for (z in o) - (_ = !(i = j(ce ? z : le + (pe ? '.' : '#') + z, s.forced)) && fe && V(fe, z)), + ((_ = !(i = j(ce ? z : le + (pe ? '.' : '#') + z, s.forced)) && fe && V(fe, z)), (Z = ye[z]), _ && (ee = s.dontCallGetSet ? (ae = C(fe, z)) && ae.value : fe[z]), (Y = _ && ee ? ee : o[z]), @@ -29201,7 +29239,7 @@ de && (V(L, (U = le + 'Prototype')) || $(L, U, {}), $(L[U], z, Y), - s.real && be && (i || !be[z]) && $(be, z, Y))); + s.real && be && (i || !be[z]) && $(be, z, Y)))); }; }, 98828: (s) => { @@ -29285,7 +29323,7 @@ })(o, i.length, i) : o.apply(s, i); }; - return w(i) && (j.prototype = i), j; + return (w(i) && (j.prototype = i), j); }; }, 13930: (s, o, i) => { @@ -29515,32 +29553,32 @@ Z = C.WeakMap; if (x || $.state) { var ee = $.state || ($.state = new Z()); - (ee.get = ee.get), + ((ee.get = ee.get), (ee.has = ee.has), (ee.set = ee.set), (u = function (s, o) { if (ee.has(s)) throw new Y(z); - return (o.facade = s), ee.set(s, o), o; + return ((o.facade = s), ee.set(s, o), o); }), (_ = function (s) { return ee.get(s) || {}; }), (w = function (s) { return ee.has(s); - }); + })); } else { var ie = V('state'); - (U[ie] = !0), + ((U[ie] = !0), (u = function (s, o) { if (B(s, ie)) throw new Y(z); - return (o.facade = s), L(s, ie, o), o; + return ((o.facade = s), L(s, ie, o), o); }), (_ = function (s) { return B(s, ie) ? s[ie] : {}; }), (w = function (s) { return B(s, ie); - }); + })); } s.exports = { set: u, @@ -29652,7 +29690,7 @@ V = i(40154), U = TypeError, Result = function (s, o) { - (this.stopped = s), (this.result = o); + ((this.stopped = s), (this.result = o)); }, z = Result.prototype; s.exports = function (s, o, i) { @@ -29670,7 +29708,7 @@ be = !(!i || !i.INTERRUPTED), _e = u(o, pe), stop = function (s) { - return Y && V(Y, 'normal', s), new Result(!0, s); + return (Y && V(Y, 'normal', s), new Result(!0, s)); }, callFn = function (s) { return de @@ -29716,11 +29754,11 @@ } x = u(x, s); } catch (s) { - (C = !0), (x = s); + ((C = !0), (x = s)); } if ('throw' === o) throw i; if (C) throw x; - return _(x), i; + return (_(x), i); }; }, 47181: (s, o, i) => { @@ -29736,7 +29774,10 @@ s.exports = function (s, o, i, j) { var L = o + ' Iterator'; return ( - (s.prototype = _(u, { next: w(+!j, i) })), x(s, L, !1, !0), (C[L] = returnThis), s + (s.prototype = _(u, { next: w(+!j, i) })), + x(s, L, !1, !0), + (C[L] = returnThis), + s ); }; }, @@ -29828,7 +29869,7 @@ ) for (we in _e) (le || xe || !(we in Pe)) && U(Pe, we, _e[we]); else u({ target: o, proto: !0, forced: le || xe }, _e); - return (w && !ye) || Pe[ce] === Re || U(Pe, ce, Re, { name: z }), (Y[o] = Re), _e; + return ((w && !ye) || Pe[ce] === Re || U(Pe, ce, Re, { name: z }), (Y[o] = Re), _e); }; }, 95116: (s, o, i) => { @@ -29846,7 +29887,7 @@ U = i(7376), z = V('iterator'), Y = !1; - [].keys && + ([].keys && ('next' in (w = [].keys()) ? (_ = B(B(w))) !== Object.prototype && (u = _) : (Y = !0)), !j(u) || x(function () { @@ -29859,7 +29900,7 @@ $(u, z, function () { return this; }), - (s.exports = { IteratorPrototype: u, BUGGY_SAFARI_ITERATORS: Y }); + (s.exports = { IteratorPrototype: u, BUGGY_SAFARI_ITERATORS: Y })); }, 93742: (s) => { 'use strict'; @@ -29945,9 +29986,8 @@ ie = ee.length, ae = 0; ie > ae; - ) - (Y = ee[ae++]), (u && !w(U, Z, Y)) || (i[Y] = Z[Y]); + ((Y = ee[ae++]), (u && !w(U, Z, Y)) || (i[Y] = Z[Y])); return i; } : V; @@ -29970,9 +30010,9 @@ return '<' + V + '>' + s + ''; }, NullProtoObjectViaActiveX = function (s) { - s.write(scriptTag('')), s.close(); + (s.write(scriptTag('')), s.close()); var o = s.parentWindow.Object; - return (s = null), o; + return ((s = null), o); }, NullProtoObject = function () { try { @@ -29996,7 +30036,7 @@ for (var _ = x.length; _--; ) delete NullProtoObject[$][x[_]]; return NullProtoObject(); }; - (C[U] = !0), + ((C[U] = !0), (s.exports = Object.create || function create(s, o) { @@ -30010,7 +30050,7 @@ : (i = NullProtoObject()), void 0 === o ? i : w.f(i, o) ); - }); + })); }, 42220: (s, o, i) => { 'use strict'; @@ -30071,7 +30111,7 @@ return L(s, o, i); } catch (s) {} if ('get' in i || 'set' in i) throw new j('Accessors not supported'); - return 'value' in i && (s[o] = i.value), s; + return ('value' in i && (s[o] = i.value), s); }; }, 13846: (s, o, i) => { @@ -30187,10 +30227,11 @@ o = !1, i = {}; try { - (s = u(Object.prototype, '__proto__', 'set'))(i, []), (o = i instanceof Array); + ((s = u(Object.prototype, '__proto__', 'set'))(i, []), + (o = i instanceof Array)); } catch (s) {} return function setPrototypeOf(i, u) { - return w(i), x(u), _(i) ? (o ? s(i, u) : (i.__proto__ = u), i) : i; + return (w(i), x(u), _(i) ? (o ? s(i, u) : (i.__proto__ = u), i) : i); }; })() : void 0); @@ -30417,7 +30458,7 @@ if ((void 0 === o && (o = 'default'), (i = u(j, s, o)), !_(i) || w(i))) return i; throw new L("Can't convert object to primitive value"); } - return void 0 === o && (o = 'number'), C(s, o); + return (void 0 === o && (o = 'number'), C(s, o)); }; }, 70470: (s, o, i) => { @@ -30432,7 +30473,7 @@ 52623: (s, o, i) => { 'use strict'; var u = {}; - (u[i(76264)('toStringTag')] = 'z'), (s.exports = '[object z]' === String(u)); + ((u[i(76264)('toStringTag')] = 'z'), (s.exports = '[object z]' === String(u))); }, 90160: (s, o, i) => { 'use strict'; @@ -30502,7 +30543,7 @@ B = _('wks'), $ = j ? L.for || L : (L && L.withoutSetter) || x; s.exports = function (s) { - return w(B, s) || (B[s] = C && w(L, s) ? L[s] : $('Symbol.' + s)), B[s]; + return (w(B, s) || (B[s] = C && w(L, s) ? L[s] : $('Symbol.' + s)), B[s]); }; }, 19358: (s, o, i) => { @@ -30552,7 +30593,7 @@ !Y) ) try { - pe.name !== le && w(pe, 'name', le), (pe.constructor = fe); + (pe.name !== le && w(pe, 'name', le), (pe.constructor = fe)); } catch (s) {} return fe; } @@ -30610,12 +30651,12 @@ ie = function AggregateError(s, o) { var i, u = _(ae, this); - x ? (i = x(new Z(), u ? w(this) : ae)) : ((i = u ? this : j(ae)), L(i, Y, 'Error')), + (x ? (i = x(new Z(), u ? w(this) : ae)) : ((i = u ? this : j(ae)), L(i, Y, 'Error')), void 0 !== o && L(i, 'message', z(o)), V(i, ie, i.stack, 1), - arguments.length > 2 && $(i, arguments[2]); + arguments.length > 2 && $(i, arguments[2])); var C = []; - return U(s, ee, { that: C }), L(i, 'errors', C), i; + return (U(s, ee, { that: C }), L(i, 'errors', C), i); }; x ? x(ie, Z) : C(ie, Z, { name: !0 }); var ae = (ie.prototype = j(Z.prototype, { @@ -30653,7 +30694,7 @@ var s = z(this), o = s.target, i = s.index++; - if (!o || i >= o.length) return (s.target = null), L(void 0, !0); + if (!o || i >= o.length) return ((s.target = null), L(void 0, !0)); switch (s.kind) { case 'keys': return L(i, !1); @@ -30681,16 +30722,16 @@ L = 7 !== new Error('e', { cause: 7 }).cause, exportGlobalErrorCauseWrapper = function (s, o) { var i = {}; - (i[s] = x(s, o, L)), u({ global: !0, constructor: !0, arity: 1, forced: L }, i); + ((i[s] = x(s, o, L)), u({ global: !0, constructor: !0, arity: 1, forced: L }, i)); }, exportWebAssemblyErrorCauseWrapper = function (s, o) { if (j && j[s]) { var i = {}; - (i[s] = x(C + '.' + s, o, L)), - u({ target: C, stat: !0, constructor: !0, arity: 1, forced: L }, i); + ((i[s] = x(C + '.' + s, o, L)), + u({ target: C, stat: !0, constructor: !0, arity: 1, forced: L }, i)); } }; - exportGlobalErrorCauseWrapper('Error', function (s) { + (exportGlobalErrorCauseWrapper('Error', function (s) { return function Error(o) { return w(s, this, arguments); }; @@ -30739,7 +30780,7 @@ return function RuntimeError(o) { return w(s, this, arguments); }; - }); + })); }, 79307: (s, o, i) => { 'use strict'; @@ -30791,13 +30832,13 @@ _ = i(45951), w = i(14840), x = i(93742); - for (var C in u) w(_[C], C), (x[C] = x.Array); + for (var C in u) (w(_[C], C), (x[C] = x.Array)); }, 694: (s, o, i) => { 'use strict'; i(91599); var u = i(37257); - i(12560), (s.exports = u); + (i(12560), (s.exports = u)); }, 19709: (s, o, i) => { 'use strict'; @@ -30815,11 +30856,11 @@ var o = u[s]; if (void 0 !== o) return o.exports; var _ = (u[s] = { id: s, loaded: !1, exports: {} }); - return i[s].call(_.exports, _, _.exports, __webpack_require__), (_.loaded = !0), _.exports; + return (i[s].call(_.exports, _, _.exports, __webpack_require__), (_.loaded = !0), _.exports); } - (__webpack_require__.n = (s) => { + ((__webpack_require__.n = (s) => { var o = s && s.__esModule ? () => s.default : () => s; - return __webpack_require__.d(o, { a: o }), o; + return (__webpack_require__.d(o, { a: o }), o); }), (o = Object.getPrototypeOf ? (s) => Object.getPrototypeOf(s) : (s) => s.__proto__), (__webpack_require__.t = function (i, u) { @@ -30834,7 +30875,7 @@ s = s || [null, o({}), o([]), o(o)]; for (var x = 2 & u && i; 'object' == typeof x && !~s.indexOf(x); x = o(x)) Object.getOwnPropertyNames(x).forEach((s) => (w[s] = () => i[s])); - return (w.default = () => i), __webpack_require__.d(_, w), _; + return ((w.default = () => i), __webpack_require__.d(_, w), _); }), (__webpack_require__.d = (s, o) => { for (var i in o) @@ -30852,19 +30893,19 @@ })()), (__webpack_require__.o = (s, o) => Object.prototype.hasOwnProperty.call(s, o)), (__webpack_require__.r = (s) => { - 'undefined' != typeof Symbol && + ('undefined' != typeof Symbol && Symbol.toStringTag && Object.defineProperty(s, Symbol.toStringTag, { value: 'Module' }), - Object.defineProperty(s, '__esModule', { value: !0 }); + Object.defineProperty(s, '__esModule', { value: !0 })); }), - (__webpack_require__.nmd = (s) => ((s.paths = []), s.children || (s.children = []), s)); + (__webpack_require__.nmd = (s) => ((s.paths = []), s.children || (s.children = []), s))); var _ = {}; return ( (() => { 'use strict'; __webpack_require__.d(_, { default: () => WI }); var s = {}; - __webpack_require__.r(s), + (__webpack_require__.r(s), __webpack_require__.d(s, { CLEAR: () => ot, CLEAR_BY: () => it, @@ -30880,9 +30921,9 @@ newSpecErrBatch: () => newSpecErrBatch, newThrownErr: () => newThrownErr, newThrownErrBatch: () => newThrownErrBatch - }); + })); var o = {}; - __webpack_require__.r(o), + (__webpack_require__.r(o), __webpack_require__.d(o, { AUTHORIZE: () => Nt, AUTHORIZE_OAUTH2: () => Lt, @@ -30910,9 +30951,9 @@ preAuthorizeImplicit: () => preAuthorizeImplicit, restoreAuthorization: () => restoreAuthorization, showDefinitions: () => showDefinitions - }); + })); var i = {}; - __webpack_require__.r(i), + (__webpack_require__.r(i), __webpack_require__.d(i, { authorized: () => Ht, definitionsForRequirements: () => definitionsForRequirements, @@ -30921,9 +30962,9 @@ getDefinitionsByNames: () => getDefinitionsByNames, isAuthorized: () => isAuthorized, shownDefinitions: () => Wt - }); + })); var u = {}; - __webpack_require__.r(u), + (__webpack_require__.r(u), __webpack_require__.d(u, { TOGGLE_CONFIGS: () => yn, UPDATE_CONFIGS: () => gn, @@ -30932,19 +30973,19 @@ loaded: () => actions_loaded, toggle: () => toggle, update: () => update - }); + })); var w = {}; - __webpack_require__.r(w), __webpack_require__.d(w, { get: () => get }); + (__webpack_require__.r(w), __webpack_require__.d(w, { get: () => get })); var x = {}; - __webpack_require__.r(x), __webpack_require__.d(x, { transform: () => transform }); + (__webpack_require__.r(x), __webpack_require__.d(x, { transform: () => transform })); var C = {}; - __webpack_require__.r(C), - __webpack_require__.d(C, { transform: () => parameter_oneof_transform }); + (__webpack_require__.r(C), + __webpack_require__.d(C, { transform: () => parameter_oneof_transform })); var j = {}; - __webpack_require__.r(j), - __webpack_require__.d(j, { allErrors: () => Mn, lastError: () => Tn }); + (__webpack_require__.r(j), + __webpack_require__.d(j, { allErrors: () => Mn, lastError: () => Tn })); var L = {}; - __webpack_require__.r(L), + (__webpack_require__.r(L), __webpack_require__.d(L, { SHOW: () => Fn, UPDATE_FILTER: () => Ln, @@ -30954,36 +30995,36 @@ show: () => actions_show, updateFilter: () => updateFilter, updateLayout: () => updateLayout - }); + })); var B = {}; - __webpack_require__.r(B), + (__webpack_require__.r(B), __webpack_require__.d(B, { current: () => current, currentFilter: () => currentFilter, isShown: () => isShown, showSummary: () => $n, whatMode: () => whatMode - }); + })); var $ = {}; - __webpack_require__.r($), - __webpack_require__.d($, { taggedOperations: () => taggedOperations }); + (__webpack_require__.r($), + __webpack_require__.d($, { taggedOperations: () => taggedOperations })); var V = {}; - __webpack_require__.r(V), + (__webpack_require__.r(V), __webpack_require__.d(V, { requestSnippetGenerator_curl_bash: () => requestSnippetGenerator_curl_bash, requestSnippetGenerator_curl_cmd: () => requestSnippetGenerator_curl_cmd, requestSnippetGenerator_curl_powershell: () => requestSnippetGenerator_curl_powershell - }); + })); var U = {}; - __webpack_require__.r(U), + (__webpack_require__.r(U), __webpack_require__.d(U, { getActiveLanguage: () => zn, getDefaultExpanded: () => Wn, getGenerators: () => Un, getSnippetGenerators: () => getSnippetGenerators - }); + })); var z = {}; - __webpack_require__.r(z), + (__webpack_require__.r(z), __webpack_require__.d(z, { JsonSchemaArrayItemFile: () => JsonSchemaArrayItemFile, JsonSchemaArrayItemText: () => JsonSchemaArrayItemText, @@ -30992,9 +31033,9 @@ JsonSchema_boolean: () => JsonSchema_boolean, JsonSchema_object: () => JsonSchema_object, JsonSchema_string: () => JsonSchema_string - }); + })); var Y = {}; - __webpack_require__.r(Y), + (__webpack_require__.r(Y), __webpack_require__.d(Y, { allowTryItOutFor: () => allowTryItOutFor, basePath: () => Ks, @@ -31054,9 +31095,9 @@ validateBeforeExecute: () => validateBeforeExecute, validationErrors: () => validationErrors, version: () => Ds - }); + })); var Z = {}; - __webpack_require__.r(Z), + (__webpack_require__.r(Z), __webpack_require__.d(Z, { CLEAR_REQUEST: () => wo, CLEAR_RESPONSE: () => Eo, @@ -31100,17 +31141,17 @@ updateSpec: () => updateSpec, updateUrl: () => updateUrl, validateParams: () => validateParams - }); + })); var ee = {}; - __webpack_require__.r(ee), + (__webpack_require__.r(ee), __webpack_require__.d(ee, { executeRequest: () => wrap_actions_executeRequest, updateJsonSpec: () => wrap_actions_updateJsonSpec, updateSpec: () => wrap_actions_updateSpec, validateParams: () => wrap_actions_validateParams - }); + })); var ie = {}; - __webpack_require__.r(ie), + (__webpack_require__.r(ie), __webpack_require__.d(ie, { JsonPatchError: () => Ro, _areEquals: () => _areEquals, @@ -31121,17 +31162,17 @@ getValueByPointer: () => getValueByPointer, validate: () => validate, validator: () => validator - }); + })); var ae = {}; - __webpack_require__.r(ae), + (__webpack_require__.r(ae), __webpack_require__.d(ae, { compare: () => compare, generate: () => generate, observe: () => observe, unobserve: () => unobserve - }); + })); var le = {}; - __webpack_require__.r(le), + (__webpack_require__.r(le), __webpack_require__.d(le, { hasElementSourceMap: () => hasElementSourceMap, includesClasses: () => includesClasses, @@ -31151,17 +31192,17 @@ isRefElement: () => Uu, isSourceMapElement: () => Hu, isStringElement: () => Ru - }); + })); var ce = {}; - __webpack_require__.r(ce), + (__webpack_require__.r(ce), __webpack_require__.d(ce, { isJSONReferenceElement: () => Nf, isJSONSchemaElement: () => Tf, isLinkDescriptionElement: () => Df, isMediaElement: () => Rf - }); + })); var pe = {}; - __webpack_require__.r(pe), + (__webpack_require__.r(pe), __webpack_require__.d(pe, { isBooleanJsonSchemaElement: () => isBooleanJsonSchemaElement, isCallbackElement: () => Im, @@ -31190,9 +31231,9 @@ isServerElement: () => Zm, isServerVariableElement: () => Qm, isServersElement: () => rg - }); + })); var de = {}; - __webpack_require__.r(de), + (__webpack_require__.r(de), __webpack_require__.d(de, { isBooleanJsonSchemaElement: () => predicates_isBooleanJsonSchemaElement, isCallbackElement: () => T_, @@ -31223,17 +31264,17 @@ isSecuritySchemeElement: () => tE, isServerElement: () => rE, isServerVariableElement: () => nE - }); + })); var fe = {}; - __webpack_require__.r(fe), + (__webpack_require__.r(fe), __webpack_require__.d(fe, { cookie: () => parameter_builders_cookie, header: () => parameter_builders_header, path: () => parameter_builders_path, query: () => parameter_builders_query - }); + })); var ye = {}; - __webpack_require__.r(ye), + (__webpack_require__.r(ye), __webpack_require__.d(ye, { Button: () => Button, Col: () => Col, @@ -31244,9 +31285,9 @@ Row: () => Row, Select: () => Select, TextArea: () => TextArea - }); + })); var be = {}; - __webpack_require__.r(be), + (__webpack_require__.r(be), __webpack_require__.d(be, { basePath: () => KO, consumes: () => HO, @@ -31258,11 +31299,12 @@ schemes: () => GO, securityDefinitions: () => zO, validOperationMethods: () => wrap_selectors_validOperationMethods - }); + })); var _e = {}; - __webpack_require__.r(_e), __webpack_require__.d(_e, { definitionsToAuthorize: () => YO }); + (__webpack_require__.r(_e), + __webpack_require__.d(_e, { definitionsToAuthorize: () => YO })); var we = {}; - __webpack_require__.r(we), + (__webpack_require__.r(we), __webpack_require__.d(we, { callbacksOperations: () => QO, findSchema: () => findSchema, @@ -31270,9 +31312,9 @@ isOAS30: () => selectors_isOAS30, isSwagger2: () => selectors_isSwagger2, servers: () => ZO - }); + })); var Se = {}; - __webpack_require__.r(Se), + (__webpack_require__.r(Se), __webpack_require__.d(Se, { CLEAR_REQUEST_BODY_VALIDATE_ERROR: () => bA, CLEAR_REQUEST_BODY_VALUE: () => _A, @@ -31297,9 +31339,9 @@ setRetainRequestBodyValueFlag: () => setRetainRequestBodyValueFlag, setSelectedServer: () => setSelectedServer, setServerVariableValue: () => setServerVariableValue - }); + })); var xe = {}; - __webpack_require__.r(xe), + (__webpack_require__.r(xe), __webpack_require__.d(xe, { activeExamplesMember: () => jA, hasUserEditedBody: () => CA, @@ -31317,7 +31359,7 @@ validOperationMethods: () => DA, validateBeforeExecute: () => RA, validateShallowRequired: () => validateShallowRequired - }); + })); var Pe = __webpack_require__(96540); function formatProdErrorMessage(s) { return `Minified Redux error #${s}; visit https://redux.js.org/Errors?code=${s} for the full message or use the non-minified dev environment for full errors. `; @@ -31374,7 +31416,7 @@ function unsubscribe() { if (o) { if (j) throw new Error(formatProdErrorMessage(6)); - (o = !1), ensureCanMutateNextListeners(), x.delete(i), (w = null); + ((o = !1), ensureCanMutateNextListeners(), x.delete(i), (w = null)); } } ); @@ -31385,7 +31427,7 @@ if ('string' != typeof s.type) throw new Error(formatProdErrorMessage(17)); if (j) throw new Error(formatProdErrorMessage(9)); try { - (j = !0), (_ = u(_, s)); + ((j = !0), (_ = u(_, s))); } finally { j = !1; } @@ -31403,7 +31445,7 @@ getState, replaceReducer: function replaceReducer(s) { if ('function' != typeof s) throw new Error(formatProdErrorMessage(10)); - (u = s), dispatch({ type: Re.REPLACE }); + ((u = s), dispatch({ type: Re.REPLACE })); }, [Te]: function observable() { const s = subscribe; @@ -31568,11 +31610,11 @@ for (let _ of s.entries()) if (o[_[0]] || (u[_[0]] && u[_[0]].containsMultiple)) { if (!u[_[0]]) { - (u[_[0]] = { containsMultiple: !0, length: 1 }), + ((u[_[0]] = { containsMultiple: !0, length: 1 }), (o[`${_[0]}${i}${u[_[0]].length}`] = o[_[0]]), - delete o[_[0]]; + delete o[_[0]]); } - (u[_[0]].length += 1), (o[`${_[0]}${i}${u[_[0]].length}`] = _[1]); + ((u[_[0]].length += 1), (o[`${_[0]}${i}${u[_[0]].length}`] = _[1])); } else o[_[0]] = _[1]; return o; })(s); @@ -31602,7 +31644,7 @@ function objReduce(s, o) { return Object.keys(s).reduce((i, u) => { let _ = o(s[u], u); - return _ && 'object' == typeof _ && Object.assign(i, _), i; + return (_ && 'object' == typeof _ && Object.assign(i, _), i); }, {}); } function systemThunkMiddleware(s) { @@ -31630,7 +31672,7 @@ ae = null != s, le = ie || (ae && 'array' === B) || !(!ie && !ae), ce = x && null === s; - if (ie && !ae && !ce && !u && !B) return w.push('Required field is not provided'), w; + if (ie && !ae && !ce && !u && !B) return (w.push('Required field is not provided'), w); if (ce || !B || !le) return []; let pe = 'string' === B && s, de = 'array' === B && Array.isArray(s) && s.length, @@ -31647,16 +31689,16 @@ 'object' === B && 'object' == typeof s && null !== s, 'object' === B && 'string' == typeof s && s ].some((s) => !!s); - if (ie && !ye && !u) return w.push('Required field is not provided'), w; + if (ie && !ye && !u) return (w.push('Required field is not provided'), w); if ('object' === B && (null === _ || 'application/json' === _)) { let i = s; if ('string' == typeof s) try { i = JSON.parse(s); } catch (s) { - return w.push('Parameter string value must be valid JSON'), w; + return (w.push('Parameter string value must be valid JSON'), w); } - o && + (o && o.has('required') && isFunc(C.isList) && C.isList() && @@ -31668,7 +31710,7 @@ o.get('properties').forEach((s, o) => { const x = validateValueBySchema(i[o], s, !1, u, _); w.push(...x.map((s) => ({ propKey: o, error: s }))); - }); + })); } if (ee) { let o = ((s, o) => { @@ -31797,7 +31839,10 @@ } const utils_btoa = (s) => { let o; - return (o = s instanceof Ot ? s : Ot.from(s.toString(), 'utf-8')), o.toString('base64'); + return ( + (o = s instanceof Ot ? s : Ot.from(s.toString(), 'utf-8')), + o.toString('base64') + ); }, It = { operationsSorter: { @@ -31891,7 +31936,7 @@ }; const w = { getState: _.getState, dispatch: (s, ...o) => dispatch(s, ...o) }, x = s.map((s) => s(w)); - return (dispatch = compose(...x)(_.dispatch)), { ..._, dispatch }; + return ((dispatch = compose(...x)(_.dispatch)), { ..._, dispatch }); }; })(...u) ) @@ -31899,7 +31944,7 @@ } class Store { constructor(s = {}) { - We()( + (We()( this, { state: {}, @@ -31915,20 +31960,20 @@ return createStoreWithMiddleware(s, o, i); })(idFn, (0, qe.fromJS)(this.state), this.getSystem)), this.buildSystem(!1), - this.register(this.plugins); + this.register(this.plugins)); } getStore() { return this.store; } register(s, o = !0) { var i = combinePlugins(s, this.getSystem()); - systemExtend(this.system, i), o && this.buildSystem(); + (systemExtend(this.system, i), o && this.buildSystem()); callAfterLoad.call(this.system, s, this.getSystem()) && this.buildSystem(); } buildSystem(s = !0) { let o = this.getStore().dispatch, i = this.getStore().getState; - (this.boundSystem = Object.assign( + ((this.boundSystem = Object.assign( {}, this.getRootInjects(), this.getWrappedAndBoundActions(o), @@ -31937,7 +31982,7 @@ this.getFn(), this.getConfigs() )), - s && this.rebuildReducer(); + s && this.rebuildReducer()); } _getSystem() { return this.boundSystem; @@ -32081,7 +32126,7 @@ let _ = [u.slice(0, -9)]; return objMap(i, (i) => (...u) => { let w = wrapWithTryCatch(i).apply(null, [s().getIn(_), ...u]); - return 'function' == typeof w && (w = wrapWithTryCatch(w)(o())), w; + return ('function' == typeof w && (w = wrapWithTryCatch(w)(o())), w); }); }); } @@ -32166,7 +32211,7 @@ if (isObject(_)) for (let i in _) { let u = _[i]; - Array.isArray(u) || ((u = [u]), (_[i] = u)), + (Array.isArray(u) || ((u = [u]), (_[i] = u)), o && o.statePlugins && o.statePlugins[s] && @@ -32174,12 +32219,12 @@ o.statePlugins[s].wrapActions[i] && (o.statePlugins[s].wrapActions[i] = _[i].concat( o.statePlugins[s].wrapActions[i] - )); + ))); } if (isObject(w)) for (let i in w) { let u = w[i]; - Array.isArray(u) || ((u = [u]), (w[i] = u)), + (Array.isArray(u) || ((u = [u]), (w[i] = u)), o && o.statePlugins && o.statePlugins[s] && @@ -32187,7 +32232,7 @@ o.statePlugins[s].wrapSelectors[i] && (o.statePlugins[s].wrapSelectors[i] = w[i].concat( o.statePlugins[s].wrapSelectors[i] - )); + ))); } } return We()(s, o); @@ -32199,7 +32244,7 @@ try { return s.call(this, ...i); } catch (s) { - return o && console.error(s), null; + return (o && console.error(s), null); } }; } @@ -32222,7 +32267,7 @@ const authorizeWithPersistOption = (s) => ({ authActions: o }) => { - o.authorize(s), o.persistAuthorizationIfNeeded(); + (o.authorize(s), o.persistAuthorizationIfNeeded()); }; function logout(s) { return { type: Rt, payload: s }; @@ -32230,7 +32275,7 @@ const logoutWithPersistOption = (s) => ({ authActions: o }) => { - o.logout(s), o.persistAuthorizationIfNeeded(); + (o.logout(s), o.persistAuthorizationIfNeeded()); }, preAuthorizeImplicit = (s) => @@ -32238,7 +32283,7 @@ let { auth: u, token: _, isValid: w } = s, { schema: x, name: C } = u, j = x.get('flow'); - delete at.swaggerUIRedirectOauth2, + (delete at.swaggerUIRedirectOauth2, 'accessCode' === j || w || i.newAuthErr({ @@ -32255,7 +32300,7 @@ level: 'error', message: JSON.stringify(_) }) - : o.authorizeOauth2WithPersistOption({ auth: u, token: _ }); + : o.authorizeOauth2WithPersistOption({ auth: u, token: _ })); }; function authorizeOauth2(s) { return { type: Lt, payload: s }; @@ -32263,7 +32308,7 @@ const authorizeOauth2WithPersistOption = (s) => ({ authActions: o }) => { - o.authorizeOauth2(s), o.persistAuthorizationIfNeeded(); + (o.authorizeOauth2(s), o.persistAuthorizationIfNeeded()); }, authorizePassword = (s) => @@ -32419,8 +32464,8 @@ const i = s.response.data; try { const s = 'string' == typeof i ? JSON.parse(i) : i; - s.error && (o += `, error: ${s.error}`), - s.error_description && (o += `, description: ${s.error_description}`); + (s.error && (o += `, error: ${s.error}`), + s.error_description && (o += `, description: ${s.error_description}`)); } catch (s) {} } _.newAuthErr({ authId: V, level: 'error', source: 'auth', message: o }); @@ -32440,7 +32485,7 @@ localStorage.setItem('authorized', JSON.stringify(i)); }, authPopup = (s, o) => () => { - (at.swaggerUIRedirectOauth2 = o), at.open(s); + ((at.swaggerUIRedirectOauth2 = o), at.open(s)); }, $t = { [Tt]: (s, { payload: o }) => s.set('showDefinitions', o), @@ -32455,11 +32500,11 @@ else if ('basic' === _) { let s = i.getIn(['value', 'username']), _ = i.getIn(['value', 'password']); - (u = u.setIn([o, 'value'], { + ((u = u.setIn([o, 'value'], { username: s, header: 'Basic ' + utils_btoa(s + ':' + _) })), - (u = u.setIn([o, 'schema'], i.get('schema'))); + (u = u.setIn([o, 'schema'], i.get('schema')))); } }), s.set('authorized', u) @@ -32468,9 +32513,9 @@ [Lt]: (s, { payload: o }) => { let i, { auth: u, token: _ } = o; - (u.token = Object.assign({}, _)), (i = (0, qe.fromJS)(u)); + ((u.token = Object.assign({}, _)), (i = (0, qe.fromJS)(u))); let w = s.get('authorized') || (0, qe.Map)(); - return (w = w.set(i.get('name'), i)), s.set('authorized', w); + return ((w = w.set(i.get('name'), i)), s.set('authorized', w)); }, [Rt]: (s, { payload: o }) => { let i = s.get('authorized').withMutations((s) => { @@ -32509,7 +32554,7 @@ o ); } - Symbol(), Object.getPrototypeOf({}); + (Symbol(), Object.getPrototypeOf({})); var Vt = 'undefined' != typeof WeakRef ? WeakRef @@ -32551,11 +32596,11 @@ null != s && u(s, j) && ((j = s), 0 !== w && w--); _ = ('object' == typeof j && null !== j) || 'function' == typeof j ? new Vt(j) : j; } - return (C.s = 1), (C.v = j), j; + return ((C.s = 1), (C.v = j), j); } return ( (memoized.clearCache = () => { - (i = { s: 0, v: void 0, o: null, p: null }), memoized.resetResultsCount(); + ((i = { s: 0, v: void 0, o: null, p: null }), memoized.resetResultsCount()); }), (memoized.resultsCount = () => w), (memoized.resetResultsCount = () => { @@ -32572,11 +32617,11 @@ _ = 0, w = {}, x = s.pop(); - 'object' == typeof x && ((w = x), (x = s.pop())), + ('object' == typeof x && ((w = x), (x = s.pop())), assertIsFunction( x, `createSelector expects an output function after the inputs, but received: [${typeof x}]` - ); + )); const C = { ...i, ...w }, { memoize: j, @@ -32590,7 +32635,7 @@ Y = getDependencies(s), Z = j( function recomputationWrapper() { - return u++, x.apply(null, arguments); + return (u++, x.apply(null, arguments)); }, ...U ); @@ -32603,7 +32648,7 @@ for (let _ = 0; _ < u; _++) i.push(s[_].apply(null, o)); return i; })(Y, arguments); - return (o = Z.apply(null, s)), o; + return ((o = Z.apply(null, s)), o); }, ...z ); @@ -32625,7 +32670,8 @@ }); }; return ( - Object.assign(createSelector2, { withTypes: () => createSelector2 }), createSelector2 + Object.assign(createSelector2, { withTypes: () => createSelector2 }), + createSelector2 ); } var Ut = createSelectorCreator(weakMapMemoize), @@ -32654,7 +32700,7 @@ return ( o.entrySeq().forEach(([s, o]) => { let u = (0, qe.Map)(); - (u = u.set(s, o)), (i = i.push(u)); + ((u = u.set(s, o)), (i = i.push(u))); }), i ); @@ -32670,19 +32716,19 @@ return ( o.valueSeq().forEach((s) => { let o = (0, qe.Map)(); - s.entrySeq().forEach(([s, u]) => { + (s.entrySeq().forEach(([s, u]) => { let _, w = i.get(s); - 'oauth2' === w.get('type') && + ('oauth2' === w.get('type') && u.size && ((_ = w.get('scopes')), _.keySeq().forEach((s) => { u.contains(s) || (_ = _.delete(s)); }), (w = w.set('allowedScopes', _))), - (o = o.set(s, w)); + (o = o.set(s, w))); }), - (u = u.push(o)); + (u = u.push(o))); }), u ); @@ -32806,10 +32852,10 @@ function auth() { return { afterLoad(s) { - (this.rootInjects = this.rootInjects || {}), + ((this.rootInjects = this.rootInjects || {}), (this.rootInjects.initOAuth = s.authActions.configureAuth), (this.rootInjects.preauthorizeApiKey = preauthorizeApiKey.bind(null, s)), - (this.rootInjects.preauthorizeBasic = preauthorizeBasic.bind(null, s)); + (this.rootInjects.preauthorizeBasic = preauthorizeBasic.bind(null, s))); }, components: { LockAuthIcon: Xt, @@ -32887,20 +32933,20 @@ : u; } function YAMLException$1(s, o) { - Error.call(this), + (Error.call(this), (this.name = 'YAMLException'), (this.reason = s), (this.mark = o), (this.message = formatError(this, !1)), Error.captureStackTrace ? Error.captureStackTrace(this, this.constructor) - : (this.stack = new Error().stack || ''); + : (this.stack = new Error().stack || '')); } - (YAMLException$1.prototype = Object.create(Error.prototype)), + ((YAMLException$1.prototype = Object.create(Error.prototype)), (YAMLException$1.prototype.constructor = YAMLException$1), (YAMLException$1.prototype.toString = function toString(s) { return this.name + ': ' + formatError(this, s); - }); + })); var rr = YAMLException$1; function getLine(s, o, i, u, _) { var w = '', @@ -32917,14 +32963,14 @@ } var nr = function makeSnippet(s, o) { if (((o = Object.create(o || null)), !s.buffer)) return null; - o.maxLength || (o.maxLength = 79), + (o.maxLength || (o.maxLength = 79), 'number' != typeof o.indent && (o.indent = 1), 'number' != typeof o.linesBefore && (o.linesBefore = 3), - 'number' != typeof o.linesAfter && (o.linesAfter = 2); + 'number' != typeof o.linesAfter && (o.linesAfter = 2)); for (var i, u = /\r?\n|\r|\0/g, _ = [0], w = [], x = -1; (i = u.exec(s.buffer)); ) - w.push(i.index), + (w.push(i.index), _.push(i.index + i[0].length), - s.position <= i.index && x < 0 && (x = _.length - 2); + s.position <= i.index && x < 0 && (x = _.length - 2)); x < 0 && (x = _.length - 1); var C, j, @@ -32932,14 +32978,14 @@ B = Math.min(s.line + o.linesAfter, w.length).toString().length, $ = o.maxLength - (o.indent + B + 3); for (C = 1; C <= o.linesBefore && !(x - C < 0); C++) - (j = getLine(s.buffer, _[x - C], w[x - C], s.position - (_[x] - _[x - C]), $)), + ((j = getLine(s.buffer, _[x - C], w[x - C], s.position - (_[x] - _[x - C]), $)), (L = tr.repeat(' ', o.indent) + padStart((s.line - C + 1).toString(), B) + ' | ' + j.str + '\n' + - L); + L)); for ( j = getLine(s.buffer, _[x], w[x], s.position, $), L += @@ -32953,13 +32999,13 @@ C <= o.linesAfter && !(x + C >= w.length); C++ ) - (j = getLine(s.buffer, _[x + C], w[x + C], s.position - (_[x] - _[x + C]), $)), + ((j = getLine(s.buffer, _[x + C], w[x + C], s.position - (_[x] - _[x + C]), $)), (L += tr.repeat(' ', o.indent) + padStart((s.line + C + 1).toString(), B) + ' | ' + j.str + - '\n'); + '\n')); return L.replace(/\n$/, ''); }, sr = [ @@ -33026,10 +33072,10 @@ return ( s[o].forEach(function (s) { var o = i.length; - i.forEach(function (i, u) { + (i.forEach(function (i, u) { i.tag === s.tag && i.kind === s.kind && i.multi === s.multi && (o = u); }), - (i[o] = s); + (i[o] = s)); }), i ); @@ -33047,9 +33093,9 @@ throw new rr( 'Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })' ); - s.implicit && (o = o.concat(s.implicit)), s.explicit && (i = i.concat(s.explicit)); + (s.implicit && (o = o.concat(s.implicit)), s.explicit && (i = i.concat(s.explicit))); } - o.forEach(function (s) { + (o.forEach(function (s) { if (!(s instanceof ar)) throw new rr( 'Specified list of YAML types (or a single Type object) contains a non-Type object.' @@ -33068,7 +33114,7 @@ throw new rr( 'Specified list of YAML types (or a single Type object) contains a non-Type object.' ); - }); + })); var u = Object.create(Schema$1.prototype); return ( (u.implicit = (this.implicit || []).concat(o)), @@ -33345,7 +33391,7 @@ return '-.Inf'; } else if (tr.isNegativeZero(s)) return '-0.0'; - return (i = s.toString(10)), vr.test(i) ? i.replace('e', '.e') : i; + return ((i = s.toString(10)), vr.test(i) ? i.replace('e', '.e') : i); }, defaultStyle: 'lowercase' }), @@ -33423,10 +33469,10 @@ x = 0, C = []; for (o = 0; o < _; o++) - o % 4 == 0 && + (o % 4 == 0 && o && (C.push((x >> 16) & 255), C.push((x >> 8) & 255), C.push(255 & x)), - (x = (x << 6) | w.indexOf(u.charAt(o))); + (x = (x << 6) | w.indexOf(u.charAt(o)))); return ( 0 === (i = (_ % 4) * 6) ? (C.push((x >> 16) & 255), C.push((x >> 8) & 255), C.push(255 & x)) @@ -33447,13 +33493,13 @@ w = s.length, x = Cr; for (o = 0; o < w; o++) - o % 3 == 0 && + (o % 3 == 0 && o && ((u += x[(_ >> 18) & 63]), (u += x[(_ >> 12) & 63]), (u += x[(_ >> 6) & 63]), (u += x[63 & _])), - (_ = (_ << 8) + s[o]); + (_ = (_ << 8) + s[o])); return ( 0 === (i = w % 3) ? ((u += x[(_ >> 18) & 63]), @@ -33531,7 +33577,7 @@ w, x = s; for (w = new Array(x.length), o = 0, i = x.length; o < i; o += 1) - (u = x[o]), (_ = Object.keys(u)), (w[o] = [_[0], u[_[0]]]); + ((u = x[o]), (_ = Object.keys(u)), (w[o] = [_[0], u[_[0]]])); return w; } }), @@ -33619,9 +33665,9 @@ : String.fromCharCode(55296 + ((s - 65536) >> 10), 56320 + ((s - 65536) & 1023)); } for (var Vr = new Array(256), Ur = new Array(256), zr = 0; zr < 256; zr++) - (Vr[zr] = simpleEscapeSequence(zr) ? 1 : 0), (Ur[zr] = simpleEscapeSequence(zr)); + ((Vr[zr] = simpleEscapeSequence(zr) ? 1 : 0), (Ur[zr] = simpleEscapeSequence(zr))); function State$1(s, o) { - (this.input = s), + ((this.input = s), (this.filename = o.filename || null), (this.schema = o.schema || Rr), (this.onWarning = o.onWarning || null), @@ -33636,7 +33682,7 @@ (this.lineStart = 0), (this.lineIndent = 0), (this.firstTabInLine = -1), - (this.documents = []); + (this.documents = [])); } function generateError(s, o) { var i = { @@ -33646,7 +33692,7 @@ line: s.line, column: s.position - s.lineStart }; - return (i.snippet = nr(i)), new rr(o, i); + return ((i.snippet = nr(i)), new rr(o, i)); } function throwError(s, o) { throw generateError(s, o); @@ -33657,7 +33703,7 @@ var Wr = { YAML: function handleYamlDirective(s, o, i) { var u, _, w; - null !== s.version && throwError(s, 'duplication of %YAML directive'), + (null !== s.version && throwError(s, 'duplication of %YAML directive'), 1 !== i.length && throwError(s, 'YAML directive accepts exactly one argument'), null === (u = /^([0-9]+)\.([0-9]+)$/.exec(i[0])) && throwError(s, 'ill-formed argument of the YAML directive'), @@ -33666,11 +33712,11 @@ 1 !== _ && throwError(s, 'unacceptable YAML version of the document'), (s.version = i[0]), (s.checkLineBreaks = w < 2), - 1 !== w && 2 !== w && throwWarning(s, 'unsupported YAML version of the document'); + 1 !== w && 2 !== w && throwWarning(s, 'unsupported YAML version of the document')); }, TAG: function handleTagDirective(s, o, i) { var u, _; - 2 !== i.length && throwError(s, 'TAG directive accepts exactly two arguments'), + (2 !== i.length && throwError(s, 'TAG directive accepts exactly two arguments'), (u = i[0]), (_ = i[1]), qr.test(u) || @@ -33678,7 +33724,7 @@ Dr.call(s.tagMap, u) && throwError(s, 'there is a previously declared suffix for "' + u + '" tag handle'), $r.test(_) || - throwError(s, 'ill-formed tag prefix (second argument) of the TAG directive'); + throwError(s, 'ill-formed tag prefix (second argument) of the TAG directive')); try { _ = decodeURIComponent(_); } catch (o) { @@ -33709,16 +33755,16 @@ x < C; x += 1 ) - (w = _[x]), Dr.call(o, w) || ((o[w] = i[w]), (u[w] = !0)); + ((w = _[x]), Dr.call(o, w) || ((o[w] = i[w]), (u[w] = !0))); } function storeMappingPair(s, o, i, u, _, w, x, C, j) { var L, B; if (Array.isArray(_)) for (L = 0, B = (_ = Array.prototype.slice.call(_)).length; L < B; L += 1) - Array.isArray(_[L]) && throwError(s, 'nested arrays are not supported inside keys'), + (Array.isArray(_[L]) && throwError(s, 'nested arrays are not supported inside keys'), 'object' == typeof _ && '[object Object]' === _class(_[L]) && - (_[L] = '[object Object]'); + (_[L] = '[object Object]')); if ( ('object' == typeof _ && '[object Object]' === _class(_) && (_ = '[object Object]'), (_ = String(_)), @@ -33729,7 +33775,7 @@ for (L = 0, B = w.length; L < B; L += 1) mergeMappings(s, o, w[L], i); else mergeMappings(s, o, w, i); else - s.json || + (s.json || Dr.call(i, _) || !Dr.call(o, _) || ((s.line = x || s.line), @@ -33744,25 +33790,25 @@ value: w }) : (o[_] = w), - delete i[_]; + delete i[_]); return o; } function readLineBreak(s) { var o; - 10 === (o = s.input.charCodeAt(s.position)) + (10 === (o = s.input.charCodeAt(s.position)) ? s.position++ : 13 === o ? (s.position++, 10 === s.input.charCodeAt(s.position) && s.position++) : throwError(s, 'a line break is expected'), (s.line += 1), (s.lineStart = s.position), - (s.firstTabInLine = -1); + (s.firstTabInLine = -1)); } function skipSeparationSpace(s, o, i) { for (var u = 0, _ = s.input.charCodeAt(s.position); 0 !== _; ) { for (; is_WHITE_SPACE(_); ) - 9 === _ && -1 === s.firstTabInLine && (s.firstTabInLine = s.position), - (_ = s.input.charCodeAt(++s.position)); + (9 === _ && -1 === s.firstTabInLine && (s.firstTabInLine = s.position), + (_ = s.input.charCodeAt(++s.position))); if (o && 35 === _) do { _ = s.input.charCodeAt(++s.position); @@ -33771,12 +33817,12 @@ for ( readLineBreak(s), _ = s.input.charCodeAt(s.position), u++, s.lineIndent = 0; 32 === _; - ) - s.lineIndent++, (_ = s.input.charCodeAt(++s.position)); + (s.lineIndent++, (_ = s.input.charCodeAt(++s.position))); } return ( - -1 !== i && 0 !== u && s.lineIndent < i && throwWarning(s, 'deficient indentation'), u + -1 !== i && 0 !== u && s.lineIndent < i && throwWarning(s, 'deficient indentation'), + u ); } function testDocumentSeparator(s) { @@ -33808,10 +33854,9 @@ throwError(s, 'tab characters must not be used in indentation')), 45 === u) && is_WS_OR_EOL(s.input.charCodeAt(s.position + 1)); - ) if (((C = !0), s.position++, skipSeparationSpace(s, !0, -1) && s.lineIndent <= o)) - x.push(null), (u = s.input.charCodeAt(s.position)); + (x.push(null), (u = s.input.charCodeAt(s.position))); else if ( ((i = s.line), composeNode(s, o, 3, !1, !0), @@ -33850,16 +33895,16 @@ : throwError(s, 'unexpected end of the stream within a verbatim tag'); } else { for (; 0 !== _ && !is_WS_OR_EOL(_); ) - 33 === _ && + (33 === _ && (x ? throwError(s, 'tag suffix cannot contain exclamation marks') : ((i = s.input.slice(o - 1, s.position + 1)), qr.test(i) || throwError(s, 'named tag handle cannot contain such characters'), (x = !0), (o = s.position + 1))), - (_ = s.input.charCodeAt(++s.position)); - (u = s.input.slice(o, s.position)), - Fr.test(u) && throwError(s, 'tag suffix cannot contain flow indicator characters'); + (_ = s.input.charCodeAt(++s.position))); + ((u = s.input.slice(o, s.position)), + Fr.test(u) && throwError(s, 'tag suffix cannot contain flow indicator characters')); } u && !$r.test(u) && throwError(s, 'tag name cannot contain such characters: ' + u); try { @@ -33888,7 +33933,6 @@ i = s.input.charCodeAt(++s.position), o = s.position; 0 !== i && !is_WS_OR_EOL(i) && !is_FLOW_INDICATOR(i); - ) i = s.input.charCodeAt(++s.position); return ( @@ -33968,7 +34012,6 @@ null !== s.anchor && (s.anchorMap[s.anchor] = V), L = s.input.charCodeAt(s.position); 0 !== L; - ) { if ( (ee || @@ -33990,7 +34033,7 @@ for (L = s.input.charCodeAt(s.position); is_WHITE_SPACE(L); ) L = s.input.charCodeAt(++s.position); if (58 === L) - is_WS_OR_EOL((L = s.input.charCodeAt(++s.position))) || + (is_WS_OR_EOL((L = s.input.charCodeAt(++s.position))) || throwError( s, 'a whitespace character is expected after the key-value separator within a block mapping' @@ -34002,23 +34045,23 @@ (ee = !1), (_ = !1), (z = s.tag), - (Y = s.result); + (Y = s.result)); else { - if (!ie) return (s.tag = B), (s.anchor = $), !0; + if (!ie) return ((s.tag = B), (s.anchor = $), !0); throwError( s, 'can not read an implicit mapping pair; a colon is missed' ); } } else { - if (!ie) return (s.tag = B), (s.anchor = $), !0; + if (!ie) return ((s.tag = B), (s.anchor = $), !0); throwError( s, 'can not read a block mapping entry; a multiline key may not be an implicit key' ); } } else - 63 === L + (63 === L ? (ee && (storeMappingPair(s, V, U, z, Y, null, x, C, j), (z = Y = Z = null)), @@ -34032,7 +34075,7 @@ 'incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line' ), (s.position += 1), - (L = u); + (L = u)); if ( ((s.line === w || s.lineIndent > o) && (ee && ((x = s.line), (C = s.lineStart), (j = s.position)), @@ -34069,16 +34112,15 @@ Y = s.tag, Z = s.anchor, ee = Object.create(null); - if (91 === (U = s.input.charCodeAt(s.position))) (x = 93), (L = !1), (w = []); + if (91 === (U = s.input.charCodeAt(s.position))) ((x = 93), (L = !1), (w = [])); else { if (123 !== U) return !1; - (x = 125), (L = !0), (w = {}); + ((x = 125), (L = !0), (w = {})); } for ( null !== s.anchor && (s.anchorMap[s.anchor] = w), U = s.input.charCodeAt(++s.position); 0 !== U; - ) { if ( (skipSeparationSpace(s, !0, o), (U = s.input.charCodeAt(s.position)) === x) @@ -34091,7 +34133,7 @@ (s.result = w), !0 ); - z + (z ? 44 === U && throwError(s, "expected the node content, but found ','") : throwError(s, 'missed comma between flow collection entries'), (V = null), @@ -34122,7 +34164,7 @@ skipSeparationSpace(s, !0, o), 44 === (U = s.input.charCodeAt(s.position)) ? ((z = !0), (U = s.input.charCodeAt(++s.position))) - : (z = !1); + : (z = !1)); } throwError(s, 'unexpected end of the stream within a flow collection'); })(s, V) @@ -34174,9 +34216,8 @@ for ( readLineBreak(s), s.lineIndent = 0, w = s.input.charCodeAt(s.position); (!L || s.lineIndent < B) && 32 === w; - ) - s.lineIndent++, (w = s.input.charCodeAt(++s.position)); + (s.lineIndent++, (w = s.input.charCodeAt(++s.position))); if ((!L && s.lineIndent > B && (B = s.lineIndent), is_EOL(w))) $++; else { if (s.lineIndent < B) { @@ -34200,7 +34241,6 @@ $ = 0, i = s.position; !is_EOL(w) && 0 !== w; - ) w = s.input.charCodeAt(++s.position); captureSegment(s, i, s.position, !1); @@ -34214,7 +34254,6 @@ for ( s.kind = 'scalar', s.result = '', s.position++, u = _ = s.position; 0 !== (i = s.input.charCodeAt(s.position)); - ) if (39 === i) { if ( @@ -34222,7 +34261,7 @@ 39 !== (i = s.input.charCodeAt(++s.position))) ) return !0; - (u = s.position), s.position++, (_ = s.position); + ((u = s.position), s.position++, (_ = s.position)); } else is_EOL(i) ? (captureSegment(s, u, _, !0), @@ -34242,16 +34281,16 @@ for ( s.kind = 'scalar', s.result = '', s.position++, i = u = s.position; 0 !== (C = s.input.charCodeAt(s.position)); - ) { - if (34 === C) return captureSegment(s, i, s.position, !0), s.position++, !0; + if (34 === C) + return (captureSegment(s, i, s.position, !0), s.position++, !0); if (92 === C) { if ( (captureSegment(s, i, s.position, !0), is_EOL((C = s.input.charCodeAt(++s.position)))) ) skipSeparationSpace(s, !1, o); - else if (C < 256 && Vr[C]) (s.result += Ur[C]), s.position++; + else if (C < 256 && Vr[C]) ((s.result += Ur[C]), s.position++); else if ( (x = 120 === (j = C) ? 2 : 117 === j ? 4 : 85 === j ? 8 : 0) > 0 ) { @@ -34259,7 +34298,7 @@ (x = fromHexCode((C = s.input.charCodeAt(++s.position)))) >= 0 ? (w = (w << 4) + x) : throwError(s, 'expected hexadecimal character'); - (s.result += charFromCodepoint(w)), s.position++; + ((s.result += charFromCodepoint(w)), s.position++); } else throwError(s, 'unknown escape sequence'); i = u = s.position; } else @@ -34283,7 +34322,6 @@ for ( u = s.input.charCodeAt(++s.position), o = s.position; 0 !== u && !is_WS_OR_EOL(u) && !is_FLOW_INDICATOR(u); - ) u = s.input.charCodeAt(++s.position); return ( @@ -34336,7 +34374,6 @@ for ( s.kind = 'scalar', s.result = '', _ = w = s.position, x = !1; 0 !== B; - ) { if (58 === B) { if ( @@ -34360,23 +34397,23 @@ skipSeparationSpace(s, !1, -1), s.lineIndent >= o) ) { - (x = !0), (B = s.input.charCodeAt(s.position)); + ((x = !0), (B = s.input.charCodeAt(s.position))); continue; } - (s.position = w), + ((s.position = w), (s.line = C), (s.lineStart = j), - (s.lineIndent = L); + (s.lineIndent = L)); break; } } - x && + (x && (captureSegment(s, _, w, !1), writeFoldedLines(s, s.line - C), (_ = w = s.position), (x = !1)), is_WHITE_SPACE(B) || (w = s.position + 1), - (B = s.input.charCodeAt(++s.position)); + (B = s.input.charCodeAt(++s.position))); } return ( captureSegment(s, _, w, !1), @@ -34405,9 +34442,9 @@ j += 1 ) if (($ = s.implicitTypes[j]).resolve(s.result)) { - (s.result = $.construct(s.result)), + ((s.result = $.construct(s.result)), (s.tag = $.tag), - null !== s.anchor && (s.anchorMap[s.anchor] = s.result); + null !== s.anchor && (s.anchorMap[s.anchor] = s.result)); break; } } else if ('!' !== s.tag) { @@ -34423,7 +34460,7 @@ $ = B[j]; break; } - $ || throwError(s, 'unknown tag !<' + s.tag + '>'), + ($ || throwError(s, 'unknown tag !<' + s.tag + '>'), null !== s.result && $.kind !== s.kind && throwError( @@ -34439,10 +34476,11 @@ $.resolve(s.result, s.tag) ? ((s.result = $.construct(s.result, s.tag)), null !== s.anchor && (s.anchorMap[s.anchor] = s.result)) - : throwError(s, 'cannot resolve a node with !<' + s.tag + '> explicit tag'); + : throwError(s, 'cannot resolve a node with !<' + s.tag + '> explicit tag')); } return ( - null !== s.listener && s.listener('close', s), null !== s.tag || null !== s.anchor || Z + null !== s.listener && s.listener('close', s), + null !== s.tag || null !== s.anchor || Z ); } function readDocument(s) { @@ -34461,12 +34499,10 @@ (skipSeparationSpace(s, !0, -1), (_ = s.input.charCodeAt(s.position)), !(s.lineIndent > 0 || 37 !== _)); - ) { for ( x = !0, _ = s.input.charCodeAt(++s.position), o = s.position; 0 !== _ && !is_WS_OR_EOL(_); - ) _ = s.input.charCodeAt(++s.position); for ( @@ -34474,7 +34510,6 @@ (i = s.input.slice(o, s.position)).length < 1 && throwError(s, 'directive name must not be less than one character in length'); 0 !== _; - ) { for (; is_WHITE_SPACE(_); ) _ = s.input.charCodeAt(++s.position); if (35 === _) { @@ -34488,12 +34523,12 @@ _ = s.input.charCodeAt(++s.position); u.push(s.input.slice(o, s.position)); } - 0 !== _ && readLineBreak(s), + (0 !== _ && readLineBreak(s), Dr.call(Wr, i) ? Wr[i](s, i, u) - : throwWarning(s, 'unknown document directive "' + i + '"'); + : throwWarning(s, 'unknown document directive "' + i + '"')); } - skipSeparationSpace(s, !0, -1), + (skipSeparationSpace(s, !0, -1), 0 === s.lineIndent && 45 === s.input.charCodeAt(s.position) && 45 === s.input.charCodeAt(s.position + 1) && @@ -34510,24 +34545,23 @@ ? 46 === s.input.charCodeAt(s.position) && ((s.position += 3), skipSeparationSpace(s, !0, -1)) : s.position < s.length - 1 && - throwError(s, 'end of the stream or a document separator is expected'); + throwError(s, 'end of the stream or a document separator is expected')); } function loadDocuments(s, o) { - (o = o || {}), + ((o = o || {}), 0 !== (s = String(s)).length && (10 !== s.charCodeAt(s.length - 1) && 13 !== s.charCodeAt(s.length - 1) && (s += '\n'), - 65279 === s.charCodeAt(0) && (s = s.slice(1))); + 65279 === s.charCodeAt(0) && (s = s.slice(1)))); var i = new State$1(s, o), u = s.indexOf('\0'); for ( -1 !== u && ((i.position = u), throwError(i, 'null byte is not allowed in input')), i.input += '\0'; 32 === i.input.charCodeAt(i.position); - ) - (i.lineIndent += 1), (i.position += 1); + ((i.lineIndent += 1), (i.position += 1)); for (; i.position < i.length - 1; ) readDocument(i); return i.documents; } @@ -34587,17 +34621,17 @@ Zr = /^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/; function encodeHex(s) { var o, i, u; - if (((o = s.toString(16).toUpperCase()), s <= 255)) (i = 'x'), (u = 2); - else if (s <= 65535) (i = 'u'), (u = 4); + if (((o = s.toString(16).toUpperCase()), s <= 255)) ((i = 'x'), (u = 2)); + else if (s <= 65535) ((i = 'u'), (u = 4)); else { if (!(s <= 4294967295)) throw new rr('code point within a string may not be greater than 0xFFFFFFFF'); - (i = 'U'), (u = 8); + ((i = 'U'), (u = 8)); } return '\\' + i + tr.repeat('0', u - o.length) + o; } function State(s) { - (this.schema = s.schema || Rr), + ((this.schema = s.schema || Rr), (this.indent = Math.max(1, s.indent || 2)), (this.noArrayIndent = s.noArrayIndent || !1), (this.skipInvalid = s.skipInvalid || !1), @@ -34606,13 +34640,13 @@ var i, u, _, w, x, C, j; if (null === o) return {}; for (i = {}, _ = 0, w = (u = Object.keys(o)).length; _ < w; _ += 1) - (x = u[_]), + ((x = u[_]), (C = String(o[x])), '!!' === x.slice(0, 2) && (x = 'tag:yaml.org,2002:' + x.slice(2)), (j = s.compiledTypeMap.fallback[x]) && Jr.call(j.styleAliases, C) && (C = j.styleAliases[C]), - (i[x] = C); + (i[x] = C)); return i; })(this.schema, s.styles || null)), (this.sortKeys = s.sortKeys || !1), @@ -34628,15 +34662,15 @@ (this.tag = null), (this.result = ''), (this.duplicates = []), - (this.usedDuplicates = null); + (this.usedDuplicates = null)); } function indentString(s, o) { for (var i, u = tr.repeat(' ', o), _ = 0, w = -1, x = '', C = s.length; _ < C; ) - -1 === (w = s.indexOf('\n', _)) + (-1 === (w = s.indexOf('\n', _)) ? ((i = s.slice(_)), (_ = C)) : ((i = s.slice(_, w + 1)), (_ = w + 1)), i.length && '\n' !== i && (x += u), - (x += i); + (x += i)); return x; } function generateNextLine(s, o) { @@ -34723,14 +34757,14 @@ if (o || x) for (j = 0; j < s.length; L >= 65536 ? (j += 2) : j++) { if (!isPrintable((L = codePointAt(s, j)))) return 5; - (Y = Y && isPlainSafe(L, B, C)), (B = L); + ((Y = Y && isPlainSafe(L, B, C)), (B = L)); } else { for (j = 0; j < s.length; L >= 65536 ? (j += 2) : j++) { if (10 === (L = codePointAt(s, j))) - ($ = !0), U && ((V = V || (j - z - 1 > u && ' ' !== s[z + 1])), (z = j)); + (($ = !0), U && ((V = V || (j - z - 1 > u && ' ' !== s[z + 1])), (z = j))); else if (!isPrintable(L)) return 5; - (Y = Y && isPlainSafe(L, B, C)), (B = L); + ((Y = Y && isPlainSafe(L, B, C)), (B = L)); } V = V || (U && j - z - 1 > u && ' ' !== s[z + 1]); } @@ -34803,9 +34837,9 @@ for (; (u = _.exec(s)); ) { var j = u[1], L = u[2]; - (i = ' ' === L[0]), + ((i = ' ' === L[0]), (w += j + (x || i || '' === L ? '' : '\n') + foldLine(L, o)), - (x = i); + (x = i)); } return w; })(o, x), @@ -34818,10 +34852,10 @@ '"' + (function escapeString(s) { for (var o, i = '', u = 0, _ = 0; _ < s.length; u >= 65536 ? (_ += 2) : _++) - (u = codePointAt(s, _)), + ((u = codePointAt(s, _)), !(o = Yr[u]) && isPrintable(u) ? ((i += s[_]), u >= 65536 && (i += s[_ + 1])) - : (i += o || encodeHex(u)); + : (i += o || encodeHex(u))); return i; })(o) + '"' @@ -34842,9 +34876,9 @@ function foldLine(s, o) { if ('' === s || ' ' === s[0]) return s; for (var i, u, _ = / [^ ]/g, w = 0, x = 0, C = 0, j = ''; (i = _.exec(s)); ) - (C = i.index) - w > o && + ((C = i.index) - w > o && ((u = x > w ? x : C), (j += '\n' + s.slice(w, u)), (w = u + 1)), - (x = C); + (x = C)); return ( (j += '\n'), s.length - w > o && x > w @@ -34860,14 +34894,14 @@ C = '', j = s.tag; for (_ = 0, w = i.length; _ < w; _ += 1) - (x = i[_]), + ((x = i[_]), s.replacer && (x = s.replacer.call(i, String(_), x)), (writeNode(s, o + 1, x, !0, !0, !1, !0) || (void 0 === x && writeNode(s, o + 1, null, !0, !0, !1, !0))) && ((u && '' === C) || (C += generateNextLine(s, o)), s.dump && 10 === s.dump.charCodeAt(0) ? (C += '-') : (C += '- '), - (C += s.dump)); - (s.tag = j), (s.dump = C || '[]'); + (C += s.dump))); + ((s.tag = j), (s.dump = C || '[]')); } function detectType(s, o, i) { var u, _, w, x, C, j; @@ -34902,7 +34936,7 @@ return !1; } function writeNode(s, o, i, u, _, w, x) { - (s.tag = null), (s.dump = i), detectType(s, i, !1) || detectType(s, i, !0); + ((s.tag = null), (s.dump = i), detectType(s, i, !1) || detectType(s, i, !0)); var C, j = Hr.call(s.dump), L = u; @@ -34936,7 +34970,7 @@ else if ('function' == typeof s.sortKeys) V.sort(s.sortKeys); else if (s.sortKeys) throw new rr('sortKeys must be a boolean or a function'); for (_ = 0, w = V.length; _ < w; _ += 1) - (L = ''), + ((L = ''), (u && '' === B) || (L += generateNextLine(s, o)), (C = i[(x = V[_])]), s.replacer && (C = s.replacer.call(i, x, C)), @@ -34949,8 +34983,8 @@ j && (L += generateNextLine(s, o)), writeNode(s, o + 1, C, !0, j) && (s.dump && 10 === s.dump.charCodeAt(0) ? (L += ':') : (L += ': '), - (B += L += s.dump))); - (s.tag = $), (s.dump = B || '{}'); + (B += L += s.dump)))); + ((s.tag = $), (s.dump = B || '{}')); })(s, o, s.dump, _), $ && (s.dump = '&ref_' + B + s.dump)) : (!(function writeFlowMapping(s, o, i) { @@ -34963,7 +34997,7 @@ L = s.tag, B = Object.keys(i); for (u = 0, _ = B.length; u < _; u += 1) - (C = ''), + ((C = ''), '' !== j && (C += ', '), s.condenseFlow && (C += '"'), (x = i[(w = B[u])]), @@ -34975,8 +35009,8 @@ (s.condenseFlow ? '"' : '') + ':' + (s.condenseFlow ? '' : ' ')), - writeNode(s, o, x, !1, !1) && (j += C += s.dump)); - (s.tag = L), (s.dump = '{' + j + '}'); + writeNode(s, o, x, !1, !1) && (j += C += s.dump))); + ((s.tag = L), (s.dump = '{' + j + '}')); })(s, o, s.dump), $ && (s.dump = '&ref_' + B + ' ' + s.dump)); else if ('[object Array]' === j) @@ -34992,12 +35026,12 @@ x = '', C = s.tag; for (u = 0, _ = i.length; u < _; u += 1) - (w = i[u]), + ((w = i[u]), s.replacer && (w = s.replacer.call(i, String(u), w)), (writeNode(s, o, w, !1, !1) || (void 0 === w && writeNode(s, o, null, !1, !1))) && - ('' !== x && (x += ',' + (s.condenseFlow ? '' : ' ')), (x += s.dump)); - (s.tag = C), (s.dump = '[' + x + ']'); + ('' !== x && (x += ',' + (s.condenseFlow ? '' : ' ')), (x += s.dump))); + ((s.tag = C), (s.dump = '[' + x + ']')); })(s, o, s.dump), $ && (s.dump = '&ref_' + B + ' ' + s.dump)); else { @@ -35133,7 +35167,7 @@ try { return mn.load(s); } catch (s) { - return o && o.errActions.newThrownErr(new Error(s)), {}; + return (o && o.errActions.newThrownErr(new Error(s)), {}); } })(_.text, i) ); @@ -35179,7 +35213,7 @@ actions: { scrollToElement: (s, o) => (i) => { try { - (o = o || i.fn.getScrollParent(s)), _n().createScroller(o).to(s); + ((o = o || i.fn.getScrollParent(s)), _n().createScroller(o).to(s)); } catch (s) { console.error(s); } @@ -35196,13 +35230,13 @@ ({ layoutActions: o, layoutSelectors: i, getConfigs: u }) => { if (u().deepLinking && s) { let u = s.slice(1); - '!' === u[0] && (u = u.slice(1)), '/' === u[0] && (u = u.slice(1)); + ('!' === u[0] && (u = u.slice(1)), '/' === u[0] && (u = u.slice(1))); const _ = u.split('/').map((s) => s || ''), w = i.isShownKeyFromUrlHashArray(_), [x, C = '', j = ''] = w; if ('operations' === x) { const s = i.isShownKeyFromUrlHashArray([C]); - C.indexOf('_') > -1 && + (C.indexOf('_') > -1 && (console.warn( 'Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.' ), @@ -35210,9 +35244,9 @@ s.map((s) => s.replace(/_/g, ' ')), !0 )), - o.show(s, !0); + o.show(s, !0)); } - (C.indexOf('_') > -1 || j.indexOf('_') > -1) && + ((C.indexOf('_') > -1 || j.indexOf('_') > -1) && (console.warn( 'Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.' ), @@ -35221,7 +35255,7 @@ !0 )), o.show(w, !0), - o.scrollTo(w); + o.scrollTo(w)); } } }, @@ -35276,7 +35310,7 @@ const { operation: i } = this.props, { tag: u, operationId: _ } = i.toObject(); let { isShownKey: w } = i.toObject(); - (w = w || ['operations', u, _]), o.layoutActions.readyToScroll(w, s); + ((w = w || ['operations', u, _]), o.layoutActions.readyToScroll(w, s)); }; render() { return Pe.createElement( @@ -35368,7 +35402,7 @@ try { return i.transform(s, o).filter((s) => !!s); } catch (o) { - return console.error('Transformer error:', o), s; + return (console.error('Transformer error:', o), s); } }, s @@ -35616,10 +35650,10 @@ return { type: Ln, payload: s }; } function actions_show(s, o = !0) { - return (s = normalizeArray(s)), { type: Fn, payload: { thing: s, shown: o } }; + return ((s = normalizeArray(s)), { type: Fn, payload: { thing: s, shown: o } }); } function changeMode(s, o = '') { - return (s = normalizeArray(s)), { type: Bn, payload: { thing: s, mode: o } }; + return ((s = normalizeArray(s)), { type: Bn, payload: { thing: s, mode: o } }); } const qn = { [Dn]: (s, o) => s.set('layout', o.payload), @@ -35638,7 +35672,8 @@ current = (s) => s.get('layout'), currentFilter = (s) => s.get('filter'), isShown = (s, o, i) => ( - (o = normalizeArray(o)), s.get('shown', (0, qe.fromJS)({})).get((0, qe.fromJS)(o), i) + (o = normalizeArray(o)), + s.get('shown', (0, qe.fromJS)({})).get((0, qe.fromJS)(o), i) ), whatMode = (s, o, i = '') => ((o = normalizeArray(o)), s.getIn(['modes', ...o], i)), $n = Ut( @@ -35653,7 +35688,7 @@ j = C(), { maxDisplayedTags: L } = j; let B = x.currentFilter(); - return B && !0 !== B && (_ = w.opsFilter(_, B)), L >= 0 && (_ = _.slice(0, L)), _; + return (B && !0 !== B && (_ = w.opsFilter(_, B)), L >= 0 && (_ = _.slice(0, L)), _); }; function plugins_layout() { return { @@ -35692,7 +35727,10 @@ (s, o) => (...i) => { const u = o.getConfigs().onComplete; - return Vn && 'function' == typeof u && (setTimeout(u, 0), (Vn = !1)), s(...i); + return ( + Vn && 'function' == typeof u && (setTimeout(u, 0), (Vn = !1)), + s(...i) + ); } } } @@ -35745,31 +35783,31 @@ x && x.size) ) for (let o of s.get('headers').entries()) { - addNewLine(), addIndent(); + (addNewLine(), addIndent()); let [s, i] = o; - addWordsWithoutLeadingSpace('-H', `${s}: ${i}`), - (_ = _ || (/^content-type$/i.test(s) && /^multipart\/form-data$/i.test(i))); + (addWordsWithoutLeadingSpace('-H', `${s}: ${i}`), + (_ = _ || (/^content-type$/i.test(s) && /^multipart\/form-data$/i.test(i)))); } const j = s.get('body'); if (j) if (_ && ['POST', 'PUT', 'PATCH'].includes(s.get('method'))) for (let [s, o] of j.entrySeq()) { let i = extractKey(s); - addNewLine(), + (addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-F'), o instanceof at.File && 'string' == typeof o.valueOf() ? addWords(`${i}=${o.data}${o.type ? `;type=${o.type}` : ''}`) : o instanceof at.File ? addWords(`${i}=@${o.name}${o.type ? `;type=${o.type}` : ''}`) - : addWords(`${i}=${o}`); + : addWords(`${i}=${o}`)); } else if (j instanceof at.File) - addNewLine(), + (addNewLine(), addIndent(), - addWordsWithoutLeadingSpace(`--data-binary '@${j.name}'`); + addWordsWithoutLeadingSpace(`--data-binary '@${j.name}'`)); else { - addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-d '); + (addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-d ')); let o = j; qe.Map.isMap(o) ? addWordsWithoutLeadingSpace( @@ -36009,14 +36047,14 @@ this.props.expanded !== s.expanded && this.setState({ expanded: s.expanded }); } toggleCollapsed = () => { - this.props.onToggle && this.props.onToggle(this.props.modelName, !this.state.expanded), - this.setState({ expanded: !this.state.expanded }); + (this.props.onToggle && this.props.onToggle(this.props.modelName, !this.state.expanded), + this.setState({ expanded: !this.state.expanded })); }; onLoad = (s) => { if (s && this.props.layoutSelectors) { const o = this.props.layoutSelectors.getScrollToKey(); - $e().is(o, this.props.specPath) && this.toggleCollapsed(), - this.props.layoutActions.readyToScroll(this.props.specPath, s.parentElement); + ($e().is(o, this.props.specPath) && this.toggleCollapsed(), + this.props.layoutActions.readyToScroll(this.props.specPath, s.parentElement)); } }; render() { @@ -36225,10 +36263,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _defineProperty(s, o, i) { @@ -36248,11 +36286,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -36271,7 +36309,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -36360,13 +36398,13 @@ (function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(ImmutablePureComponent, s), (function _createClass(s, o, i) { - return o && _defineProperties(s.prototype, o), i && _defineProperties(s, i), s; + return (o && _defineProperties(s.prototype, o), i && _defineProperties(s, i), s); })(ImmutablePureComponent, [ { key: 'shouldComponentUpdate', @@ -36560,8 +36598,8 @@ getCollapsedContent = () => ' '; handleToggle = (s, o) => { const { layoutActions: i } = this.props; - i.show([...this.getSchemaBasePath(), s], o), - o && this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), s]); + (i.show([...this.getSchemaBasePath(), s], o), + o && this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), s])); }; onLoadModels = (s) => { s && this.props.layoutActions.readyToScroll(this.getSchemaBasePath(), s); @@ -37363,12 +37401,12 @@ class JsonSchema_array extends Pe.PureComponent { static defaultProps = os; constructor(s, o) { - super(s, o), (this.state = { value: valueOrEmptyList(s.value), schema: s.schema }); + (super(s, o), (this.state = { value: valueOrEmptyList(s.value), schema: s.schema })); } UNSAFE_componentWillReceiveProps(s) { const o = valueOrEmptyList(s.value); - o !== this.state.value && this.setState({ value: o }), - s.schema !== this.state.schema && this.setState({ schema: s.schema }); + (o !== this.state.value && this.setState({ value: o }), + s.schema !== this.state.schema && this.setState({ schema: s.schema })); } onChange = () => { this.props.onChange(this.state.value); @@ -37670,7 +37708,7 @@ const { Cache: i } = ut(); ut().Cache = Cache; const u = ut()(s, o); - return (ut().Cache = i), u; + return ((ut().Cache = i), u); }, ds = { string: (s) => @@ -37914,9 +37952,9 @@ (s && ce[o] && ce[o].xml && ce[o].xml.attribute ? (C[ce[o].xml.name || o] = _[o]) : pe(o, _[o]))); - return hs()(C) || le[Z].push({ _attr: C }), le; + return (hs()(C) || le[Z].push({ _attr: C }), le); } - return (le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le; + return ((le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le); } if ('object' === L) { for (let s in ce) @@ -37927,10 +37965,10 @@ pe(s)); if ((u && C && le[Z].push({ _attr: C }), hasExceededMaxProperties())) return le; if (!0 === V) - u + (u ? le[Z].push({ additionalProp: 'Anything can be here' }) : (le.additionalProp1 = {}), - de++; + de++); else if (V) { const i = objectify(V), _ = sampleFromSchemaGeneric(i, o, void 0, u); @@ -37944,7 +37982,7 @@ if (hasExceededMaxProperties()) return le; if (u) { const o = {}; - (o['additionalProp' + s] = _.notagname), le[Z].push(o); + ((o['additionalProp' + s] = _.notagname), le[Z].push(o)); } else le['additionalProp' + s] = _; de++; } @@ -38019,10 +38057,10 @@ x = w.getJsonSampleSchema(o, i, u, _); let C; try { - (C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), - '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1)); + ((C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), + '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1))); } catch (s) { - return console.error(s), 'error: could not generate yaml example'; + return (console.error(s), 'error: could not generate yaml example'); } return C.replace(/\t/g, ' '); }, @@ -38444,7 +38482,7 @@ let { specStr: _ } = i, w = null; try { - (s = s || _()), u.clear({ source: 'parser' }), (w = mn.load(s, { schema: nn })); + ((s = s || _()), u.clear({ source: 'parser' }), (w = mn.load(s, { schema: nn }))); } catch (s) { return ( console.error(s), @@ -38479,7 +38517,7 @@ requestInterceptor: $, responseInterceptor: V } = j(); - void 0 === s && (s = u.specJson()), void 0 === o && (o = u.url()); + (void 0 === s && (s = u.specJson()), void 0 === o && (o = u.url())); let U = C.getLineNumberForPath ? C.getLineNumberForPath : () => {}, z = u.specStr(); return x({ @@ -38515,7 +38553,7 @@ (s, { path: o, system: i }) => (s.has(i) || s.set(i, []), s.get(i).push(o), s), new Map() ); - (jo = []), + ((jo = []), s.forEach(async (s, o) => { if (!o) return void console.error( @@ -38615,7 +38653,7 @@ } catch (s) { console.error(s); } - }); + })); }, 35), requestResolvedSubtree = (s) => (o) => { jo.find(({ path: i, system: u }) => u === o && i.toString() === s.toString()) || @@ -38679,9 +38717,9 @@ s.server = w.selectedServer(o) || w.selectedServer(); const i = w.serverVariables({ server: s.server, namespace: o }).toJS(), u = w.serverVariables({ server: s.server }).toJS(); - (s.serverVariables = Object.keys(i).length ? i : u), + ((s.serverVariables = Object.keys(i).length ? i : u), (s.requestContentType = w.requestContentType(x, C)), - (s.responseContentType = w.responseContentType(x, C) || '*/*'); + (s.responseContentType = w.responseContentType(x, C) || '*/*')); const _ = w.requestBodyValue(x, C), j = w.requestBodyInclusionSetting(x, C); _ && _.toJS @@ -38693,25 +38731,25 @@ : (s.requestBody = _); } let V = Object.assign({}, s); - (V = o.buildRequest(V)), i.setRequest(s.pathName, s.method, V); - (s.requestInterceptor = async (o) => { + ((V = o.buildRequest(V)), i.setRequest(s.pathName, s.method, V)); + ((s.requestInterceptor = async (o) => { let u = await L.apply(void 0, [o]), _ = Object.assign({}, u); - return i.setMutatedRequest(s.pathName, s.method, _), u; + return (i.setMutatedRequest(s.pathName, s.method, _), u); }), - (s.responseInterceptor = B); + (s.responseInterceptor = B)); const U = Date.now(); return o .execute(s) .then((o) => { - (o.duration = Date.now() - U), i.setResponse(s.pathName, s.method, o); + ((o.duration = Date.now() - U), i.setResponse(s.pathName, s.method, o)); }) .catch((o) => { - 'Failed to fetch' === o.message && + ('Failed to fetch' === o.message && ((o.name = ''), (o.message = '**Failed to fetch.** \n**Possible Reasons:** \n - CORS \n - Network Failure \n - URL scheme must be "http" or "https" for CORS request.')), - i.setResponse(s.pathName, s.method, { error: !0, err: o }); + i.setResponse(s.pathName, s.method, { error: !0, err: o })); }); }, actions_execute = @@ -38801,7 +38839,7 @@ ), [yo]: (s, { payload: { res: o, path: i, method: u } }) => { let _; - (_ = o.error + ((_ = o.error ? Object.assign( { error: !0, @@ -38812,7 +38850,7 @@ o.err.response ) : o), - (_.headers = _.headers || {}); + (_.headers = _.headers || {})); let w = s.setIn(['responses', i, u], fromJSOrdered(_)); return ( at.Blob && @@ -38846,18 +38884,18 @@ wrap_actions_updateSpec = (s, { specActions: o }) => (...i) => { - s(...i), o.parseToJson(...i); + (s(...i), o.parseToJson(...i)); }, wrap_actions_updateJsonSpec = (s, { specActions: o }) => (...i) => { - s(...i), o.invalidateResolvedSubtreeCache(); + (s(...i), o.invalidateResolvedSubtreeCache()); const [u] = i, _ = jn()(u, ['paths']) || {}; - Object.keys(_).forEach((s) => { + (Object.keys(_).forEach((s) => { jn()(_, [s]).$ref && o.requestResolvedSubtree(['paths', s]); }), - o.requestResolvedSubtree(['components', 'securitySchemes']); + o.requestResolvedSubtree(['components', 'securitySchemes'])); }, wrap_actions_executeRequest = (s, { specActions: o }) => @@ -38895,9 +38933,9 @@ function __() { this.constructor = s; } - extendStatics(s, o), + (extendStatics(s, o), (s.prototype = - null === o ? Object.create(o) : ((__.prototype = o.prototype), new __())); + null === o ? Object.create(o) : ((__.prototype = o.prototype), new __()))); }; })(), To = Object.prototype.hasOwnProperty; @@ -38980,21 +39018,21 @@ C ); } - return Mo(PatchError, s), PatchError; + return (Mo(PatchError, s), PatchError); })(Error), Ro = No, Do = _deepClone, Lo = { add: function (s, o, i) { - return (s[o] = this.value), { newDocument: i }; + return ((s[o] = this.value), { newDocument: i }); }, remove: function (s, o, i) { var u = s[o]; - return delete s[o], { newDocument: i, removed: u }; + return (delete s[o], { newDocument: i, removed: u }); }, replace: function (s, o, i) { var u = s[o]; - return (s[o] = this.value), { newDocument: i, removed: u }; + return ((s[o] = this.value), { newDocument: i, removed: u }); }, move: function (s, o, i) { var u = getValueByPointer(i, this.path); @@ -39016,7 +39054,7 @@ return { newDocument: i, test: _areEquals(s[o], this.value) }; }, _get: function (s, o, i) { - return (this.value = s[o]), { newDocument: i }; + return ((this.value = s[o]), { newDocument: i }); } }, Bo = { @@ -39031,7 +39069,7 @@ }, replace: function (s, o, i) { var u = s[o]; - return (s[o] = this.value), { newDocument: i, removed: u }; + return ((s[o] = this.value), { newDocument: i, removed: u }); }, move: Lo.move, copy: Lo.copy, @@ -39041,7 +39079,7 @@ function getValueByPointer(s, o) { if ('' == o) return s; var i = { op: '_get', path: o }; - return applyOperation(s, i), i.value; + return (applyOperation(s, i), i.value); } function applyOperation(s, o, i, u, _, w) { if ( @@ -39053,8 +39091,8 @@ '' === o.path) ) { var x = { newDocument: s }; - if ('add' === o.op) return (x.newDocument = o.value), x; - if ('replace' === o.op) return (x.newDocument = o.value), (x.removed = s), x; + if ('add' === o.op) return ((x.newDocument = o.value), x); + if ('replace' === o.op) return ((x.newDocument = o.value), (x.removed = s), x); if ('move' === o.op || 'copy' === o.op) return ( (x.newDocument = getValueByPointer(s, o.from)), @@ -39064,10 +39102,10 @@ if ('test' === o.op) { if (((x.test = _areEquals(s, o.value)), !1 === x.test)) throw new Ro('Test operation failed', 'TEST_OPERATION_FAILED', w, o, s); - return (x.newDocument = s), x; + return ((x.newDocument = s), x); } - if ('remove' === o.op) return (x.removed = s), (x.newDocument = null), x; - if ('_get' === o.op) return (o.value = s), x; + if ('remove' === o.op) return ((x.removed = s), (x.newDocument = null), x); + if ('_get' === o.op) return ((o.value = s), x); if (i) throw new Ro( 'Operation `op` property is not one of operations defined in RFC-6902', @@ -39147,8 +39185,8 @@ throw new Ro('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY'); u || (s = _deepClone(s)); for (var w = new Array(o.length), x = 0, C = o.length; x < C; x++) - (w[x] = applyOperation(s, o[x], i, !0, _, x)), (s = w[x].newDocument); - return (w.newDocument = s), w; + ((w[x] = applyOperation(s, o[x], i, !0, _, x)), (s = w[x].newDocument)); + return ((w.newDocument = s), w); } function applyReducer(s, o, i) { var u = applyOperation(s, o); @@ -39278,10 +39316,10 @@ } var Fo = new WeakMap(), qo = function qo(s) { - (this.observers = new Map()), (this.obj = s); + ((this.observers = new Map()), (this.obj = s)); }, $o = function $o(s, o) { - (this.callback = s), (this.observer = o); + ((this.callback = s), (this.observer = o)); }; function unobserve(s, o) { o.unobserve(); @@ -39296,15 +39334,15 @@ return s.observers.get(o); })(u, o); i = _ && _.observer; - } else (u = new qo(s)), Fo.set(s, u); + } else ((u = new qo(s)), Fo.set(s, u)); if (i) return i; if (((i = {}), (u.value = _deepClone(s)), o)) { - (i.callback = o), (i.next = null); + ((i.callback = o), (i.next = null)); var dirtyCheck = function () { generate(i); }, fastCheck = function () { - clearTimeout(i.next), (i.next = setTimeout(dirtyCheck)); + (clearTimeout(i.next), (i.next = setTimeout(dirtyCheck))); }; 'undefined' != typeof window && (window.addEventListener('mouseup', fastCheck), @@ -39317,7 +39355,7 @@ (i.patches = []), (i.object = s), (i.unobserve = function () { - generate(i), + (generate(i), clearTimeout(i.next), (function removeObserverFromMirror(s, o) { s.observers.delete(o.callback); @@ -39327,7 +39365,7 @@ window.removeEventListener('keyup', fastCheck), window.removeEventListener('mousedown', fastCheck), window.removeEventListener('keydown', fastCheck), - window.removeEventListener('change', fastCheck)); + window.removeEventListener('change', fastCheck))); }), u.observers.set(o, new $o(o, i)), i @@ -39336,10 +39374,10 @@ function generate(s, o) { void 0 === o && (o = !1); var i = Fo.get(s.object); - _generate(i.value, s.object, s.patches, '', o), - s.patches.length && applyPatch(i.value, s.patches); + (_generate(i.value, s.object, s.patches, '', o), + s.patches.length && applyPatch(i.value, s.patches)); var u = s.patches; - return u.length > 0 && ((s.patches = []), s.callback && s.callback(u)), u; + return (u.length > 0 && ((s.patches = []), s.callback && s.callback(u)), u); } function _generate(s, o, i, u, _) { if (o !== s) { @@ -39404,7 +39442,7 @@ function compare(s, o, i) { void 0 === i && (i = !1); var u = []; - return _generate(s, o, u, '', i), u; + return (_generate(s, o, u, '', i), u); } Object.assign({}, ie, ae, { JsonPatchError: No, @@ -39440,7 +39478,7 @@ 'merge' === (o = { ...o, path: o.path && normalizeJSONPath(o.path) }).op) ) { const i = getInByJsonPath(s, o.path); - Object.assign(i, o.value), applyPatch(s, [replace(o.path, i)]); + (Object.assign(i, o.value), applyPatch(s, [replace(o.path, i)])); } else if ('mergeDeep' === o.op) { const i = getInByJsonPath(s, o.path), u = Uo()(i, o.value); @@ -39450,19 +39488,20 @@ s, Object.keys(o.value).reduce( (s, i) => ( - s.push({ op: 'add', path: `/${normalizeJSONPath(i)}`, value: o.value[i] }), s + s.push({ op: 'add', path: `/${normalizeJSONPath(i)}`, value: o.value[i] }), + s ), [] ) ); } else if ('replace' === o.op && '' === o.path) { let { value: u } = o; - i.allowMetaPatches && + (i.allowMetaPatches && o.meta && isAdditiveMutation(o) && (Array.isArray(o.value) || lib_isObject(o.value)) && (u = { ...u, ...o.meta }), - (s = u); + (s = u)); } else if ( (applyPatch(s, [o]), i.allowMetaPatches && @@ -39556,7 +39595,7 @@ const _ = Object.keys(s).map((u) => forEach(s[u], o, i.concat(u))); _ && (u = u.concat(_)); } - return (u = flatten(u)), u; + return ((u = flatten(u)), u); } function lib_normalizeArray(s) { return Array.isArray(s) ? s : [s]; @@ -39596,7 +39635,7 @@ try { return getValueByPointer(s, o); } catch (s) { - return console.error(s), {}; + return (console.error(s), {}); } } var Wo = __webpack_require__(48675); @@ -39612,10 +39651,10 @@ null != i && 'object' == typeof i && Object.hasOwn(i, 'cause') && !('cause' in this)) ) { const { cause: s } = i; - (this.cause = s), + ((this.cause = s), s instanceof Error && 'stack' in s && - (this.stack = `${this.stack}\nCAUSE: ${s.stack}`); + (this.stack = `${this.stack}\nCAUSE: ${s.stack}`)); } } }; @@ -39636,10 +39675,10 @@ null != o && 'object' == typeof o && Object.hasOwn(o, 'cause') && !('cause' in this)) ) { const { cause: s } = o; - (this.cause = s), + ((this.cause = s), s instanceof Error && 'stack' in s && - (this.stack = `${this.stack}\nCAUSE: ${s.stack}`); + (this.stack = `${this.stack}\nCAUSE: ${s.stack}`)); } } } @@ -39772,11 +39811,11 @@ s.flags ? s.flags : (s.global ? 'g' : '') + - (s.ignoreCase ? 'i' : '') + - (s.multiline ? 'm' : '') + - (s.sticky ? 'y' : '') + - (s.unicode ? 'u' : '') + - (s.dotAll ? 's' : '') + (s.ignoreCase ? 'i' : '') + + (s.multiline ? 'm' : '') + + (s.sticky ? 'y' : '') + + (s.unicode ? 'u' : '') + + (s.dotAll ? 's' : '') ); } function _arrayFromIterator(s) { @@ -39840,7 +39879,7 @@ for (o in s) !_has(o, s) || (_ && 'length' === o) || (u[u.length] = o); if (Ei) for (i = Oi.length - 1; i >= 0; ) - _has((o = Oi[i]), s) && !Mi(u, o) && (u[u.length] = o), (i -= 1); + (_has((o = Oi[i]), s) && !Mi(u, o) && (u[u.length] = o), (i -= 1)); return u; }) : _curry1(function keys(s) { @@ -40014,7 +40053,7 @@ ); } function _map(s, o) { - for (var i = 0, u = o.length, _ = Array(u); i < u; ) (_[i] = s(o[i])), (i += 1); + for (var i = 0, u = o.length, _ = Array(u); i < u; ) ((_[i] = s(o[i])), (i += 1)); return _; } function _quote(s) { @@ -40065,7 +40104,7 @@ }; } function _arrayReduce(s, o, i) { - for (var u = 0, _ = i.length; u < _; ) (o = s(o, i[u])), (u += 1); + for (var u = 0, _ = i.length; u < _; ) ((o = s(o, i[u])), (u += 1)); return o; } const aa = @@ -40106,7 +40145,7 @@ }; var la = (function () { function XFilter(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XFilter.prototype['@@transducer/init'] = _xfBase_init), @@ -40127,14 +40166,14 @@ return _isObject(o) ? _arrayReduce( function (i, u) { - return s(o[u]) && (i[u] = o[u]), i; + return (s(o[u]) && (i[u] = o[u]), i); }, {}, Wi(o) ) : (function _filter(s, o) { for (var i = 0, u = o.length, _ = []; i < u; ) - s(o[i]) && (_[_.length] = o[i]), (i += 1); + (s(o[i]) && (_[_.length] = o[i]), (i += 1)); return _; })(s, o); }) @@ -40386,12 +40425,12 @@ return function () { for (var u = [], _ = 0, w = s, x = 0, C = !1; x < o.length || _ < arguments.length; ) { var j; - x < o.length && (!_isPlaceholder(o[x]) || _ >= arguments.length) + (x < o.length && (!_isPlaceholder(o[x]) || _ >= arguments.length) ? (j = o[x]) : ((j = arguments[_]), (_ += 1)), (u[x] = j), _isPlaceholder(j) ? (C = !0) : (w -= 1), - (x += 1); + (x += 1)); } return !C && w <= 0 ? i.apply(this, u) : _arity(Math.max(0, w), _curryN(s, u, i)); }; @@ -40428,12 +40467,12 @@ } var sl = (function () { function XDropLastWhile(s, o) { - (this.f = s), (this.retained = []), (this.xf = o); + ((this.f = s), (this.retained = []), (this.xf = o)); } return ( (XDropLastWhile.prototype['@@transducer/init'] = _xfBase_init), (XDropLastWhile.prototype['@@transducer/result'] = function (s) { - return (this.retained = null), this.xf['@@transducer/result'](s); + return ((this.retained = null), this.xf['@@transducer/result'](s)); }), (XDropLastWhile.prototype['@@transducer/step'] = function (s, o) { return this.f(o) ? this.retain(s, o) : this.flush(s, o); @@ -40446,7 +40485,7 @@ ); }), (XDropLastWhile.prototype.retain = function (s, o) { - return this.retained.push(o), s; + return (this.retained.push(o), s); }), XDropLastWhile ); @@ -40461,7 +40500,7 @@ var vl = _curry1(function flip(s) { return za(s.length, function (o, i) { var u = Array.prototype.slice.call(arguments, 0); - return (u[0] = i), (u[1] = o), s.apply(this, u); + return ((u[0] = i), (u[1] = o), s.apply(this, u)); }); }); const _l = vl(_curry2(_includes)); @@ -40469,7 +40508,7 @@ return pipe(tl(''), ul(_l(s)), yl(''))(o); }); function _iterableReduce(s, o, i) { - for (var u = i.next(); !u.done; ) (o = s(o, u.value)), (u = i.next()); + for (var u = i.next(); !u.done; ) ((o = s(o, u.value)), (u = i.next())); return o; } function _methodReduce(s, o, i, u) { @@ -40478,7 +40517,7 @@ const wl = _createReduce(_arrayReduce, _methodReduce, _iterableReduce); var Sl = (function () { function XMap(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XMap.prototype['@@transducer/init'] = _xfBase_init), @@ -40506,7 +40545,7 @@ case '[object Object]': return _arrayReduce( function (i, u) { - return (i[u] = s(o[u])), i; + return ((i[u] = s(o[u])), i); }, {}, Wi(o) @@ -40535,8 +40574,8 @@ var u = (s = s || []).length, _ = o.length, w = []; - for (i = 0; i < u; ) (w[w.length] = s[i]), (i += 1); - for (i = 0; i < _; ) (w[w.length] = o[i]), (i += 1); + for (i = 0; i < u; ) ((w[w.length] = s[i]), (i += 1)); + for (i = 0; i < _; ) ((w[w.length] = o[i]), (i += 1)); return w; })(s, kl(i, o)); }, @@ -40625,7 +40664,7 @@ throw TypeError('`'.concat(o, '` must be a string')); }; const Ql = function replaceAll(s, o, i) { - !(function checkArguments(s, o, i) { + (!(function checkArguments(s, o, i) { if (null == i || null == s || null == o) throw TypeError('Input values must not be `null` or `undefined`'); })(s, o, i), @@ -40634,7 +40673,7 @@ (function checkSearchValue(s) { if (!('string' == typeof s || s instanceof String || s instanceof RegExp)) throw TypeError('`searchValue` must be a string or an regexp'); - })(s); + })(s)); var u = new RegExp(Jl(s) ? s : Xl(s), 'g'); return Hl(u, o, i); }; @@ -40687,7 +40726,7 @@ stripHash = (s) => { const o = s.indexOf('#'); let i = s; - return o >= 0 && (i = s.substring(0, o)), i; + return (o >= 0 && (i = s.substring(0, o)), i); }, url_cwd = () => { if (Go.browser) return stripHash(globalThis.location.href); @@ -40708,7 +40747,7 @@ return ((s) => { const o = [/\?/g, '%3F', /#/g, '%23']; let i = s; - isWindows() && (i = i.replace(/\\/g, '/')), (i = encodeURI(i)); + (isWindows() && (i = i.replace(/\\/g, '/')), (i = encodeURI(i))); for (let s = 0; s < o.length; s += 2) i = i.replace(o[s], o[s + 1]); return i; })(toFileSystemPath(s)); @@ -40736,10 +40775,10 @@ function legacy_defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _instanceof(s, o) { @@ -40770,7 +40809,7 @@ x = !0 ); } catch (s) { - (C = !0), (_ = s); + ((C = !0), (_ = s)); } finally { try { x || null == i.return || i.return(); @@ -40802,13 +40841,13 @@ ? 'symbol' : typeof s; } - void 0 === globalThis.fetch && (globalThis.fetch = ic), + (void 0 === globalThis.fetch && (globalThis.fetch = ic), void 0 === globalThis.Headers && (globalThis.Headers = lc), void 0 === globalThis.Request && (globalThis.Request = cc), void 0 === globalThis.Response && (globalThis.Response = ac), void 0 === globalThis.FormData && (globalThis.FormData = pc), void 0 === globalThis.File && (globalThis.File = hc), - void 0 === globalThis.Blob && (globalThis.Blob = dc); + void 0 === globalThis.Blob && (globalThis.Blob = dc)); var __typeError = function (s) { throw TypeError(s); }, @@ -40816,7 +40855,7 @@ return o.has(s) || __typeError('Cannot ' + i); }, __privateGet = function (s, o, i) { - return __accessCheck(s, o, 'read from private field'), i ? i.call(s) : o.get(s); + return (__accessCheck(s, o, 'read from private field'), i ? i.call(s) : o.get(s)); }, __privateAdd = function (s, o, i) { return o.has(s) @@ -40826,7 +40865,11 @@ : o.set(s, i); }, __privateSet = function (s, o, i, u) { - return __accessCheck(s, o, 'write to private field'), u ? u.call(s, i) : o.set(s, i), i; + return ( + __accessCheck(s, o, 'write to private field'), + u ? u.call(s, i) : o.set(s, i), + i + ); }, to_string = function (s) { return Object.prototype.toString.call(s); @@ -40891,7 +40934,7 @@ i[L] = s[L]; } } catch (s) { - (w = !0), (x = s); + ((w = !0), (x = s)); } finally { try { _ || null == j.return || j.return(); @@ -40934,14 +40977,14 @@ isLast: !1, update: function update(s) { var o = arguments.length > 1 && void 0 !== arguments[1] && arguments[1]; - $.isRoot || ($.parent.node[$.key] = s), ($.node = s), o && (B = !1); + ($.isRoot || ($.parent.node[$.key] = s), ($.node = s), o && (B = !1)); }, delete: function _delete(s) { - delete $.parent.node[$.key], s && (B = !1); + (delete $.parent.node[$.key], s && (B = !1)); }, remove: function remove(s) { - fc($.parent.node) ? $.parent.node.splice($.key, 1) : delete $.parent.node[$.key], - s && (B = !1); + (fc($.parent.node) ? $.parent.node.splice($.key, 1) : delete $.parent.node[$.key], + s && (B = !1)); }, keys: null, before: function before(s) { @@ -40966,15 +41009,15 @@ if (!w) return $; function update_state() { if ('object' === _type_of($.node) && null !== $.node) { - ($.keys && $.node_ === $.node) || ($.keys = x($.node)), - ($.isLeaf = 0 === $.keys.length); + (($.keys && $.node_ === $.node) || ($.keys = x($.node)), + ($.isLeaf = 0 === $.keys.length)); for (var o = 0; o < _.length; o++) if (_[o].node_ === s) { $.circular = _[o]; break; } - } else ($.isLeaf = !0), ($.keys = null); - ($.notLeaf = !$.isLeaf), ($.notRoot = !$.isRoot); + } else (($.isLeaf = !0), ($.keys = null)); + (($.notLeaf = !$.isLeaf), ($.notRoot = !$.isRoot)); } update_state(); var V = o.call($, $.node); @@ -40982,7 +41025,7 @@ return $; if ('object' === _type_of($.node) && null !== $.node && !$.circular) { var U; - _.push($), update_state(); + (_.push($), update_state()); var z = !0, Y = !1, Z = void 0; @@ -40999,18 +41042,18 @@ le = _sliced_to_array(ee.value, 2), ce = le[0], pe = le[1]; - u.push(pe), L.pre && L.pre.call($, $.node[pe], pe); + (u.push(pe), L.pre && L.pre.call($, $.node[pe], pe)); var de = walker($.node[pe]); - C && Ec.call($.node, pe) && !is_writable($.node, pe) && ($.node[pe] = de.node), + (C && Ec.call($.node, pe) && !is_writable($.node, pe) && ($.node[pe] = de.node), (de.isLast = !!(null === (ae = $.keys) || void 0 === ae ? void 0 : ae.length) && +ce == $.keys.length - 1), (de.isFirst = 0 == +ce), L.post && L.post.call($, de), - u.pop(); + u.pop()); } } catch (s) { - (Y = !0), (Z = s); + ((Y = !0), (Z = s)); } finally { try { z || null == ie.return || ie.return(); @@ -41020,24 +41063,26 @@ } _.pop(); } - return L.after && L.after.call($, $.node), $; + return (L.after && L.after.call($, $.node), $); })(s).node; } var Ic = (function () { function Traverse(s) { var o = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : jc; - !(function _class_call_check(s, o) { + (!(function _class_call_check(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, Traverse), __privateAdd(this, kc), __privateAdd(this, Oc), __privateSet(this, kc, s), - __privateSet(this, Oc, o); + __privateSet(this, Oc, o)); } return ( (function _create_class(s, o, i) { return ( - o && legacy_defineProperties(s.prototype, o), i && legacy_defineProperties(s, i), s + o && legacy_defineProperties(s.prototype, o), + i && legacy_defineProperties(s, i), + s ); })(Traverse, [ { @@ -41079,9 +41124,9 @@ u = 0; for (u = 0; u < s.length - 1; u++) { var _ = s[u]; - Ec.call(i, _) || (i[_] = {}), (i = i[_]); + (Ec.call(i, _) || (i[_] = {}), (i = i[_])); } - return (i[s[u]] = o), o; + return ((i[s[u]] = o), o); } }, { @@ -41151,7 +41196,7 @@ for (var _ = 0; _ < s.length; _++) if (s[_] === u) return o[_]; if ('object' === (void 0 === u ? 'undefined' : _type_of(u)) && null !== u) { var w = legacy_copy(u, i); - s.push(u), o.push(w); + (s.push(u), o.push(w)); var x = i.includeSymbols ? own_enumerable_keys : Object.keys, C = !0, j = !1, @@ -41166,7 +41211,7 @@ w[V] = clone(u[V]); } } catch (s) { - (j = !0), (L = s); + ((j = !0), (L = s)); } finally { try { C || null == $.return || $.return(); @@ -41174,7 +41219,7 @@ if (j) throw L; } } - return s.pop(), o.pop(), w; + return (s.pop(), o.pop(), w); } return u; })(__privateGet(this, kc)); @@ -41184,11 +41229,11 @@ Traverse ); })(); - (kc = new WeakMap()), (Oc = new WeakMap()); + ((kc = new WeakMap()), (Oc = new WeakMap())); var traverse = function (s, o) { return new Ic(s, o); }; - (traverse.get = function (s, o, i) { + ((traverse.get = function (s, o, i) { return new Ic(s, i).get(o); }), (traverse.set = function (s, o, i, u) { @@ -41214,7 +41259,7 @@ }), (traverse.clone = function (s, o) { return new Ic(s, o).clone(); - }); + })); var Pc = traverse; const Mc = 'application/json, application/yaml', Nc = 'https://swagger.io', @@ -41381,7 +41426,8 @@ !(function patchValueAlreadyInPath(s, o) { const i = [s]; return ( - o.path.reduce((s, o) => (i.push(s[o]), s[o]), s), pointToAncestor(o.value) + o.path.reduce((s, o) => (i.push(s[o]), s[o]), s), + pointToAncestor(o.value) ); function pointToAncestor(s) { return ( @@ -41506,7 +41552,7 @@ if (isFreelyNamed(w)) return; if (!Array.isArray(s)) { const s = new TypeError('allOf must be an array'); - return (s.fullPath = i), s; + return ((s.fullPath = i), s); } let x = !1, C = _.value; @@ -41527,7 +41573,7 @@ if (x) return null; x = !0; const s = new TypeError('Elements in allOf must be objects'); - return (s.fullPath = i), j.push(s); + return ((s.fullPath = i), j.push(s)); } j.push(u.mergeDeep(w, s)); const _ = (function generateAbsoluteRefPatches( @@ -41577,7 +41623,7 @@ o[_].default = u.parameterMacro(w, x); } catch (s) { const o = new Error(s); - return (o.fullPath = i), o; + return ((o.fullPath = i), o); } } return zo.replace(i, o); @@ -41594,7 +41640,7 @@ _[o].default = u.modelPropertyMacro(_[o]); } catch (s) { const o = new Error(s); - return (o.fullPath = i), o; + return ((o.fullPath = i), o); } return zo.replace(i, _); } @@ -41626,7 +41672,7 @@ : s.slice(0, -1).reduce((s, i) => { if (!s) return s; const { children: u } = s; - return !u[i] && o && (u[i] = context_tree_createNode(null, s)), u[i]; + return (!u[i] && o && (u[i] = context_tree_createNode(null, s)), u[i]); }, this.root); } } @@ -41653,7 +41699,7 @@ return s.filter(o); } constructor(s) { - Object.assign( + (Object.assign( this, { spec: '', @@ -41683,7 +41729,7 @@ .filter(zo.isFunction)), this.patches.push(zo.add([], this.spec)), this.patches.push(zo.context([], this.context)), - this.updatePatches(this.patches); + this.updatePatches(this.patches)); } debug(s, ...o) { this.debugLevel === s && console.log(...o); @@ -41764,7 +41810,7 @@ } updatePluginHistory(s, o) { const i = this.constructor.getPluginName(s); - (this.pluginHistory[i] = this.pluginHistory[i] || []), this.pluginHistory[i].push(o); + ((this.pluginHistory[i] = this.pluginHistory[i] || []), this.pluginHistory[i].push(o)); } updatePatches(s) { zo.normalizeArray(s).forEach((s) => { @@ -41774,11 +41820,11 @@ if (!zo.isObject(s)) return void this.debug('updatePatches', 'Got a non-object patch', s); if ((this.showDebug && this.allPatches.push(s), zo.isPromise(s.value))) - return this.promisedPatches.push(s), void this.promisedPatchThen(s); + return (this.promisedPatches.push(s), void this.promisedPatchThen(s)); if (zo.isContextPatch(s)) return void this.setContext(s.path, s.value); zo.isMutation(s) && this.updateMutations(s); } catch (s) { - console.error(s), this.errors.push(s); + (console.error(s), this.errors.push(s)); } }); } @@ -41801,10 +41847,10 @@ (s.value = s.value .then((o) => { const i = { ...s, value: o }; - this.removePromisedPatch(s), this.updatePatches(i); + (this.removePromisedPatch(s), this.updatePatches(i)); }) .catch((o) => { - this.removePromisedPatch(s), this.updatePatches(o); + (this.removePromisedPatch(s), this.updatePatches(o)); })), s.value ); @@ -41848,7 +41894,7 @@ const s = this.nextPromisedPatch(); if (s) return s.then(() => this.dispatch()).catch(() => this.dispatch()); const o = { spec: this.state, errors: this.errors }; - return this.showDebug && (o.patches = this.allPatches), Promise.resolve(o); + return (this.showDebug && (o.patches = this.allPatches), Promise.resolve(o)); } if ( ((s.pluginCount = s.pluginCount || new WeakMap()), @@ -41875,7 +41921,7 @@ updatePatches(o(i, s.getLib())); } } catch (s) { - console.error(s), updatePatches([Object.assign(Object.create(s), { plugin: o })]); + (console.error(s), updatePatches([Object.assign(Object.create(s), { plugin: o })])); } finally { s.updatePluginHistory(o, { mutationIndex: u }); } @@ -41916,7 +41962,7 @@ } class FileWithData extends File { constructor(s, o = '', i = {}) { - super([s], o, i), (this.data = s); + (super([s], o, i), (this.data = s)); } valueOf() { return this.data; @@ -42143,7 +42189,7 @@ return s; }, new FormData()); })(s.form); - (s.formdata = o), (s.body = o); + ((s.formdata = o), (s.body = o)); } else s.body = encodeFormOrQuery(u); delete s.form; } @@ -42152,13 +42198,13 @@ let w = ''; if (_) { const s = new URLSearchParams(_); - Object.keys(i).forEach((o) => s.delete(o)), (w = String(s)); + (Object.keys(i).forEach((o) => s.delete(o)), (w = String(s))); } const x = ((...s) => { const o = s.filter((s) => s).join('&'); return o ? `?${o}` : ''; })(w, encodeFormOrQuery(i)); - (s.url = u + x), delete s.query; + ((s.url = u + x), delete s.query); } return s; } @@ -42193,7 +42239,7 @@ ? JSON.parse(s) : mn.load(s); })(s, _); - (u.body = o), (u.obj = o); + ((u.body = o), (u.obj = o)); } catch (s) { u.parseError = s; } @@ -42201,22 +42247,22 @@ }); } async function http_http(s, o = {}) { - 'object' == typeof s && (s = (o = s).url), + ('object' == typeof s && (s = (o = s).url), (o.headers = o.headers || {}), (o = serializeRequest(o)).headers && Object.keys(o.headers).forEach((s) => { const i = o.headers[s]; 'string' == typeof i && (o.headers[s] = i.replace(/\n+/g, ' ')); }), - o.requestInterceptor && (o = (await o.requestInterceptor(o)) || o); + o.requestInterceptor && (o = (await o.requestInterceptor(o)) || o)); const i = o.headers['content-type'] || o.headers['Content-Type']; let u; /multipart\/form-data/i.test(i) && (delete o.headers['content-type'], delete o.headers['Content-Type']); try { - (u = await (o.userFetch || fetch)(o.url, o)), + ((u = await (o.userFetch || fetch)(o.url, o)), (u = await serializeResponse(u, s, o)), - o.responseInterceptor && (u = (await o.responseInterceptor(u)) || u); + o.responseInterceptor && (u = (await o.responseInterceptor(u)) || u)); } catch (s) { if (!u) throw s; const o = new Error(u.statusText || `response status is ${u.status}`); @@ -42321,13 +42367,13 @@ const s = u[C]; if (s.length > 1) s.forEach((s, o) => { - (s.__originalOperationId = s.__originalOperationId || s.operationId), - (s.operationId = `${C}${o + 1}`); + ((s.__originalOperationId = s.__originalOperationId || s.operationId), + (s.operationId = `${C}${o + 1}`)); }); else if (void 0 !== x.operationId) { const o = s[0]; - (o.__originalOperationId = o.__originalOperationId || x.operationId), - (o.operationId = C); + ((o.__originalOperationId = o.__originalOperationId || x.operationId), + (o.operationId = C)); } } if ('parameters' !== i) { @@ -42354,7 +42400,7 @@ } } } - return (o.$$normalized = !0), s; + return ((o.$$normalized = !0), s); } const cu = { name: 'generic', @@ -42468,7 +42514,7 @@ } var Ou = (function () { function XAll(s, o) { - (this.xf = o), (this.f = s), (this.all = !0); + ((this.xf = o), (this.f = s), (this.all = !0)); } return ( (XAll.prototype['@@transducer/init'] = _xfBase_init), @@ -42504,7 +42550,7 @@ const ju = Au; class Annotation extends Cu.Om { constructor(s, o, i) { - super(s, o, i), (this.element = 'annotation'); + (super(s, o, i), (this.element = 'annotation')); } get code() { return this.attributes.get('code'); @@ -42516,13 +42562,13 @@ const Iu = Annotation; class Comment extends Cu.Om { constructor(s, o, i) { - super(s, o, i), (this.element = 'comment'); + (super(s, o, i), (this.element = 'comment')); } } const Pu = Comment; class ParseResult extends Cu.wE { constructor(s, o, i) { - super(s, o, i), (this.element = 'parseResult'); + (super(s, o, i), (this.element = 'parseResult')); } get api() { return this.children.filter((s) => s.classes.contains('api')).first; @@ -42559,7 +42605,7 @@ const Mu = ParseResult; class SourceMap extends Cu.wE { constructor(s, o, i) { - super(s, o, i), (this.element = 'sourceMap'); + (super(s, o, i), (this.element = 'sourceMap')); } get positionStart() { return this.children.filter((s) => s.classes.contains('position')).get(0); @@ -42571,7 +42617,7 @@ if (void 0 === s) return; const o = new Cu.wE([s.start.row, s.start.column, s.start.char]), i = new Cu.wE([s.end.row, s.end.column, s.end.char]); - o.classes.push('position'), i.classes.push('position'), this.push(o).push(i); + (o.classes.push('position'), i.classes.push('position'), this.push(o).push(i)); } } const Tu = SourceMap, @@ -42738,7 +42784,7 @@ const ee = { ...z, replaceWith(s, o) { - z.replaceWith(s, o), (Y = s); + (z.replaceWith(s, o), (Y = s)); } }; for (let L = 0; L < s.length; L += 1) @@ -42757,7 +42803,7 @@ if (o === _) return o; if (void 0 !== o) { if (!x) return o; - (Y = o), (Z = !0); + ((Y = o), (Z = !0)); } } } @@ -42769,7 +42815,7 @@ const z = { ...V, replaceWith(s, o) { - V.replaceWith(s, o), (U = s); + (V.replaceWith(s, o), (U = s)); } }; for (let _ = 0; _ < s.length; _ += 1) @@ -42809,7 +42855,7 @@ const ee = { ...z, replaceWith(s, o) { - z.replaceWith(s, o), (Y = s); + (z.replaceWith(s, o), (Y = s)); } }; for (let L = 0; L < s.length; L += 1) @@ -42823,7 +42869,7 @@ if (o === _) return o; if (void 0 !== o) { if (!x) return o; - (Y = o), (Z = !0); + ((Y = o), (Z = !0)); } } } @@ -42835,7 +42881,7 @@ const z = { ...V, replaceWith(s, o) { - V.replaceWith(s, o), (U = s); + (V.replaceWith(s, o), (U = s)); } }; for (let _ = 0; _ < s.length; _ += 1) @@ -42894,7 +42940,7 @@ ae = B(ae); for (const [s, o] of ie) ae[s] = o; } - (ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev); + ((ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev)); } else if (z !== w && void 0 !== z) { if (((i = Y ? ee : Z[ee]), (ae = z[i]), ae === w || void 0 === ae)) continue; le.push(i); @@ -42912,8 +42958,8 @@ for (const [s, i] of Object.entries(u)) o[s] = i; const _ = { replaceWith(o, u) { - 'function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), - s || (ae = o); + ('function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), + s || (ae = o)); } }; ye = w.call(o, ae, i, z, le, ce, _); @@ -42939,13 +42985,13 @@ } var de; if ((void 0 === ye && fe && ie.push([i, ae]), !s)) - (U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), + ((U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), (Y = Array.isArray(ae)), (Z = Y ? ae : null !== (de = V[j(ae)]) && void 0 !== de ? de : []), (ee = -1), (ie = []), z !== w && void 0 !== z && ce.push(z), - (z = ae); + (z = ae)); } while (void 0 !== U); return 0 !== ie.length ? ie[ie.length - 1][1] : s; }; @@ -42993,7 +43039,7 @@ ae = B(ae); for (const [s, o] of ie) ae[s] = o; } - (ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev); + ((ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev)); } else if (z !== w && void 0 !== z) { if (((i = Y ? ee : Z[ee]), (ae = z[i]), ae === w || void 0 === ae)) continue; le.push(i); @@ -43010,8 +43056,8 @@ for (const [s, i] of Object.entries(u)) o[s] = i; const _ = { replaceWith(o, u) { - 'function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), - s || (ae = o); + ('function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), + s || (ae = o)); } }; fe = await w.call(o, ae, i, z, le, ce, _); @@ -43032,20 +43078,20 @@ } var pe; if ((void 0 === fe && de && ie.push([i, ae]), !s)) - (U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), + ((U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), (Y = Array.isArray(ae)), (Z = Y ? ae : null !== (pe = V[j(ae)]) && void 0 !== pe ? pe : []), (ee = -1), (ie = []), z !== w && void 0 !== z && ce.push(z), - (z = ae); + (z = ae)); } while (void 0 !== U); return 0 !== ie.length ? ie[ie.length - 1][1] : s; }; const Gu = class CloneError extends Jo { value; constructor(s, o) { - super(s, o), void 0 !== o && (this.value = o.value); + (super(s, o), void 0 !== o && (this.value = o.value)); } }; const Yu = class DeepCloneError extends Gu {}; @@ -43059,19 +43105,19 @@ w = Nu(o) ? cloneDeep(o, u) : o, x = Nu(_) ? cloneDeep(_, u) : _, C = new Cu.KeyValuePair(w, x); - return i.set(s, C), C; + return (i.set(s, C), C); } if (s instanceof Cu.ot) { const mapper = (s) => cloneDeep(s, u), o = [...s].map(mapper), _ = new Cu.ot(o); - return i.set(s, _), _; + return (i.set(s, _), _); } if (s instanceof Cu.G6) { const mapper = (s) => cloneDeep(s, u), o = [...s].map(mapper), _ = new Cu.G6(o); - return i.set(s, _), _; + return (i.set(s, _), _); } if (Nu(s)) { const o = cloneShallow(s); @@ -43183,10 +43229,10 @@ returnOnTrue; returnOnFalse; constructor({ predicate: s = es_F, returnOnTrue: o, returnOnFalse: i } = {}) { - (this.result = []), + ((this.result = []), (this.predicate = s), (this.returnOnTrue = o), - (this.returnOnFalse = i); + (this.returnOnFalse = i)); } enter(s) { return this.predicate(s) @@ -43245,13 +43291,13 @@ content = []; reference = void 0; constructor(s) { - (this.content = s), (this.reference = []); + ((this.content = s), (this.reference = [])); } toReference() { return this.reference; } toArray() { - return this.reference.push(...this.content), this.reference; + return (this.reference.push(...this.content), this.reference); } }; const rp = class EphemeralObject { @@ -43259,7 +43305,7 @@ content = []; reference = void 0; constructor(s) { - (this.content = s), (this.reference = {}); + ((this.content = s), (this.reference = {})); } toReference() { return this.reference; @@ -43273,7 +43319,7 @@ enter: (s) => { if (this.references.has(s)) return this.references.get(s).toReference(); const o = new rp(s.content); - return this.references.set(s, o), o; + return (this.references.set(s, o), o); } }; EphemeralObject = { leave: (s) => s.toObject() }; @@ -43282,7 +43328,7 @@ enter: (s) => { if (this.references.has(s)) return this.references.get(s).toReference(); const o = new tp(s.content); - return this.references.set(s, o), o; + return (this.references.set(s, o), o); } }; EphemeralArray = { leave: (s) => s.toArray() }; @@ -43409,17 +43455,17 @@ const _p = bp; class Namespace extends Cu.g$ { constructor() { - super(), + (super(), this.register('annotation', Iu), this.register('comment', Pu), this.register('parseResult', Mu), - this.register('sourceMap', Tu); + this.register('sourceMap', Tu)); } } const Ep = new Namespace(), createNamespace = (s) => { const o = new Namespace(); - return ku(s) && o.use(s), o; + return (ku(s) && o.use(s), o); }, wp = Ep, toolbox = () => ({ predicates: { ...le }, namespace: wp }), @@ -43436,7 +43482,7 @@ j = mergeAll(C.map(La({}, 'visitor')), { ...w }); C.forEach(_p(['pre'], [])); const L = visitor_visit(s, j, w); - return C.forEach(_p(['post'], [])), L; + return (C.forEach(_p(['post'], [])), L); }; dispatchPluginsSync[Symbol.for('nodejs.util.promisify.custom')] = async (s, o, i = {}) => { if (0 === o.length) return s; @@ -43449,7 +43495,7 @@ B = j(C.map(La({}, 'visitor')), { ...w }); await Promise.allSettled(C.map(_p(['pre'], []))); const $ = await L(s, B, w); - return await Promise.allSettled(C.map(_p(['post'], []))), $; + return (await Promise.allSettled(C.map(_p(['post'], []))), $); }; const refract = (s, { Type: o, plugins: i = [] }) => { const u = new o(s); @@ -43467,7 +43513,7 @@ (s) => (o, i = {}) => refract(o, { ...i, Type: s }); - (Cu.Sh.refract = createRefractor(Cu.Sh)), + ((Cu.Sh.refract = createRefractor(Cu.Sh)), (Cu.wE.refract = createRefractor(Cu.wE)), (Cu.Om.refract = createRefractor(Cu.Om)), (Cu.bd.refract = createRefractor(Cu.bd)), @@ -43478,12 +43524,12 @@ (Iu.refract = createRefractor(Iu)), (Pu.refract = createRefractor(Pu)), (Mu.refract = createRefractor(Mu)), - (Tu.refract = createRefractor(Tu)); + (Tu.refract = createRefractor(Tu))); const computeEdges = (s, o = new WeakMap()) => ( $u(s) ? (o.set(s.key, s), computeEdges(s.key, o), o.set(s.value, s), computeEdges(s.value, o)) : s.children.forEach((i) => { - o.set(i, s), computeEdges(i, o); + (o.set(i, s), computeEdges(i, o)); }), o ); @@ -43533,7 +43579,7 @@ const Op = class CompilationJsonPointerError extends Cp { tokens; constructor(s, o) { - super(s, o), void 0 !== o && (this.tokens = [...o.tokens]); + (super(s, o), void 0 !== o && (this.tokens = [...o.tokens])); } }, es_compile = (s) => { @@ -43573,7 +43619,7 @@ const Rp = Wl(Number.isInteger) ? za(1, Ea(Number.isInteger, Number)) : Np; var Dp = (function () { function XTake(s, o) { - (this.xf = o), (this.n = s), (this.i = 0); + ((this.xf = o), (this.n = s), (this.i = 0)); } return ( (XTake.prototype['@@transducer/init'] = _xfBase_init), @@ -43603,7 +43649,7 @@ const qp = ra(''); var $p = (function () { function XDropWhile(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XDropWhile.prototype['@@transducer/init'] = _xfBase_init), @@ -43642,7 +43688,7 @@ const Wp = class InvalidJsonPointerError extends Cp { pointer; constructor(s, o) { - super(s, o), void 0 !== o && (this.pointer = o.pointer); + (super(s, o), void 0 !== o && (this.pointer = o.pointer)); } }, uriToPointer = (s) => { @@ -43675,13 +43721,13 @@ failedTokenPosition; element; constructor(s, o) { - super(s, o), + (super(s, o), void 0 !== o && ((this.pointer = o.pointer), Array.isArray(o.tokens) && (this.tokens = [...o.tokens]), (this.failedToken = o.failedToken), (this.failedTokenPosition = o.failedTokenPosition), - (this.element = o.element)); + (this.element = o.element))); } }, es_evaluate = (s, o) => { @@ -43738,13 +43784,13 @@ }; class Callback extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'callback'); + (super(s, o, i), (this.element = 'callback')); } } const Hp = Callback; class Components extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'components'); + (super(s, o, i), (this.element = 'components')); } get schemas() { return this.get('schemas'); @@ -43804,7 +43850,7 @@ const Jp = Components; class Contact extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'contact'); + (super(s, o, i), (this.element = 'contact')); } get name() { return this.get('name'); @@ -43828,7 +43874,7 @@ const Gp = Contact; class Discriminator extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'discriminator'); + (super(s, o, i), (this.element = 'discriminator')); } get propertyName() { return this.get('propertyName'); @@ -43846,7 +43892,7 @@ const Yp = Discriminator; class Encoding extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'encoding'); + (super(s, o, i), (this.element = 'encoding')); } get contentType() { return this.get('contentType'); @@ -43882,7 +43928,7 @@ const Xp = Encoding; class Example extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'example'); + (super(s, o, i), (this.element = 'example')); } get summary() { return this.get('summary'); @@ -43912,7 +43958,7 @@ const Zp = Example; class ExternalDocumentation extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'externalDocumentation'); + (super(s, o, i), (this.element = 'externalDocumentation')); } get description() { return this.get('description'); @@ -43930,7 +43976,7 @@ const Qp = ExternalDocumentation; class Header extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'header'); + (super(s, o, i), (this.element = 'header')); } get required() { return this.hasKey('required') ? this.get('required') : new Cu.bd(!1); @@ -44005,7 +44051,7 @@ const th = Header; class Info extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'info'), this.classes.push('info'); + (super(s, o, i), (this.element = 'info'), this.classes.push('info')); } get title() { return this.get('title'); @@ -44047,7 +44093,7 @@ const rh = Info; class License extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'license'); + (super(s, o, i), (this.element = 'license')); } get name() { return this.get('name'); @@ -44065,7 +44111,7 @@ const uh = License; class Link extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'link'); + (super(s, o, i), (this.element = 'link')); } get operationRef() { return this.get('operationRef'); @@ -44122,7 +44168,7 @@ const dh = Link; class MediaType extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'mediaType'); + (super(s, o, i), (this.element = 'mediaType')); } get schema() { return this.get('schema'); @@ -44152,7 +44198,7 @@ const fh = MediaType; class OAuthFlow extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'oAuthFlow'); + (super(s, o, i), (this.element = 'oAuthFlow')); } get authorizationUrl() { return this.get('authorizationUrl'); @@ -44182,7 +44228,7 @@ const vh = OAuthFlow; class OAuthFlows extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'oAuthFlows'); + (super(s, o, i), (this.element = 'oAuthFlows')); } get implicit() { return this.get('implicit'); @@ -44212,16 +44258,16 @@ const _h = OAuthFlows; class Openapi extends Cu.Om { constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), (this.element = 'openapi'), this.classes.push('spec-version'), - this.classes.push('version'); + this.classes.push('version')); } } const wh = Openapi; class OpenApi3_0 extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'openApi3_0'), this.classes.push('api'); + (super(s, o, i), (this.element = 'openApi3_0'), this.classes.push('api')); } get openapi() { return this.get('openapi'); @@ -44275,7 +44321,7 @@ const Oh = OpenApi3_0; class Operation extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'operation'); + (super(s, o, i), (this.element = 'operation')); } get tags() { return this.get('tags'); @@ -44353,7 +44399,7 @@ const jh = Operation; class Parameter extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'parameter'); + (super(s, o, i), (this.element = 'parameter')); } get name() { return this.get('name'); @@ -44440,7 +44486,7 @@ const Ih = Parameter; class PathItem extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'pathItem'); + (super(s, o, i), (this.element = 'pathItem')); } get $ref() { return this.get('$ref'); @@ -44524,13 +44570,13 @@ const Ph = PathItem; class Paths extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'paths'); + (super(s, o, i), (this.element = 'paths')); } } const Rh = Paths; class Reference extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'reference'), this.classes.push('openapi-reference'); + (super(s, o, i), (this.element = 'reference'), this.classes.push('openapi-reference')); } get $ref() { return this.get('$ref'); @@ -44542,7 +44588,7 @@ const Dh = Reference; class RequestBody extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'requestBody'); + (super(s, o, i), (this.element = 'requestBody')); } get description() { return this.get('description'); @@ -44566,7 +44612,7 @@ const Lh = RequestBody; class Response_Response extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'response'); + (super(s, o, i), (this.element = 'response')); } get description() { return this.get('description'); @@ -44596,7 +44642,7 @@ const Fh = Response_Response; class Responses extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'responses'); + (super(s, o, i), (this.element = 'responses')); } get default() { return this.get('default'); @@ -44609,7 +44655,7 @@ const Hh = class UnsupportedOperationError extends Ho {}; class JSONSchema extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'JSONSchemaDraft4'); + (super(s, o, i), (this.element = 'JSONSchemaDraft4')); } get idProp() { return this.get('id'); @@ -44837,7 +44883,7 @@ const Jh = JSONSchema; class JSONReference extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'JSONReference'), this.classes.push('json-reference'); + (super(s, o, i), (this.element = 'JSONReference'), this.classes.push('json-reference')); } get $ref() { return this.get('$ref'); @@ -44849,7 +44895,7 @@ const Gh = JSONReference; class Media extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'media'); + (super(s, o, i), (this.element = 'media')); } get binaryEncoding() { return this.get('binaryEncoding'); @@ -44867,7 +44913,7 @@ const Qh = Media; class LinkDescription extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'linkDescription'); + (super(s, o, i), (this.element = 'linkDescription')); } get href() { return this.get('href'); @@ -44922,7 +44968,7 @@ var sd = _curry2(function mapObjIndexed(s, o) { return _arrayReduce( function (i, u) { - return (i[u] = s(o[u], u, o)), i; + return ((i[u] = s(o[u], u, o)), i); }, {}, Wi(o) @@ -44936,7 +44982,7 @@ if (0 === s.length || ld(o)) return !1; for (var i = o, u = 0; u < s.length; ) { if (ld(i) || !_has(s[u], i)) return !1; - (i = i[s[u]]), (u += 1); + ((i = i[s[u]]), (u += 1)); } return !0; }); @@ -44983,21 +45029,21 @@ Fu(s) && s.forEach((s, o, _) => { const w = cloneShallow(_); - (w.value = cloneUnlessOtherwiseSpecified(s, i)), u.content.push(w); + ((w.value = cloneUnlessOtherwiseSpecified(s, i)), u.content.push(w)); }), o.forEach((o, _, w) => { const x = serializers_value(_); let C; if (Fu(s) && s.hasKey(x) && i.isMergeableElement(o)) { const u = s.get(x); - (C = cloneShallow(w)), + ((C = cloneShallow(w)), (C.value = ((s, o) => { if ('function' != typeof o.customMerge) return deepmerge; const i = o.customMerge(s, o); return 'function' == typeof i ? i : deepmerge; - })(_, i)(u, o)); - } else (C = cloneShallow(w)), (C.value = cloneUnlessOtherwiseSpecified(o, i)); - u.remove(x), u.content.push(C); + })(_, i)(u, o))); + } else ((C = cloneShallow(w)), (C.value = cloneUnlessOtherwiseSpecified(o, i))); + (u.remove(x), u.content.push(C)); }), u ); @@ -45009,12 +45055,12 @@ function deepmerge(s, o, i) { var u, _, w; const x = { ...vd, ...i }; - (x.isMergeableElement = + ((x.isMergeableElement = null !== (u = x.isMergeableElement) && void 0 !== u ? u : vd.isMergeableElement), (x.arrayElementMerge = null !== (_ = x.arrayElementMerge) && void 0 !== _ ? _ : vd.arrayElementMerge), (x.objectElementMerge = - null !== (w = x.objectElementMerge) && void 0 !== w ? w : vd.objectElementMerge); + null !== (w = x.objectElementMerge) && void 0 !== w ? w : vd.objectElementMerge)); const C = qu(o); if (!(C === qu(s))) return cloneUnlessOtherwiseSpecified(o, x); const j = @@ -45040,16 +45086,16 @@ Object.assign(this, s); } copyMetaAndAttributes(s, o) { - (s.meta.length > 0 || o.meta.length > 0) && + ((s.meta.length > 0 || o.meta.length > 0) && ((o.meta = deepmerge(o.meta, s.meta)), hasElementSourceMap(s) && o.meta.set('sourceMap', s.meta.get('sourceMap'))), (s.attributes.length > 0 || s.meta.length > 0) && - (o.attributes = deepmerge(o.attributes, s.attributes)); + (o.attributes = deepmerge(o.attributes, s.attributes))); } }; const Ed = class FallbackVisitor extends _d { enter(s) { - return (this.element = cloneDeep(s)), Ju; + return ((this.element = cloneDeep(s)), Ju); } }, copyProps = (s, o, i = []) => { @@ -45088,7 +45134,7 @@ -1 === x.indexOf(u) && (copyProps(w, u, ['constructor', ...i]), x.push(u)); } } - return (w.constructor = o), w; + return ((w.constructor = o), w); }, unique = (s) => s.filter((o, i) => s.indexOf(o) == i), getIngredientWithProp = (s, o) => { @@ -45124,7 +45170,7 @@ const _ = getIngredientWithProp(i, s); if (void 0 === _) throw new Error('Cannot set new properties on Proxies created by ts-mixer'); - return (_[i] = u), !0; + return ((_[i] = u), !0); }, deleteProperty() { throw new Error('Cannot delete properties on Proxies created by ts-mixer'); @@ -45196,7 +45242,7 @@ ...(null !== (o = getMixinsForClass(s)) && void 0 !== o ? o : []) ].filter((s) => !i.has(s)); for (let s of w) u.add(s); - i.add(s), u.delete(s); + (i.add(s), u.delete(s)); } return [...i]; })(...s) @@ -45210,7 +45256,7 @@ }, getDecoratorsForClass = (s) => { let o = Od.get(s); - return o || ((o = {}), Od.set(s, o)), o; + return (o || ((o = {}), Od.set(s, o)), o); }; function Mixin(...s) { var o, i, u; @@ -45229,7 +45275,7 @@ null !== w && 'function' == typeof this[w] && this[w].apply(this, o); } var x, C; - (MixedClass.prototype = + ((MixedClass.prototype = 'copy' === xd ? hardMixProtos(_, MixedClass) : ((x = _), (C = MixedClass), proxyMix([...x, { constructor: C }]))), @@ -45238,7 +45284,7 @@ 'copy' === Sd ? hardMixProtos(s, null, ['prototype']) : proxyMix(s, Function.prototype) - ); + )); let j = MixedClass; if ('none' !== kd) { const _ = @@ -45256,17 +45302,17 @@ const o = s(j); o && (j = o); } - applyPropAndMethodDecorators( + (applyPropAndMethodDecorators( null !== (i = null == _ ? void 0 : _.static) && void 0 !== i ? i : {}, j ), applyPropAndMethodDecorators( null !== (u = null == _ ? void 0 : _.instance) && void 0 !== u ? u : {}, j.prototype - ); + )); } var L, B; - return (L = j), (B = s), Cd.set(L, B), j; + return ((L = j), (B = s), Cd.set(L, B), j); } const applyPropAndMethodDecorators = (s, o) => { const i = s.property, @@ -45276,14 +45322,14 @@ for (let s in u) for (let i of u[s]) i(o, s, Object.getOwnPropertyDescriptor(o, s)); }; const Ad = _curry2(function pick(s, o) { - for (var i = {}, u = 0; u < s.length; ) s[u] in o && (i[s[u]] = o[s[u]]), (u += 1); + for (var i = {}, u = 0; u < s.length; ) (s[u] in o && (i[s[u]] = o[s[u]]), (u += 1)); return i; }); const Id = class SpecificationVisitor extends _d { specObj; passingOptionsNames = ['specObj']; constructor({ specObj: s, ...o }) { - super({ ...o }), (this.specObj = s); + (super({ ...o }), (this.specObj = s)); } retrievePassingOptions() { return Ad(this.passingOptionsNames, this); @@ -45312,7 +45358,7 @@ specPath; ignoredFields; constructor({ specPath: s, ignoredFields: o, ...i }) { - super({ ...i }), (this.specPath = s), (this.ignoredFields = o || []); + (super({ ...i }), (this.specPath = s), (this.ignoredFields = o || [])); } ObjectElement(s) { const o = this.specPath(s), @@ -45326,9 +45372,9 @@ ) { const i = this.toRefractedElement([...o, 'fixedFields', serializers_value(u)], s), w = new Cu.Pr(cloneDeep(u), i); - this.copyMetaAndAttributes(_, w), + (this.copyMetaAndAttributes(_, w), w.classes.push('fixed-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(u)) || this.element.content.push(cloneDeep(_)); @@ -45340,9 +45386,9 @@ }; class JSONSchemaVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Jh()), - (this.specPath = Tl(['document', 'objects', 'JSONSchema'])); + (this.specPath = Tl(['document', 'objects', 'JSONSchema']))); } } const Td = JSONSchemaVisitor; @@ -45358,7 +45404,7 @@ const o = isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] : ['document', 'objects', 'JSONSchema']; - return (this.element = this.toRefractedElement(o, s)), Ju; + return ((this.element = this.toRefractedElement(o, s)), Ju); } ArrayElement(s) { return ( @@ -45380,7 +45426,7 @@ const Dd = class RequiredVisitor extends Ed { ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-required'), o; + return (this.element.classes.push('json-schema-required'), o); } }; const Ld = _curry1(function allPass(s) { @@ -45419,10 +45465,10 @@ ignoredFields; fieldPatternPredicate = es_F; constructor({ specPath: s, ignoredFields: o, fieldPatternPredicate: i, ...u }) { - super({ ...u }), + (super({ ...u }), (this.specPath = s), (this.ignoredFields = o || []), - 'function' == typeof i && (this.fieldPatternPredicate = i); + 'function' == typeof i && (this.fieldPatternPredicate = i)); } ObjectElement(s) { return ( @@ -45434,9 +45480,9 @@ const u = this.specPath(s), _ = this.toRefractedElement(u, s), w = new Cu.Pr(cloneDeep(o), _); - this.copyMetaAndAttributes(i, w), + (this.copyMetaAndAttributes(i, w), w.classes.push('patterned-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(o)) || this.element.content.push(cloneDeep(i)); @@ -45448,64 +45494,66 @@ }; const Wd = class MapVisitor extends Ud { constructor(s) { - super(s), (this.fieldPatternPredicate = Vd); + (super(s), (this.fieldPatternPredicate = Vd)); } }; class PropertiesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-properties'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Kd = PropertiesVisitor; class PatternPropertiesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-patternProperties'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Hd = PatternPropertiesVisitor; class DependenciesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-dependencies'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Jd = DependenciesVisitor; const Gd = class EnumVisitor extends Ed { ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-enum'), o; + return (this.element.classes.push('json-schema-enum'), o); } }; const Yd = class TypeVisitor extends Ed { StringElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } }; class AllOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-allOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-allOf')); } ArrayElement(s) { return ( @@ -45524,7 +45572,9 @@ const Xd = AllOfVisitor; class AnyOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-anyOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-anyOf')); } ArrayElement(s) { return ( @@ -45543,7 +45593,9 @@ const Zd = AnyOfVisitor; class OneOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-oneOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-oneOf')); } ArrayElement(s) { return ( @@ -45562,19 +45614,21 @@ const Qd = OneOfVisitor; class DefinitionsVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-definitions'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const ef = DefinitionsVisitor; class LinksVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-links'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-links')); } ArrayElement(s) { return ( @@ -45590,20 +45644,20 @@ const rf = LinksVisitor; class JSONReferenceVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Gh()), - (this.specPath = Tl(['document', 'objects', 'JSONReference'])); + (this.specPath = Tl(['document', 'objects', 'JSONReference']))); } ObjectElement(s) { const o = Md.prototype.ObjectElement.call(this, s); - return Ru(this.element.$ref) && this.element.classes.push('reference-element'), o; + return (Ru(this.element.$ref) && this.element.classes.push('reference-element'), o); } } const of = JSONReferenceVisitor; const af = class $RefVisitor extends Ed { StringElement(s) { const o = this.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const lf = _curry3(function ifElse(s, o, i) { @@ -45693,39 +45747,39 @@ const kf = class AlternatingVisitor extends Id { alternator; constructor({ alternator: s, ...o }) { - super({ ...o }), (this.alternator = s); + (super({ ...o }), (this.alternator = s)); } enter(s) { const o = this.alternator.map(({ predicate: s, specPath: o }) => lf(s, Tl(o), Nl)), i = xf(o)(s); - return (this.element = this.toRefractedElement(i, s)), Ju; + return ((this.element = this.toRefractedElement(i, s)), Ju); } }; const Cf = class SchemaOrReferenceVisitor extends kf { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isJSONReferenceLikeElement, specPath: ['document', 'objects', 'JSONReference'] }, { predicate: es_T, specPath: ['document', 'objects', 'JSONSchema'] } - ]); + ])); } }; class MediaVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Qh()), - (this.specPath = Tl(['document', 'objects', 'Media'])); + (this.specPath = Tl(['document', 'objects', 'Media']))); } } const Of = MediaVisitor; class LinkDescriptionVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new td()), - (this.specPath = Tl(['document', 'objects', 'LinkDescription'])); + (this.specPath = Tl(['document', 'objects', 'LinkDescription']))); } } const jf = { @@ -45871,7 +45925,7 @@ (s) => (o, i = {}) => refractor_refract(o, { specPath: s, ...i }); - (Jh.refract = refractor_createRefractor([ + ((Jh.refract = refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -45898,10 +45952,10 @@ 'objects', 'LinkDescription', '$visitor' - ])); + ]))); const Wf = class Schema_Schema extends Jh { constructor(s, o, i) { - super(s, o, i), (this.element = 'schema'), this.classes.push('json-schema-draft-4'); + (super(s, o, i), (this.element = 'schema'), this.classes.push('json-schema-draft-4')); } get idProp() { throw new Hh('idProp getter in Schema class is not not supported.'); @@ -46026,13 +46080,13 @@ }; class SecurityRequirement extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'securityRequirement'); + (super(s, o, i), (this.element = 'securityRequirement')); } } const Hf = SecurityRequirement; class SecurityScheme extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'securityScheme'); + (super(s, o, i), (this.element = 'securityScheme')); } get type() { return this.get('type'); @@ -46086,7 +46140,7 @@ const Jf = SecurityScheme; class Server extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'server'); + (super(s, o, i), (this.element = 'server')); } get url() { return this.get('url'); @@ -46110,7 +46164,7 @@ const Gf = Server; class ServerVariable extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'serverVariable'); + (super(s, o, i), (this.element = 'serverVariable')); } get enum() { return this.get('enum'); @@ -46134,7 +46188,7 @@ const Xf = ServerVariable; class Tag extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'tag'); + (super(s, o, i), (this.element = 'tag')); } get name() { return this.get('name'); @@ -46158,7 +46212,7 @@ const Qf = Tag; class Xml extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'xml'); + (super(s, o, i), (this.element = 'xml')); } get name() { return this.get('name'); @@ -46198,16 +46252,16 @@ Object.assign(this, s); } copyMetaAndAttributes(s, o) { - (s.meta.length > 0 || o.meta.length > 0) && + ((s.meta.length > 0 || o.meta.length > 0) && ((o.meta = deepmerge(o.meta, s.meta)), hasElementSourceMap(s) && o.meta.set('sourceMap', s.meta.get('sourceMap'))), (s.attributes.length > 0 || s.meta.length > 0) && - (o.attributes = deepmerge(o.attributes, s.attributes)); + (o.attributes = deepmerge(o.attributes, s.attributes))); } }; const rm = class FallbackVisitor_FallbackVisitor extends tm { enter(s) { - return (this.element = cloneDeep(s)), Ju; + return ((this.element = cloneDeep(s)), Ju); } }; const nm = class SpecificationVisitor_SpecificationVisitor extends tm { @@ -46222,11 +46276,11 @@ openApiSemanticElement: u, ..._ }) { - super({ ..._ }), + (super({ ..._ }), (this.specObj = s), (this.openApiGenericElement = i), (this.openApiSemanticElement = u), - Array.isArray(o) && (this.passingOptionsNames = o); + Array.isArray(o) && (this.passingOptionsNames = o)); } retrievePassingOptions() { return Ad(this.passingOptionsNames, this); @@ -46267,11 +46321,11 @@ specificationExtensionPredicate: u, ..._ }) { - super({ ..._ }), + (super({ ..._ }), (this.specPath = s), (this.ignoredFields = o || []), 'boolean' == typeof i && (this.canSupportSpecificationExtensions = i), - 'function' == typeof u && (this.specificationExtensionPredicate = u); + 'function' == typeof u && (this.specificationExtensionPredicate = u)); } ObjectElement(s) { const o = this.specPath(s), @@ -46285,9 +46339,9 @@ ) { const i = this.toRefractedElement([...o, 'fixedFields', serializers_value(u)], s), w = new Cu.Pr(cloneDeep(u), i); - this.copyMetaAndAttributes(_, w), + (this.copyMetaAndAttributes(_, w), w.classes.push('fixed-field'), - this.element.content.push(w); + this.element.content.push(w)); } else if ( this.canSupportSpecificationExtensions && this.specificationExtensionPredicate(_) @@ -46305,10 +46359,10 @@ }; class OpenApi3_0Visitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Oh()), (this.specPath = Tl(['document', 'objects', 'OpenApi'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { return im.prototype.ObjectElement.call(this, s); @@ -46318,7 +46372,7 @@ class OpenapiVisitor extends Mixin(nm, rm) { StringElement(s) { const o = new wh(serializers_value(s)); - return this.copyMetaAndAttributes(s, o), (this.element = o), Ju; + return (this.copyMetaAndAttributes(s, o), (this.element = o), Ju); } } const lm = OpenapiVisitor; @@ -46333,10 +46387,10 @@ }; class InfoVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new rh()), (this.specPath = Tl(['document', 'objects', 'Info'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const um = InfoVisitor; @@ -46344,34 +46398,36 @@ StringElement(s) { const o = super.enter(s); return ( - this.element.classes.push('api-version'), this.element.classes.push('version'), o + this.element.classes.push('api-version'), + this.element.classes.push('version'), + o ); } }; class ContactVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Gp()), (this.specPath = Tl(['document', 'objects', 'Contact'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const hm = ContactVisitor; class LicenseVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new uh()), (this.specPath = Tl(['document', 'objects', 'License'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const dm = LicenseVisitor; class LinkVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new dh()), (this.specPath = Tl(['document', 'objects', 'Link'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -46386,13 +46442,13 @@ const mm = class OperationRefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const gm = class OperationIdVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const ym = class PatternedFieldsVisitor_PatternedFieldsVisitor extends nm { @@ -46409,12 +46465,12 @@ specificationExtensionPredicate: _, ...w }) { - super({ ...w }), + (super({ ...w }), (this.specPath = s), (this.ignoredFields = o || []), 'function' == typeof i && (this.fieldPatternPredicate = i), 'boolean' == typeof u && (this.canSupportSpecificationExtensions = u), - 'function' == typeof _ && (this.specificationExtensionPredicate = _); + 'function' == typeof _ && (this.specificationExtensionPredicate = _)); } ObjectElement(s) { return ( @@ -46432,9 +46488,9 @@ const u = this.specPath(s), _ = this.toRefractedElement(u, s), w = new Cu.Pr(cloneDeep(o), _); - this.copyMetaAndAttributes(i, w), + (this.copyMetaAndAttributes(i, w), w.classes.push('patterned-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(o)) || this.element.content.push(cloneDeep(i)); @@ -46446,47 +46502,47 @@ }; const vm = class MapVisitor_MapVisitor extends ym { constructor(s) { - super(s), (this.fieldPatternPredicate = Vd); + (super(s), (this.fieldPatternPredicate = Vd)); } }; class LinkParameters extends Cu.Sh { static primaryClass = 'link-parameters'; constructor(s, o, i) { - super(s, o, i), this.classes.push(LinkParameters.primaryClass); + (super(s, o, i), this.classes.push(LinkParameters.primaryClass)); } } const bm = LinkParameters; class ParametersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new bm()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new bm()), (this.specPath = Tl(['value']))); } } const _m = ParametersVisitor; class ServerVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Gf()), (this.specPath = Tl(['document', 'objects', 'Server'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Em = ServerVisitor; const wm = class UrlVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('server-url'), o; + return (this.element.classes.push('server-url'), o); } }; class Servers extends Cu.wE { static primaryClass = 'servers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Servers.primaryClass); + (super(s, o, i), this.classes.push(Servers.primaryClass)); } } const Sm = Servers; class ServersVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Sm()); + (super(s), (this.element = new Sm())); } ArrayElement(s) { return ( @@ -46503,46 +46559,46 @@ const xm = ServersVisitor; class ServerVariableVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Xf()), (this.specPath = Tl(['document', 'objects', 'ServerVariable'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const km = ServerVariableVisitor; class ServerVariables extends Cu.Sh { static primaryClass = 'server-variables'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ServerVariables.primaryClass); + (super(s, o, i), this.classes.push(ServerVariables.primaryClass)); } } const Cm = ServerVariables; class VariablesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cm()), - (this.specPath = Tl(['document', 'objects', 'ServerVariable'])); + (this.specPath = Tl(['document', 'objects', 'ServerVariable']))); } } const Om = VariablesVisitor; class MediaTypeVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new fh()), (this.specPath = Tl(['document', 'objects', 'MediaType'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Am = MediaTypeVisitor; const jm = class AlternatingVisitor_AlternatingVisitor extends nm { alternator; constructor({ alternator: s, ...o }) { - super({ ...o }), (this.alternator = s || []); + (super({ ...o }), (this.alternator = s || [])); } enter(s) { const o = this.alternator.map(({ predicate: s, specPath: o }) => lf(s, Tl(o), Nl)), i = xf(o)(s); - return (this.element = this.toRefractedElement(i, s)), Ju; + return ((this.element = this.toRefractedElement(i, s)), Ju); } }, Im = helpers( @@ -46678,33 +46734,34 @@ ); class SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } const ng = SchemaVisitor; class ExamplesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('examples'), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Example']), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -46720,48 +46777,48 @@ class MediaTypeExamples extends Cu.Sh { static primaryClass = 'media-type-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(MediaTypeExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const og = MediaTypeExamples; const lg = class ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new og()); + (super(s), (this.element = new og())); } }; class MediaTypeEncoding extends Cu.Sh { static primaryClass = 'media-type-encoding'; constructor(s, o, i) { - super(s, o, i), this.classes.push(MediaTypeEncoding.primaryClass); + (super(s, o, i), this.classes.push(MediaTypeEncoding.primaryClass)); } } const pg = MediaTypeEncoding; class EncodingVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new pg()), - (this.specPath = Tl(['document', 'objects', 'Encoding'])); + (this.specPath = Tl(['document', 'objects', 'Encoding']))); } } const fg = EncodingVisitor; class SecurityRequirementVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new Hf()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new Hf()), (this.specPath = Tl(['value']))); } } const mg = SecurityRequirementVisitor; class Security extends Cu.wE { static primaryClass = 'security'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Security.primaryClass); + (super(s, o, i), this.classes.push(Security.primaryClass)); } } const gg = Security; class SecurityVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new gg()); + (super(s), (this.element = new gg())); } ArrayElement(s) { return ( @@ -46782,47 +46839,47 @@ const yg = SecurityVisitor; class ComponentsVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Jp()), (this.specPath = Tl(['document', 'objects', 'Components'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const _g = ComponentsVisitor; class TagVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Qf()), (this.specPath = Tl(['document', 'objects', 'Tag'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const xg = TagVisitor; class ReferenceVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Dh()), (this.specPath = Tl(['document', 'objects', 'Reference'])), - (this.canSupportSpecificationExtensions = !1); + (this.canSupportSpecificationExtensions = !1)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); - return Ru(this.element.$ref) && this.element.classes.push('reference-element'), o; + return (Ru(this.element.$ref) && this.element.classes.push('reference-element'), o); } } const kg = ReferenceVisitor; const qg = class $RefVisitor_$RefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class ParameterVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ih()), (this.specPath = Tl(['document', 'objects', 'Parameter'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -46838,47 +46895,49 @@ const Vg = ParameterVisitor; class SchemaVisitor_SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } const Ug = SchemaVisitor_SchemaVisitor; class HeaderVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new th()), (this.specPath = Tl(['document', 'objects', 'Header'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const zg = HeaderVisitor; class header_SchemaVisitor_SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } @@ -46886,46 +46945,46 @@ class HeaderExamples extends Cu.Sh { static primaryClass = 'header-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(HeaderExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const Kg = HeaderExamples; const Yg = class header_ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new Kg()); + (super(s), (this.element = new Kg())); } }; class ContentVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('content'), - (this.specPath = Tl(['document', 'objects', 'MediaType'])); + (this.specPath = Tl(['document', 'objects', 'MediaType']))); } } const Xg = ContentVisitor; class HeaderContent extends Cu.Sh { static primaryClass = 'header-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(HeaderContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const Zg = HeaderContent; const ey = class ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new Zg()); + (super(s), (this.element = new Zg())); } }; class schema_SchemaVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Wf()), (this.specPath = Tl(['document', 'objects', 'Schema'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const ty = schema_SchemaVisitor, @@ -46970,7 +47029,8 @@ ObjectElement(s) { const o = ly.prototype.ObjectElement.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } ArrayElement(s) { @@ -47000,84 +47060,85 @@ ObjectElement(s) { const o = fy.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } }; class DiscriminatorVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Yp()), (this.specPath = Tl(['document', 'objects', 'Discriminator'])), - (this.canSupportSpecificationExtensions = !1); + (this.canSupportSpecificationExtensions = !1)); } } const gy = DiscriminatorVisitor; class DiscriminatorMapping extends Cu.Sh { static primaryClass = 'discriminator-mapping'; constructor(s, o, i) { - super(s, o, i), this.classes.push(DiscriminatorMapping.primaryClass); + (super(s, o, i), this.classes.push(DiscriminatorMapping.primaryClass)); } } const yy = DiscriminatorMapping; class MappingVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new yy()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new yy()), (this.specPath = Tl(['value']))); } } const vy = MappingVisitor; class XmlVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new em()), (this.specPath = Tl(['document', 'objects', 'XML'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const by = XmlVisitor; class ParameterExamples extends Cu.Sh { static primaryClass = 'parameter-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ParameterExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const _y = ParameterExamples; const Ey = class parameter_ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new _y()); + (super(s), (this.element = new _y())); } }; class ParameterContent extends Cu.Sh { static primaryClass = 'parameter-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ParameterContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const wy = ParameterContent; const Sy = class parameter_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new wy()); + (super(s), (this.element = new wy())); } }; class ComponentsSchemas extends Cu.Sh { static primaryClass = 'components-schemas'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsSchemas.primaryClass); + (super(s, o, i), this.classes.push(ComponentsSchemas.primaryClass)); } } const xy = ComponentsSchemas; class SchemasVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new xy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Schema']); + : ['document', 'objects', 'Schema'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47093,18 +47154,18 @@ class ComponentsResponses extends Cu.Sh { static primaryClass = 'components-responses'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsResponses.primaryClass); + (super(s, o, i), this.classes.push(ComponentsResponses.primaryClass)); } } const Cy = ComponentsResponses; class ResponsesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Response']); + : ['document', 'objects', 'Response'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47123,20 +47184,20 @@ class ComponentsParameters extends Cu.Sh { static primaryClass = 'components-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ComponentsParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const Ay = ComponentsParameters; class ParametersVisitor_ParametersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ay()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Parameter']); + : ['document', 'objects', 'Parameter'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47152,20 +47213,20 @@ class ComponentsExamples extends Cu.Sh { static primaryClass = 'components-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ComponentsExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const Iy = ComponentsExamples; class components_ExamplesVisitor_ExamplesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Iy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Example']); + : ['document', 'objects', 'Example'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47181,18 +47242,18 @@ class ComponentsRequestBodies extends Cu.Sh { static primaryClass = 'components-request-bodies'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsRequestBodies.primaryClass); + (super(s, o, i), this.classes.push(ComponentsRequestBodies.primaryClass)); } } const My = ComponentsRequestBodies; class RequestBodiesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new My()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'RequestBody']); + : ['document', 'objects', 'RequestBody'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47208,18 +47269,18 @@ class ComponentsHeaders extends Cu.Sh { static primaryClass = 'components-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsHeaders.primaryClass); + (super(s, o, i), this.classes.push(ComponentsHeaders.primaryClass)); } } const Ny = ComponentsHeaders; class HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ny()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47238,18 +47299,18 @@ class ComponentsSecuritySchemes extends Cu.Sh { static primaryClass = 'components-security-schemes'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsSecuritySchemes.primaryClass); + (super(s, o, i), this.classes.push(ComponentsSecuritySchemes.primaryClass)); } } const Dy = ComponentsSecuritySchemes; class SecuritySchemesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Dy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'SecurityScheme']); + : ['document', 'objects', 'SecurityScheme'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47265,18 +47326,18 @@ class ComponentsLinks extends Cu.Sh { static primaryClass = 'components-links'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsLinks.primaryClass); + (super(s, o, i), this.classes.push(ComponentsLinks.primaryClass)); } } const By = ComponentsLinks; class LinksVisitor_LinksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new By()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Link']); + : ['document', 'objects', 'Link'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47292,18 +47353,18 @@ class ComponentsCallbacks extends Cu.Sh { static primaryClass = 'components-callbacks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsCallbacks.primaryClass); + (super(s, o, i), this.classes.push(ComponentsCallbacks.primaryClass)); } } const qy = ComponentsCallbacks; class CallbacksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new qy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Callback']); + : ['document', 'objects', 'Callback'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47318,15 +47379,16 @@ const $y = CallbacksVisitor; class ExampleVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Zp()), (this.specPath = Tl(['document', 'objects', 'Example'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); return ( - Ru(this.element.externalValue) && this.element.classes.push('reference-element'), o + Ru(this.element.externalValue) && this.element.classes.push('reference-element'), + o ); } } @@ -47334,24 +47396,24 @@ const Uy = class ExternalValueVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class ExternalDocumentationVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Qp()), (this.specPath = Tl(['document', 'objects', 'ExternalDocumentation'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const zy = ExternalDocumentationVisitor; class encoding_EncodingVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Xp()), (this.specPath = Tl(['document', 'objects', 'Encoding'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47368,18 +47430,18 @@ class EncodingHeaders extends Cu.Sh { static primaryClass = 'encoding-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(EncodingHeaders.primaryClass); + (super(s, o, i), this.classes.push(EncodingHeaders.primaryClass)); } } const Ky = EncodingHeaders; class HeadersVisitor_HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ky()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47399,19 +47461,19 @@ const Hy = HeadersVisitor_HeadersVisitor; class PathsVisitor extends Mixin(ym, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Rh()), (this.specPath = Tl(['document', 'objects', 'PathItem'])), (this.canSupportSpecificationExtensions = !0), - (this.fieldPatternPredicate = es_T); + (this.fieldPatternPredicate = es_T)); } ObjectElement(s) { const o = ym.prototype.ObjectElement.call(this, s); return ( this.element.filter(Um).forEach((s, o) => { - o.classes.push('openapi-path-template'), + (o.classes.push('openapi-path-template'), o.classes.push('path-template'), - s.setMetaProperty('path', cloneDeep(o)); + s.setMetaProperty('path', cloneDeep(o))); }), o ); @@ -47420,9 +47482,9 @@ const Jy = PathsVisitor; class RequestBodyVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Lh()), - (this.specPath = Tl(['document', 'objects', 'RequestBody'])); + (this.specPath = Tl(['document', 'objects', 'RequestBody']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47439,24 +47501,25 @@ class RequestBodyContent extends Cu.Sh { static primaryClass = 'request-body-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(RequestBodyContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const Yy = RequestBodyContent; const Xy = class request_body_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new Yy()); + (super(s), (this.element = new Yy())); } }; class CallbackVisitor extends Mixin(ym, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Hp()), (this.specPath = Tl(['document', 'objects', 'PathItem'])), (this.canSupportSpecificationExtensions = !0), - (this.fieldPatternPredicate = (s) => /{(?[^}]{1,2083})}/.test(String(s))); + (this.fieldPatternPredicate = (s) => + /{(?[^}]{1,2083})}/.test(String(s)))); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47471,9 +47534,9 @@ const Zy = CallbackVisitor; class ResponseVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Fh()), - (this.specPath = Tl(['document', 'objects', 'Response'])); + (this.specPath = Tl(['document', 'objects', 'Response']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47494,18 +47557,18 @@ class ResponseHeaders extends Cu.Sh { static primaryClass = 'response-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ResponseHeaders.primaryClass); + (super(s, o, i), this.classes.push(ResponseHeaders.primaryClass)); } } const ev = ResponseHeaders; class response_HeadersVisitor_HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new ev()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47526,32 +47589,32 @@ class ResponseContent extends Cu.Sh { static primaryClass = 'response-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ResponseContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const rv = ResponseContent; const nv = class response_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new rv()); + (super(s), (this.element = new rv())); } }; class ResponseLinks extends Cu.Sh { static primaryClass = 'response-links'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ResponseLinks.primaryClass); + (super(s, o, i), this.classes.push(ResponseLinks.primaryClass)); } } const sv = ResponseLinks; class response_LinksVisitor_LinksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new sv()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Link']); + : ['document', 'objects', 'Link'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47573,9 +47636,8 @@ for ( var i = Array(s < o ? o - s : 0), u = s < 0 ? o + Math.abs(s) : o - s, _ = 0; _ < u; - ) - (i[_] = _ + s), (_ += 1); + ((i[_] = _ + s), (_ += 1)); return i; }); const av = iv; @@ -47599,7 +47661,7 @@ var w = s ? 1 : 0; return !!i._items[_][w] || (o && (i._items[_][w] = !0), !1); } - return o && (i._items[_] = s ? [!1, !0] : [!0, !1]), !1; + return (o && (i._items[_] = s ? [!1, !0] : [!0, !1]), !1); case 'function': return null !== i._nativeSet ? o @@ -47620,7 +47682,7 @@ } const lv = (function () { function _Set() { - (this._nativeSet = 'function' == typeof Set ? new Set() : null), (this._items = {}); + ((this._nativeSet = 'function' == typeof Set ? new Set() : null), (this._items = {})); } return ( (_Set.prototype.add = function (s) { @@ -47635,7 +47697,7 @@ var cv = _curry2(function difference(s, o) { for (var i = [], u = 0, _ = s.length, w = o.length, x = new lv(), C = 0; C < w; C += 1) x.add(o[C]); - for (; u < _; ) x.add(s[u]) && (i[i.length] = s[u]), (u += 1); + for (; u < _; ) (x.add(s[u]) && (i[i.length] = s[u]), (u += 1)); return i; }); const uv = cv; @@ -47643,18 +47705,18 @@ specPathFixedFields; specPathPatternedFields; constructor({ specPathFixedFields: s, specPathPatternedFields: o, ...i }) { - super({ ...i }), (this.specPathFixedFields = s), (this.specPathPatternedFields = o); + (super({ ...i }), (this.specPathFixedFields = s), (this.specPathPatternedFields = o)); } ObjectElement(s) { const { specPath: o, ignoredFields: i } = this; try { this.specPath = this.specPathFixedFields; const o = this.retrieveFixedFields(this.specPath(s)); - (this.ignoredFields = [...i, ...uv(s.keys(), o)]), + ((this.ignoredFields = [...i, ...uv(s.keys(), o)]), im.prototype.ObjectElement.call(this, s), (this.specPath = this.specPathPatternedFields), (this.ignoredFields = o), - ym.prototype.ObjectElement.call(this, s); + ym.prototype.ObjectElement.call(this, s)); } catch (s) { throw ((this.specPath = o), s); } @@ -47664,7 +47726,7 @@ const pv = MixedFieldsVisitor; class responses_ResponsesVisitor extends Mixin(pv, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Kh()), (this.specPathFixedFields = Tl(['document', 'objects', 'Responses'])), (this.canSupportSpecificationExtensions = !0), @@ -47673,7 +47735,7 @@ ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Response']), (this.fieldPatternPredicate = (s) => - new RegExp(`^(1XX|2XX|3XX|4XX|5XX|${av(100, 600).join('|')})$`).test(String(s))); + new RegExp(`^(1XX|2XX|3XX|4XX|5XX|${av(100, 600).join('|')})$`).test(String(s)))); } ObjectElement(s) { const o = pv.prototype.ObjectElement.call(this, s); @@ -47693,14 +47755,14 @@ const hv = responses_ResponsesVisitor; class DefaultVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Response'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); @@ -47715,39 +47777,39 @@ const dv = DefaultVisitor; class OperationVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new jh()), - (this.specPath = Tl(['document', 'objects', 'Operation'])); + (this.specPath = Tl(['document', 'objects', 'Operation']))); } } const fv = OperationVisitor; class OperationTags extends Cu.wE { static primaryClass = 'operation-tags'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OperationTags.primaryClass); + (super(s, o, i), this.classes.push(OperationTags.primaryClass)); } } const mv = OperationTags; const gv = class TagsVisitor extends rm { constructor(s) { - super(s), (this.element = new mv()); + (super(s), (this.element = new mv())); } ArrayElement(s) { - return (this.element = this.element.concat(cloneDeep(s))), Ju; + return ((this.element = this.element.concat(cloneDeep(s))), Ju); } }; class OperationParameters extends Cu.wE { static primaryClass = 'operation-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const yv = OperationParameters; class open_api_3_0_ParametersVisitor_ParametersVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('parameters'); + (super(s), (this.element = new Cu.wE()), this.element.classes.push('parameters')); } ArrayElement(s) { return ( @@ -47756,7 +47818,8 @@ ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Parameter'], i = this.toRefractedElement(o, s); - Wm(i) && i.setMetaProperty('referenced-element', 'parameter'), this.element.push(i); + (Wm(i) && i.setMetaProperty('referenced-element', 'parameter'), + this.element.push(i)); }), this.copyMetaAndAttributes(s, this.element), Ju @@ -47766,19 +47829,19 @@ const vv = open_api_3_0_ParametersVisitor_ParametersVisitor; const bv = class operation_ParametersVisitor_ParametersVisitor extends vv { constructor(s) { - super(s), (this.element = new yv()); + (super(s), (this.element = new yv())); } }; const _v = class RequestBodyVisitor_RequestBodyVisitor extends jm { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'RequestBody'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); @@ -47791,19 +47854,19 @@ class OperationCallbacks extends Cu.Sh { static primaryClass = 'operation-callbacks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OperationCallbacks.primaryClass); + (super(s, o, i), this.classes.push(OperationCallbacks.primaryClass)); } } const Ev = OperationCallbacks; class CallbacksVisitor_CallbacksVisitor extends Mixin(vm, rm) { specPath; constructor(s) { - super(s), + (super(s), (this.element = new Ev()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Callback']); + : ['document', 'objects', 'Callback'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47819,15 +47882,15 @@ class OperationSecurity extends Cu.wE { static primaryClass = 'operation-security'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationSecurity.primaryClass), - this.classes.push('security'); + this.classes.push('security')); } } const Sv = OperationSecurity; class SecurityVisitor_SecurityVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Sv()); + (super(s), (this.element = new Sv())); } ArrayElement(s) { return ( @@ -47845,30 +47908,30 @@ class OperationServers extends Cu.wE { static primaryClass = 'operation-servers'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationServers.primaryClass), - this.classes.push('servers'); + this.classes.push('servers')); } } const kv = OperationServers; const Cv = class ServersVisitor_ServersVisitor extends xm { constructor(s) { - super(s), (this.element = new kv()); + (super(s), (this.element = new kv())); } }; class PathItemVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ph()), - (this.specPath = Tl(['document', 'objects', 'PathItem'])); + (this.specPath = Tl(['document', 'objects', 'PathItem']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); return ( this.element.filter($m).forEach((s, o) => { const i = cloneDeep(o); - (i.content = serializers_value(i).toUpperCase()), - s.setMetaProperty('http-method', i); + ((i.content = serializers_value(i).toUpperCase()), + s.setMetaProperty('http-method', i)); }), Ru(this.element.$ref) && this.element.classes.push('reference-element'), o @@ -47879,87 +47942,87 @@ const Av = class path_item_$RefVisitor_$RefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class PathItemServers extends Cu.wE { static primaryClass = 'path-item-servers'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(PathItemServers.primaryClass), - this.classes.push('servers'); + this.classes.push('servers')); } } const jv = PathItemServers; const Iv = class path_item_ServersVisitor_ServersVisitor extends xm { constructor(s) { - super(s), (this.element = new jv()); + (super(s), (this.element = new jv())); } }; class PathItemParameters extends Cu.wE { static primaryClass = 'path-item-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(PathItemParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const Pv = PathItemParameters; const Mv = class path_item_ParametersVisitor_ParametersVisitor extends vv { constructor(s) { - super(s), (this.element = new Pv()); + (super(s), (this.element = new Pv())); } }; class SecuritySchemeVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Jf()), (this.specPath = Tl(['document', 'objects', 'SecurityScheme'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Tv = SecuritySchemeVisitor; class OAuthFlowsVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new _h()), (this.specPath = Tl(['document', 'objects', 'OAuthFlows'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Nv = OAuthFlowsVisitor; class OAuthFlowVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new vh()), (this.specPath = Tl(['document', 'objects', 'OAuthFlow'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Rv = OAuthFlowVisitor; class OAuthFlowScopes extends Cu.Sh { static primaryClass = 'oauth-flow-scopes'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OAuthFlowScopes.primaryClass); + (super(s, o, i), this.classes.push(OAuthFlowScopes.primaryClass)); } } const Dv = OAuthFlowScopes; class ScopesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new Dv()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new Dv()), (this.specPath = Tl(['value']))); } } const Lv = ScopesVisitor; class Tags extends Cu.wE { static primaryClass = 'tags'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Tags.primaryClass); + (super(s, o, i), this.classes.push(Tags.primaryClass)); } } const Bv = Tags; class TagsVisitor_TagsVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Bv()); + (super(s), (this.element = new Bv())); } ArrayElement(s) { return ( @@ -48397,7 +48460,7 @@ (s) => (o, i = {}) => es_refractor_refract(o, { specPath: s, ...i }); - (Hp.refract = es_refractor_createRefractor([ + ((Hp.refract = es_refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -48614,7 +48677,7 @@ 'objects', 'XML', '$visitor' - ])); + ]))); const Wv = class Callback_Callback extends Hp {}; const Kv = class Components_Components extends Jp { get pathItems() { @@ -48654,7 +48717,7 @@ class JsonSchemaDialect extends Cu.Om { static default = new JsonSchemaDialect('https://spec.openapis.org/oas/3.1/dialect/base'); constructor(s, o, i) { - super(s, o, i), (this.element = 'jsonSchemaDialect'); + (super(s, o, i), (this.element = 'jsonSchemaDialect')); } } const eb = JsonSchemaDialect; @@ -48680,7 +48743,7 @@ const _b = class Openapi_Openapi extends wh {}; class OpenApi3_1 extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'openApi3_1'), this.classes.push('api'); + (super(s, o, i), (this.element = 'openApi3_1'), this.classes.push('api')); } get openapi() { return this.get('openapi'); @@ -48812,7 +48875,7 @@ }; const Ib = class Paths_Paths extends Rh {}; class Reference_Reference extends Dh {} - Object.defineProperty(Reference_Reference.prototype, 'description', { + (Object.defineProperty(Reference_Reference.prototype, 'description', { get() { return this.get('description'); }, @@ -48829,14 +48892,14 @@ this.set('summary', s); }, enumerable: !0 - }); + })); const Pb = Reference_Reference; const Mb = class RequestBody_RequestBody extends Lh {}; const Rb = class elements_Response_Response extends Fh {}; const Lb = class Responses_Responses extends Kh {}; class elements_Schema_Schema extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'schema'); + (super(s, o, i), (this.element = 'schema')); } get $schema() { return this.get('$schema'); @@ -49214,14 +49277,14 @@ const n_ = class Xml_Xml extends em {}; class OpenApi3_1Visitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new wb()), (this.specPath = Tl(['document', 'objects', 'OpenApi'])), (this.canSupportSpecificationExtensions = !0), - (this.openApiSemanticElement = this.element); + (this.openApiSemanticElement = this.element)); } ObjectElement(s) { - return (this.openApiGenericElement = s), im.prototype.ObjectElement.call(this, s); + return ((this.openApiGenericElement = s), im.prototype.ObjectElement.call(this, s)); } } const s_ = OpenApi3_1Visitor, @@ -49236,7 +49299,7 @@ } = $v; const i_ = class info_InfoVisitor extends o_ { constructor(s) { - super(s), (this.element = new Qv()); + (super(s), (this.element = new Qv())); } }, { @@ -49250,7 +49313,7 @@ } = $v; const l_ = class contact_ContactVisitor extends a_ { constructor(s) { - super(s), (this.element = new Hv()); + (super(s), (this.element = new Hv())); } }, { @@ -49264,7 +49327,7 @@ } = $v; const u_ = class license_LicenseVisitor extends c_ { constructor(s) { - super(s), (this.element = new tb()); + (super(s), (this.element = new tb())); } }, { @@ -49278,13 +49341,13 @@ } = $v; const h_ = class link_LinkVisitor extends p_ { constructor(s) { - super(s), (this.element = new nb()); + (super(s), (this.element = new nb())); } }; class JsonSchemaDialectVisitor extends Mixin(nm, rm) { StringElement(s) { const o = new eb(serializers_value(s)); - return this.copyMetaAndAttributes(s, o), (this.element = o), Ju; + return (this.copyMetaAndAttributes(s, o), (this.element = o), Ju); } } const d_ = JsonSchemaDialectVisitor, @@ -49299,7 +49362,7 @@ } = $v; const m_ = class server_ServerVisitor extends f_ { constructor(s) { - super(s), (this.element = new e_()); + (super(s), (this.element = new e_())); } }, { @@ -49313,7 +49376,7 @@ } = $v; const y_ = class server_variable_ServerVariableVisitor extends g_ { constructor(s) { - super(s), (this.element = new t_()); + (super(s), (this.element = new t_())); } }, { @@ -49327,7 +49390,7 @@ } = $v; const b_ = class media_type_MediaTypeVisitor extends v_ { constructor(s) { - super(s), (this.element = new pb()); + (super(s), (this.element = new pb())); } }, { @@ -49341,7 +49404,7 @@ } = $v; const w_ = class security_requirement_SecurityRequirementVisitor extends E_ { constructor(s) { - super(s), (this.element = new zb()); + (super(s), (this.element = new zb())); } }, { @@ -49355,7 +49418,7 @@ } = $v; const x_ = class components_ComponentsVisitor extends S_ { constructor(s) { - super(s), (this.element = new Kv()); + (super(s), (this.element = new Kv())); } }, { @@ -49369,7 +49432,7 @@ } = $v; const C_ = class tag_TagVisitor extends k_ { constructor(s) { - super(s), (this.element = new r_()); + (super(s), (this.element = new r_())); } }, { @@ -49383,7 +49446,7 @@ } = $v; const A_ = class reference_ReferenceVisitor extends O_ { constructor(s) { - super(s), (this.element = new Pb()); + (super(s), (this.element = new Pb())); } }, { @@ -49397,7 +49460,7 @@ } = $v; const I_ = class parameter_ParameterVisitor extends j_ { constructor(s) { - super(s), (this.element = new Ob()); + (super(s), (this.element = new Ob())); } }, { @@ -49411,7 +49474,7 @@ } = $v; const M_ = class header_HeaderVisitor extends P_ { constructor(s) { - super(s), (this.element = new Zv()); + (super(s), (this.element = new Zv())); } }, T_ = helpers( @@ -49566,15 +49629,15 @@ }; class open_api_3_1_schema_SchemaVisitor extends Mixin(im, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new qb()), (this.specPath = Tl(['document', 'objects', 'Schema'])), (this.canSupportSpecificationExtensions = !0), (this.jsonSchemaDefaultDialect = eb.default), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ObjectElement(s) { - this.handle$schema(s), this.handle$id(s), (this.parent = this.element); + (this.handle$schema(s), this.handle$id(s), (this.parent = this.element)); const o = im.prototype.ObjectElement.call(this, s); return ( Ru(this.element.$ref) && @@ -49585,7 +49648,7 @@ } BooleanElement(s) { const o = super.enter(s); - return this.element.classes.push('boolean-json-schema'), o; + return (this.element.classes.push('boolean-json-schema'), o); } getJsonSchemaDialect() { let s; @@ -49618,38 +49681,38 @@ ? cloneDeep(this.parent.getMetaProperty('inherited$id', [])) : new Cu.wE(), i = serializers_value(s.get('$id')); - Vd(i) && o.push(i), this.element.setMetaProperty('inherited$id', o); + (Vd(i) && o.push(i), this.element.setMetaProperty('inherited$id', o)); } } const iE = open_api_3_1_schema_SchemaVisitor; const aE = class $vocabularyVisitor extends rm { ObjectElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-$vocabulary'), o; + return (this.element.classes.push('json-schema-$vocabulary'), o); } }; const lE = class $refVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class $defsVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-$defs'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const cE = $defsVisitor; class schema_AllOfVisitor_AllOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-allOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49670,10 +49733,10 @@ const uE = schema_AllOfVisitor_AllOfVisitor; class schema_AnyOfVisitor_AnyOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-anyOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49694,10 +49757,10 @@ const pE = schema_AnyOfVisitor_AnyOfVisitor; class schema_OneOfVisitor_OneOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-oneOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49718,20 +49781,20 @@ const hE = schema_OneOfVisitor_OneOfVisitor; class DependentSchemasVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-dependentSchemas'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const dE = DependentSchemasVisitor; class PrefixItemsVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-prefixItems'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49752,50 +49815,50 @@ const fE = PrefixItemsVisitor; class schema_PropertiesVisitor_PropertiesVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-properties'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const mE = schema_PropertiesVisitor_PropertiesVisitor; class PatternPropertiesVisitor_PatternPropertiesVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-patternProperties'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const gE = PatternPropertiesVisitor_PatternPropertiesVisitor; const yE = class schema_TypeVisitor_TypeVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } }; const vE = class EnumVisitor_EnumVisitor extends rm { ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-enum'), o; + return (this.element.classes.push('json-schema-enum'), o); } }; const bE = class DependentRequiredVisitor extends rm { ObjectElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-dependentRequired'), o; + return (this.element.classes.push('json-schema-dependentRequired'), o); } }; const _E = class schema_ExamplesVisitor_ExamplesVisitor extends rm { ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-examples'), o; + return (this.element.classes.push('json-schema-examples'), o); } }, { @@ -49809,7 +49872,7 @@ } = $v; const wE = class distriminator_DiscriminatorVisitor extends EE { constructor(s) { - super(s), (this.element = new Jv()), (this.canSupportSpecificationExtensions = !0); + (super(s), (this.element = new Jv()), (this.canSupportSpecificationExtensions = !0)); } }, { @@ -49823,32 +49886,32 @@ } = $v; const xE = class xml_XmlVisitor extends SE { constructor(s) { - super(s), (this.element = new n_()); + (super(s), (this.element = new n_())); } }; class SchemasVisitor_SchemasVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new xy()), - (this.specPath = Tl(['document', 'objects', 'Schema'])); + (this.specPath = Tl(['document', 'objects', 'Schema']))); } } const kE = SchemasVisitor_SchemasVisitor; class ComponentsPathItems extends Cu.Sh { static primaryClass = 'components-path-items'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsPathItems.primaryClass); + (super(s, o, i), this.classes.push(ComponentsPathItems.primaryClass)); } } const CE = ComponentsPathItems; class PathItemsVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new CE()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -49872,7 +49935,7 @@ } = $v; const jE = class example_ExampleVisitor extends AE { constructor(s) { - super(s), (this.element = new Yv()); + (super(s), (this.element = new Yv())); } }, { @@ -49886,7 +49949,7 @@ } = $v; const PE = class external_documentation_ExternalDocumentationVisitor extends IE { constructor(s) { - super(s), (this.element = new Xv()); + (super(s), (this.element = new Xv())); } }, { @@ -49900,7 +49963,7 @@ } = $v; const TE = class open_api_3_1_encoding_EncodingVisitor extends ME { constructor(s) { - super(s), (this.element = new Gv()); + (super(s), (this.element = new Gv())); } }, { @@ -49914,7 +49977,7 @@ } = $v; const RE = class paths_PathsVisitor extends NE { constructor(s) { - super(s), (this.element = new Ib()); + (super(s), (this.element = new Ib())); } }, { @@ -49928,7 +49991,7 @@ } = $v; const LE = class request_body_RequestBodyVisitor extends DE { constructor(s) { - super(s), (this.element = new Mb()); + (super(s), (this.element = new Mb())); } }, { @@ -49942,12 +50005,12 @@ } = $v; const FE = class callback_CallbackVisitor extends BE { constructor(s) { - super(s), + (super(s), (this.element = new Wv()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = BE.prototype.ObjectElement.call(this, s); @@ -49970,7 +50033,7 @@ } = $v; const $E = class response_ResponseVisitor extends qE { constructor(s) { - super(s), (this.element = new Rb()); + (super(s), (this.element = new Rb())); } }, { @@ -49984,7 +50047,7 @@ } = $v; const UE = class open_api_3_1_responses_ResponsesVisitor extends VE { constructor(s) { - super(s), (this.element = new Lb()); + (super(s), (this.element = new Lb())); } }, { @@ -49998,7 +50061,7 @@ } = $v; const WE = class operation_OperationVisitor extends zE { constructor(s) { - super(s), (this.element = new Sb()); + (super(s), (this.element = new Sb())); } }, { @@ -50012,7 +50075,7 @@ } = $v; const HE = class path_item_PathItemVisitor extends KE { constructor(s) { - super(s), (this.element = new Ab()); + (super(s), (this.element = new Ab())); } }, { @@ -50026,7 +50089,7 @@ } = $v; const GE = class security_scheme_SecuritySchemeVisitor extends JE { constructor(s) { - super(s), (this.element = new Qb()); + (super(s), (this.element = new Qb())); } }, { @@ -50040,7 +50103,7 @@ } = $v; const XE = class oauth_flows_OAuthFlowsVisitor extends YE { constructor(s) { - super(s), (this.element = new yb()); + (super(s), (this.element = new yb())); } }, { @@ -50054,24 +50117,24 @@ } = $v; const QE = class oauth_flow_OAuthFlowVisitor extends ZE { constructor(s) { - super(s), (this.element = new mb()); + (super(s), (this.element = new mb())); } }; class Webhooks extends Cu.Sh { static primaryClass = 'webhooks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Webhooks.primaryClass); + (super(s, o, i), this.classes.push(Webhooks.primaryClass)); } } const ew = Webhooks; class WebhooksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new ew()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -50585,7 +50648,7 @@ (s) => (o, i = {}) => apidom_ns_openapi_3_1_es_refractor_refract(o, { specPath: s, ...i }); - (Wv.refract = apidom_ns_openapi_3_1_es_refractor_createRefractor([ + ((Wv.refract = apidom_ns_openapi_3_1_es_refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -50810,7 +50873,7 @@ 'objects', 'XML', '$visitor' - ])); + ]))); const iw = class NotImplementedError extends Hh {}; const aw = class MediaTypes extends Array { unknownMediaType = 'application/octet-stream'; @@ -50852,11 +50915,11 @@ refSet; errors; constructor({ uri: s, depth: o = 0, refSet: i, value: u }) { - (this.uri = s), + ((this.uri = s), (this.value = u), (this.depth = o), (this.refSet = i), - (this.errors = []); + (this.errors = [])); } }; const uw = class ReferenceSet { @@ -50864,7 +50927,7 @@ refs; circular; constructor({ refs: s = [], circular: o = !1 } = {}) { - (this.refs = []), (this.circular = o), s.forEach(this.add.bind(this)); + ((this.refs = []), (this.circular = o), s.forEach(this.add.bind(this))); } get size() { return this.refs.length; @@ -50893,11 +50956,11 @@ yield* this.refs; } clean() { - this.refs.forEach((s) => { + (this.refs.forEach((s) => { s.refSet = void 0; }), (this.rootRef = void 0), - (this.refs.length = 0); + (this.refs.length = 0)); } }, pw = { @@ -50945,11 +51008,11 @@ return (function _assoc(s, o, i) { if (Yo(s) && aa(i)) { var u = [].concat(i); - return (u[s] = o), u; + return ((u[s] = o), u); } var _ = {}; for (var w in i) _[w] = i[w]; - return (_[s] = o), _; + return ((_[s] = o), _); })(u, o, i); }); const fw = dw; @@ -50979,7 +51042,7 @@ data; parseResult; constructor({ uri: s, mediaType: o = 'text/plain', data: i, parseResult: u }) { - (this.uri = s), (this.mediaType = o), (this.data = i), (this.parseResult = u); + ((this.uri = s), (this.mediaType = o), (this.data = i), (this.parseResult = u)); } get extension() { return Yl(this.uri) @@ -51004,7 +51067,7 @@ const bw = class PluginError extends Ho { plugin; constructor(s, o) { - super(s, { cause: o.cause }), (this.plugin = o.plugin); + (super(s, { cause: o.cause }), (this.plugin = o.plugin)); } }, plugins_filter = async (s, o, i) => { @@ -51029,7 +51092,7 @@ u = !1; if (!Ku(s)) { const o = cloneShallow(s); - o.classes.push('result'), (i = new Mu([o])), (u = !0); + (o.classes.push('result'), (i = new Mu([o])), (u = !0)); } const _ = new vw({ uri: o.resolve.baseURI, @@ -51060,11 +51123,11 @@ fileExtensions: u = [], mediaTypes: _ = [] }) { - (this.name = s), + ((this.name = s), (this.allowEmpty = o), (this.sourceMap = i), (this.fileExtensions = u), - (this.mediaTypes = _); + (this.mediaTypes = _)); } }; const kw = class BinaryParser extends xw { @@ -51081,7 +51144,7 @@ u = new Mu(); if (0 !== i.length) { const s = new Cu.Om(i); - s.classes.push('result'), u.push(s); + (s.classes.push('result'), u.push(s)); } return u; } catch (o) { @@ -51108,7 +51171,7 @@ if (void 0 === i) throw new Ew('"openapi-3-1" dereference strategy is not available.'); const u = new uw(), _ = util_merge(o, { resolve: { internal: !1 }, dereference: { refSet: u } }); - return await i.dereference(s, _), u; + return (await i.dereference(s, _), u); } }; const Aw = class Resolver { @@ -51128,10 +51191,10 @@ redirects: u = 5, withCredentials: _ = !1 } = null != s ? s : {}; - super({ name: o }), + (super({ name: o }), (this.timeout = i), (this.redirects = u), - (this.withCredentials = _); + (this.withCredentials = _)); } canRead(s) { return isHttpUrl(s.uri); @@ -51140,8 +51203,8 @@ const Iw = class ResolveError extends Ho {}; const Pw = class ResolverError extends Iw {}, { AbortController: Mw, AbortSignal: Tw } = globalThis; - void 0 === globalThis.AbortController && (globalThis.AbortController = Mw), - void 0 === globalThis.AbortSignal && (globalThis.AbortSignal = Tw); + (void 0 === globalThis.AbortController && (globalThis.AbortController = Mw), + void 0 === globalThis.AbortSignal && (globalThis.AbortSignal = Tw)); const Nw = class HTTPResolverSwaggerClient extends jw { swaggerHTTPClient = http_http; swaggerHTTPClientConfig; @@ -51150,9 +51213,9 @@ swaggerHTTPClientConfig: o = {}, ...i } = {}) { - super({ ...i, name: 'http-swagger-client' }), + (super({ ...i, name: 'http-swagger-client' }), (this.swaggerHTTPClient = s), - (this.swaggerHTTPClientConfig = o); + (this.swaggerHTTPClientConfig = o)); } getHttpClient() { return this.swaggerHTTPClient; @@ -51180,8 +51243,8 @@ try { i.headers.delete('Content-Type'); } catch { - (i = new Response(i.body, { ...i, headers: new Headers(i.headers) })), - i.headers.delete('Content-Type'); + ((i = new Response(i.body, { ...i, headers: new Headers(i.headers) })), + i.headers.delete('Content-Type')); } return i; }, @@ -51216,7 +51279,7 @@ if (i) return !0; if (!i) try { - return JSON.parse(s.toString()), !0; + return (JSON.parse(s.toString()), !0); } catch (s) { return !1; } @@ -51230,7 +51293,7 @@ if (this.allowEmpty && '' === i.trim()) return o; try { const s = from(JSON.parse(i)); - return s.classes.push('result'), o.push(s), o; + return (s.classes.push('result'), o.push(s), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51251,7 +51314,7 @@ if (i) return !0; if (!i) try { - return mn.load(s.toString(), { schema: nn }), !0; + return (mn.load(s.toString(), { schema: nn }), !0); } catch (s) { return !1; } @@ -51268,7 +51331,7 @@ const s = mn.load(i, { schema: nn }); if (this.allowEmpty && void 0 === s) return o; const u = from(s); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51294,7 +51357,7 @@ if (!i) try { const o = s.toString(); - return JSON.parse(o), this.detectionRegExp.test(o); + return (JSON.parse(o), this.detectionRegExp.test(o)); } catch (s) { return !1; } @@ -51311,7 +51374,7 @@ try { const s = JSON.parse(i), u = wb.refract(s, this.refractorOpts); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51338,7 +51401,7 @@ if (!i) try { const o = s.toString(); - return mn.load(o), this.detectionRegExp.test(o); + return (mn.load(o), this.detectionRegExp.test(o)); } catch (s) { return !1; } @@ -51355,7 +51418,7 @@ const s = mn.load(i, { schema: nn }); if (this.allowEmpty && void 0 === s) return o; const u = wb.refract(s, this.refractorOpts); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51378,14 +51441,14 @@ const zw = class ElementIdentityError extends Jo { value; constructor(s, o) { - super(s, o), void 0 !== o && (this.value = o.value); + (super(s, o), void 0 !== o && (this.value = o.value)); } }; class IdentityManager { uuid; identityMap; constructor({ length: s = 6 } = {}) { - (this.uuid = new Uw({ length: s })), (this.identityMap = new WeakMap()); + ((this.uuid = new Uw({ length: s })), (this.identityMap = new WeakMap())); } identify(s) { if (!Nu(s)) @@ -51397,7 +51460,7 @@ return s.id; if (this.identityMap.has(s)) return this.identityMap.get(s); const o = new Cu.Om(this.generateId()); - return this.identityMap.set(s, o), o; + return (this.identityMap.set(s, o), o); } forget(s) { return !!this.identityMap.has(s) && (this.identityMap.delete(s), !0); @@ -51412,7 +51475,7 @@ }), traversal_find = (s, o) => { const i = new PredicateVisitor({ predicate: s, returnOnTrue: Ju }); - return visitor_visit(o, i), Ww(void 0, [0], i.result); + return (visitor_visit(o, i), Ww(void 0, [0], i.result)); }; const Kw = class JsonSchema$anchorError extends Ho {}; const Hw = class EvaluationJsonSchema$anchorError extends Kw {}; @@ -51437,7 +51500,7 @@ }, traversal_filter = (s, o) => { const i = new PredicateVisitor({ predicate: s }); - return visitor_visit(o, i), new Cu.G6(i.result); + return (visitor_visit(o, i), new Cu.G6(i.result)); }; const Gw = class JsonSchemaUriError extends Ho {}; const Yw = class EvaluationJsonSchemaUriError extends Gw {}, @@ -51454,7 +51517,7 @@ refractToSchemaElement = (s) => { if (refractToSchemaElement.cache.has(s)) return refractToSchemaElement.cache.get(s); const o = qb.refract(s); - return refractToSchemaElement.cache.set(s, o), o; + return (refractToSchemaElement.cache.set(s, o), o); }; refractToSchemaElement.cache = new WeakMap(); const maybeRefractToSchemaElement = (s) => @@ -51555,12 +51618,12 @@ ancestors: _ = new AncestorLineage(), refractCache: w = new Map() }) { - (this.indirections = u), + ((this.indirections = u), (this.namespace = o), (this.reference = s), (this.options = i), (this.ancestors = new AncestorLineage(..._)), - (this.refractCache = w); + (this.refractCache = w)); } toBaseURI(s) { return resolve(this.reference.uri, sanitize(stripHash(s))); @@ -51610,11 +51673,11 @@ i = `${o}-${serializers_value(tS.identify(z))}`; if (this.refractCache.has(i)) z = this.refractCache.get(i); else if (isReferenceLikeElement(z)) - (z = Pb.refract(z)), + ((z = Pb.refract(z)), z.setMetaProperty('referenced-element', o), - this.refractCache.set(i, z); + this.refractCache.set(i, z)); else { - (z = this.namespace.getElementClass(o).refract(z)), this.refractCache.set(i, z); + ((z = this.namespace.getElementClass(o).refract(z)), this.refractCache.set(i, z)); } } if (s === z) throw new Ho('Recursive Reference Object detected'); @@ -51642,7 +51705,7 @@ ? Y : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const ee = stripHash($.refSet.rootRef.uri) !== $.uri, @@ -51657,11 +51720,11 @@ refractCache: this.refractCache, ancestors: x }); - (z = await eS(z, o, { + ((z = await eS(z, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } this.indirections.pop(); const ae = cloneShallow(z); @@ -51731,7 +51794,7 @@ ? Y : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const ee = stripHash($.refSet.rootRef.uri) !== $.uri, @@ -51746,25 +51809,25 @@ refractCache: this.refractCache, ancestors: x }); - (z = await eS(z, o, { + ((z = await eS(z, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } if ((this.indirections.pop(), H_(z))) { const o = new Ab([...z.content], cloneDeep(z.meta), cloneDeep(z.attributes)); - o.setMetaProperty('id', tS.generateId()), + (o.setMetaProperty('id', tS.generateId()), s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), o.setMetaProperty('ref-origin', $.uri), o.setMetaProperty('ref-referencing-element-id', cloneDeep(tS.identify(s))), - (z = o); + (z = o)); } - return w.replaceWith(z, mutationReplacer), i ? void 0 : z; + return (w.replaceWith(z, mutationReplacer), i ? void 0 : z); } async LinkElement(s, o, i, u, _, w) { if (!Ru(s.operationRef) && !Ru(s.operationId)) return; @@ -51788,7 +51851,7 @@ ? (x = this.refractCache.get(s)) : ((x = Sb.refract(x)), this.refractCache.set(s, x)); } - (x = cloneShallow(x)), x.setMetaProperty('ref-origin', L.uri); + ((x = cloneShallow(x)), x.setMetaProperty('ref-origin', L.uri)); const B = cloneShallow(s); return ( null === (C = B.operationRef) || void 0 === C || C.meta.set('operation', x), @@ -51829,7 +51892,7 @@ B = cloneShallow(L.value.result); B.setMetaProperty('ref-origin', L.uri); const $ = cloneShallow(s); - return ($.value = B), w.replaceWith($, mutationReplacer), i ? void 0 : $; + return (($.value = B), w.replaceWith($, mutationReplacer), i ? void 0 : $); } async SchemaElement(s, o, i, u, _, w) { if (!Ru(s.$ref)) return; @@ -51871,9 +51934,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToPointer(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = es_evaluate(s, o)), + ((Y = es_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } } catch (s) { if (!(z && s instanceof Yw)) throw s; @@ -51888,9 +51951,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToAnchor(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = $anchor_evaluate(s, o)), + ((Y = $anchor_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } else { if ( ((L = this.toBaseURI(B)), @@ -51903,9 +51966,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToPointer(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = es_evaluate(s, o)), + ((Y = es_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } } if (s === Y) throw new Ho('Recursive Schema Object reference detected'); @@ -51933,7 +51996,7 @@ ? ie : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const le = stripHash(j.refSet.rootRef.uri) !== j.uri, @@ -51948,11 +52011,11 @@ refractCache: this.refractCache, ancestors: x }); - (Y = await eS(Y, o, { + ((Y = await eS(Y, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } if ((this.indirections.pop(), predicates_isBooleanJsonSchemaElement(Y))) { const o = cloneDeep(Y); @@ -51967,17 +52030,17 @@ } if (Q_(Y)) { const o = new qb([...Y.content], cloneDeep(Y.meta), cloneDeep(Y.attributes)); - o.setMetaProperty('id', tS.generateId()), + (o.setMetaProperty('id', tS.generateId()), s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), o.setMetaProperty('ref-origin', j.uri), o.setMetaProperty('ref-referencing-element-id', cloneDeep(tS.identify(s))), - (Y = o); + (Y = o)); } - return w.replaceWith(Y, mutationReplacer), i ? void 0 : Y; + return (w.replaceWith(Y, mutationReplacer), i ? void 0 : Y); } } const rS = OpenAPI3_1DereferenceVisitor, @@ -51999,7 +52062,7 @@ w = new uw(); let x, C = _; - _.has(s.uri) + (_.has(s.uri) ? (x = _.find(Fw(s.uri, 'uri'))) : ((x = new cw({ uri: s.uri, value: s.parseResult })), _.add(x)), o.dereference.immutable && @@ -52007,7 +52070,7 @@ .map((s) => new cw({ ...s, value: cloneDeep(s.value) })) .forEach((s) => w.add(s)), (x = w.find((o) => o.uri === s.uri)), - (C = w)); + (C = w))); const j = new rS({ reference: x, namespace: u, options: o }), L = await nS(C.rootRef.value, j, { keyMap: nw, @@ -52053,20 +52116,20 @@ } catch (o) { var u, w; const x = new Error(o, { cause: o }); - (x.fullPath = [...to_path([..._, i, s]), 'properties']), + ((x.fullPath = [...to_path([..._, i, s]), 'properties']), null === (u = this.options.dereference.dereferenceOpts) || void 0 === u || null === (u = u.errors) || void 0 === u || null === (w = u.push) || void 0 === w || - w.call(u, x); + w.call(u, x)); } }); } }; constructor({ modelPropertyMacro: s, options: o }) { - (this.modelPropertyMacro = s), (this.options = o); + ((this.modelPropertyMacro = s), (this.options = o)); } }; const iS = class all_of_AllOfVisitor { @@ -52149,19 +52212,19 @@ } catch (s) { var C, j; const o = new Error(s, { cause: s }); - (o.fullPath = to_path([..._, i])), + ((o.fullPath = to_path([..._, i])), null === (C = this.options.dereference.dereferenceOpts) || void 0 === C || null === (C = C.errors) || void 0 === C || null === (j = C.push) || void 0 === j || - j.call(C, o); + j.call(C, o)); } } }; constructor({ parameterMacro: s, options: o }) { - (this.parameterMacro = s), (this.options = o); + ((this.parameterMacro = s), (this.options = o)); } }, get_root_cause = (s) => { @@ -52187,10 +52250,10 @@ basePath: i = null, ...u }) { - super(u), + (super(u), (this.allowMetaPatches = s), (this.useCircularStructures = o), - (this.basePath = i); + (this.basePath = i)); } async ReferenceElement(s, o, i, u, _, w) { try { @@ -52211,11 +52274,11 @@ i = `${o}-${serializers_value(pS.identify(Y))}`; if (this.refractCache.has(i)) Y = this.refractCache.get(i); else if (isReferenceLikeElement(Y)) - (Y = Pb.refract(Y)), + ((Y = Pb.refract(Y)), Y.setMetaProperty('referenced-element', o), - this.refractCache.set(i, Y); + this.refractCache.set(i, Y)); else { - (Y = this.namespace.getElementClass(o).refract(Y)), this.refractCache.set(i, Y); + ((Y = this.namespace.getElementClass(o).refract(Y)), this.refractCache.set(i, Y)); } } if (s === Y) throw new Ho('Recursive Reference Object detected'); @@ -52245,7 +52308,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(o, dereference_mutationReplacer), !i && u; + return (w.replaceWith(o, dereference_mutationReplacer), !i && u); } } const Z = stripHash(V.refSet.rootRef.uri) !== V.uri, @@ -52267,11 +52330,11 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Y = await uS(Y, w, { + ((Y = await uS(Y, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } this.indirections.pop(); const ie = cloneShallow(Y); @@ -52295,7 +52358,7 @@ const s = resolve(L, U); ie.set('$$ref', s); } - return w.replaceWith(ie, dereference_mutationReplacer), !i && ie; + return (w.replaceWith(ie, dereference_mutationReplacer), !i && ie); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52368,7 +52431,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(o, dereference_mutationReplacer), !i && u; + return (w.replaceWith(o, dereference_mutationReplacer), !i && u); } } const Z = stripHash(V.refSet.rootRef.uri) !== V.uri, @@ -52389,17 +52452,17 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Y = await uS(Y, w, { + ((Y = await uS(Y, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } if ((this.indirections.pop(), H_(Y))) { const o = new Ab([...Y.content], cloneDeep(Y.meta), cloneDeep(Y.attributes)); if ( (s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), @@ -52412,7 +52475,7 @@ } Y = o; } - return w.replaceWith(Y, dereference_mutationReplacer), i ? void 0 : Y; + return (w.replaceWith(Y, dereference_mutationReplacer), i ? void 0 : Y); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52477,9 +52540,9 @@ L = await this.toReference(unsanitize($)); const s = uriToPointer($), o = maybeRefractToSchemaElement(L.value.result); - (Z = es_evaluate(s, o)), + ((Z = es_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } } catch (s) { if (!(Y && s instanceof Yw)) throw s; @@ -52494,9 +52557,9 @@ L = await this.toReference(unsanitize($)); const s = uriToAnchor($), o = maybeRefractToSchemaElement(L.value.result); - (Z = $anchor_evaluate(s, o)), + ((Z = $anchor_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } else { if ( ((B = this.toBaseURI(serializers_value($))), @@ -52509,9 +52572,9 @@ L = await this.toReference(unsanitize($)); const s = uriToPointer($), o = maybeRefractToSchemaElement(L.value.result); - (Z = es_evaluate(s, o)), + ((Z = es_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } } if (s === Z) throw new Ho('Recursive Schema Object reference detected'); @@ -52541,7 +52604,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, dereference_mutationReplacer), !i && u; + return (w.replaceWith(u, dereference_mutationReplacer), !i && u); } } const ae = stripHash(L.refSet.rootRef.uri) !== L.uri, @@ -52562,11 +52625,11 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Z = await uS(Z, w, { + ((Z = await uS(Z, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } if ((this.indirections.pop(), predicates_isBooleanJsonSchemaElement(Z))) { const o = cloneDeep(Z); @@ -52582,7 +52645,7 @@ const o = new qb([...Z.content], cloneDeep(Z.meta), cloneDeep(Z.attributes)); if ( (s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), @@ -52595,7 +52658,7 @@ } Z = o; } - return w.replaceWith(Z, dereference_mutationReplacer), i ? void 0 : Z; + return (w.replaceWith(Z, dereference_mutationReplacer), i ? void 0 : Z); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52651,10 +52714,10 @@ const fS = class RootVisitor { constructor({ parameterMacro: s, modelPropertyMacro: o, mode: i, options: u, ..._ }) { const w = []; - w.push(new hS({ ..._, options: u })), + (w.push(new hS({ ..._, options: u })), 'function' == typeof o && w.push(new oS({ modelPropertyMacro: o, options: u })), 'strict' !== i && w.push(new iS({ options: u })), - 'function' == typeof s && w.push(new aS({ parameterMacro: s, options: u })); + 'function' == typeof s && w.push(new aS({ parameterMacro: s, options: u }))); const x = dS(w, { nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType }); @@ -52676,13 +52739,13 @@ ancestors: _ = [], ...w } = {}) { - super({ ...w }), + (super({ ...w }), (this.name = 'openapi-3-1-swagger-client'), (this.allowMetaPatches = s), (this.parameterMacro = o), (this.modelPropertyMacro = i), (this.mode = u), - (this.ancestors = [..._]); + (this.ancestors = [..._])); } async dereference(s, o) { var i; @@ -52691,7 +52754,7 @@ w = new uw(); let x, C = _; - _.has(s.uri) + (_.has(s.uri) ? (x = _.find((o) => o.uri === s.uri)) : ((x = new cw({ uri: s.uri, value: s.parseResult })), _.add(x)), o.dereference.immutable && @@ -52699,7 +52762,7 @@ .map((s) => new cw({ ...s, value: cloneDeep(s.value) })) .forEach((s) => w.add(s)), (x = w.find((o) => o.uri === s.uri)), - (C = w)); + (C = w))); const j = new fS({ reference: x, namespace: u, @@ -52860,13 +52923,13 @@ } var vS = (function () { function _ObjectMap() { - (this.map = {}), (this.length = 0); + ((this.map = {}), (this.length = 0)); } return ( (_ObjectMap.prototype.set = function (s, o) { var i = this.hash(s), u = this.map[i]; - u || (this.map[i] = u = []), u.push([s, o]), (this.length += 1); + (u || (this.map[i] = u = []), u.push([s, o]), (this.length += 1)); }), (_ObjectMap.prototype.hash = function (s) { var o = []; @@ -52893,11 +52956,11 @@ })(), bS = (function () { function XReduceBy(s, o, i, u) { - (this.valueFn = s), + ((this.valueFn = s), (this.valueAcc = o), (this.keyFn = i), (this.xf = u), - (this.inputs = {}); + (this.inputs = {})); } return ( (XReduceBy.prototype['@@transducer/init'] = _xfBase_init), @@ -52911,7 +52974,7 @@ s = s['@@transducer/value']; break; } - return (this.inputs = null), this.xf['@@transducer/result'](s); + return ((this.inputs = null), this.xf['@@transducer/result'](s)); }), (XReduceBy.prototype['@@transducer/step'] = function (s, o) { var i = this.keyFn(o); @@ -52945,22 +53008,22 @@ _checkForMethod( 'groupBy', _S(function (s, o) { - return s.push(o), s; + return (s.push(o), s); }, []) ) ); const wS = class NormalizeStorage { internalStore; constructor(s, o, i) { - (this.storageElement = s), (this.storageField = o), (this.storageSubField = i); + ((this.storageElement = s), (this.storageField = o), (this.storageSubField = i)); } get store() { if (!this.internalStore) { let s = this.storageElement.get(this.storageField); Fu(s) || ((s = new Cu.Sh()), this.storageElement.set(this.storageField, s)); let o = s.get(this.storageSubField); - qu(o) || ((o = new Cu.wE()), s.set(this.storageSubField, o)), - (this.internalStore = o); + (qu(o) || ((o = new Cu.wE()), s.set(this.storageSubField, o)), + (this.internalStore = o)); } return this.internalStore; } @@ -53002,7 +53065,7 @@ }, leave() { const s = ES((s) => serializers_value(s.operationId), C); - Object.entries(s).forEach(([s, o]) => { + (Object.entries(s).forEach(([s, o]) => { Array.isArray(o) && (o.length <= 1 || o.forEach((o, i) => { @@ -53023,7 +53086,7 @@ }), (C.length = 0), (j.length = 0), - (L = void 0); + (L = void 0)); } }, PathItemElement: { @@ -53062,7 +53125,7 @@ }; var SS = (function () { function XUniqWith(s, o) { - (this.xf = o), (this.pred = s), (this.items = []); + ((this.xf = o), (this.pred = s), (this.items = [])); } return ( (XUniqWith.prototype['@@transducer/init'] = _xfBase_init), @@ -53083,7 +53146,7 @@ var xS = _curry2( _dispatchable([], _xuniqWith, function (s, o) { for (var i, u = 0, _ = o.length, w = []; u < _; ) - _includesWith(s, (i = o[u]), w) || (w[w.length] = i), (u += 1); + (_includesWith(s, (i = o[u]), w) || (w[w.length] = i), (u += 1)); return w; }) ); @@ -53131,7 +53194,7 @@ if (w.includes(L)) return; const B = Ww([], ['parameters', 'content'], s), $ = kS(parameterEquals, [...B, ...j]); - (s.parameters = new yv($)), w.append(L); + ((s.parameters = new yv($)), w.append(L)); } } } @@ -53146,11 +53209,11 @@ visitor: { OpenApi3_1Element: { enter(o) { - (w = new wS(o, s, 'security-requirements')), - i.isArrayElement(o.security) && (_ = o.security); + ((w = new wS(o, s, 'security-requirements')), + i.isArrayElement(o.security) && (_ = o.security)); }, leave() { - (w = void 0), (_ = void 0); + ((w = void 0), (_ = void 0)); } }, OperationElement: { @@ -53299,9 +53362,9 @@ i.classes.push('result'); const u = o(i), _ = serializers_value(u); - return yS.cache.set(_, u), serializers_value(u); + return (yS.cache.set(_, u), serializers_value(u)); })(s); - return (i.$$normalized = !0), i; + return ((i.$$normalized = !0), i); } var o; return Nu(s) ? openapi_3_1_apidom_normalize(s) : s; @@ -53327,7 +53390,7 @@ o = MS, i = this, u = 'parser.js: Parser(): '; - (i.ast = void 0), (i.stats = void 0), (i.trace = void 0), (i.callbacks = []); + ((i.ast = void 0), (i.stats = void 0), (i.trace = void 0), (i.callbacks = [])); let _, w, x, @@ -53341,15 +53404,15 @@ z = 0, Y = 0, Z = new (function systemData() { - (this.state = s.ACTIVE), + ((this.state = s.ACTIVE), (this.phraseLength = 0), (this.refresh = () => { - (this.state = s.ACTIVE), (this.phraseLength = 0); - }); + ((this.state = s.ACTIVE), (this.phraseLength = 0)); + })); })(); i.parse = (ee, ie, ae, le) => { const ce = `${u}parse(): `; - ($ = 0), + (($ = 0), (V = 0), (U = 0), (z = 0), @@ -53364,7 +53427,7 @@ (B = void 0), (C = o.stringToChars(ae)), (_ = ee.rules), - (w = ee.udts); + (w = ee.udts)); const pe = ie.toLowerCase(); let de; for (const s in _) @@ -53374,7 +53437,7 @@ } if (void 0 === de) throw new Error(`${ce}start rule name '${startRule}' not recognized`); - (() => { + ((() => { const s = `${u}initializeCallbacks(): `; let o, x; for (j = [], L = [], o = 0; o < _.length; o += 1) j[o] = void 0; @@ -53402,7 +53465,7 @@ (B = le), (x = [{ type: s.RNM, index: de }]), opExecute(0, 0), - (x = void 0); + (x = void 0)); let fe = !1; switch (Z.state) { case s.ACTIVE: @@ -53432,9 +53495,9 @@ if (i.phraseLength > _) { let s = `${u}opRNM(${o.name}): callback function error: `; throw ( - ((s += `sysData.phraseLength: ${i.phraseLength}`), + (s += `sysData.phraseLength: ${i.phraseLength}`), (s += ` must be <= remaining chars: ${_}`), - new Error(s)) + new Error(s) ); } switch (i.state) { @@ -53463,20 +53526,20 @@ let V, U, z; const Y = x[o], ee = w[Y.index]; - (Z.UdtIndex = ee.index), + ((Z.UdtIndex = ee.index), $ || ((z = i.ast && i.ast.udtDefined(Y.index)), z && - ((U = _.length + Y.index), (V = i.ast.getLength()), i.ast.down(U, ee.name))); + ((U = _.length + Y.index), (V = i.ast.getLength()), i.ast.down(U, ee.name)))); const ie = C.length - j; - L[Y.index](Z, C, j, B), + (L[Y.index](Z, C, j, B), ((o, i, _) => { if (i.phraseLength > _) { let s = `${u}opUDT(${o.name}): callback function error: `; throw ( - ((s += `sysData.phraseLength: ${i.phraseLength}`), + (s += `sysData.phraseLength: ${i.phraseLength}`), (s += ` must be <= remaining chars: ${_}`), - new Error(s)) + new Error(s) ); } switch (i.state) { @@ -53506,7 +53569,7 @@ (z && (Z.state === s.NOMATCH ? i.ast.setLength(V) - : i.ast.up(U, ee.name, j, Z.phraseLength))); + : i.ast.up(U, ee.name, j, Z.phraseLength)))); }, opExecute = (o, w) => { const L = `${u}opExecute(): `, @@ -53534,13 +53597,13 @@ ((o, u) => { let _, w, C, j; const L = x[o]; - i.ast && (w = i.ast.getLength()), (_ = !0), (C = u), (j = 0); + (i.ast && (w = i.ast.getLength()), (_ = !0), (C = u), (j = 0)); for (let o = 0; o < L.children.length; o += 1) { if ((opExecute(L.children[o], C), Z.state === s.NOMATCH)) { _ = !1; break; } - (C += Z.phraseLength), (j += Z.phraseLength); + ((C += Z.phraseLength), (j += Z.phraseLength)); } _ ? ((Z.state = 0 === j ? s.EMPTY : s.MATCH), (Z.phraseLength = j)) @@ -53553,14 +53616,13 @@ ((o, u) => { let _, w, j, L; const B = x[o]; - if (0 === B.max) return (Z.state = s.EMPTY), void (Z.phraseLength = 0); + if (0 === B.max) return ((Z.state = s.EMPTY), void (Z.phraseLength = 0)); for ( w = u, j = 0, L = 0, i.ast && (_ = i.ast.getLength()); !(w >= C.length) && (opExecute(o + 1, w), Z.state !== s.NOMATCH) && Z.state !== s.EMPTY && ((L += 1), (j += Z.phraseLength), (w += Z.phraseLength), L !== B.max); - ); Z.state === s.EMPTY || L >= B.min ? ((Z.state = 0 === j ? s.EMPTY : s.MATCH), (Z.phraseLength = j)) @@ -53582,7 +53644,7 @@ Y) ) { const o = C.length - u; - Y(Z, C, u, B), + (Y(Z, C, u, B), validateRnmCallbackResult(z, Z, o, !0), Z.state === s.ACTIVE && ((V = x), @@ -53590,8 +53652,8 @@ opExecute(0, u), (x = V), Y(Z, C, u, B), - validateRnmCallbackResult(z, Z, o, !1)); - } else (V = x), (x = z.opcodes), opExecute(0, u, Z), (x = V); + validateRnmCallbackResult(z, Z, o, !1))); + } else ((V = x), (x = z.opcodes), opExecute(0, u, Z), (x = V)); $ || (L && (Z.state === s.NOMATCH @@ -53602,11 +53664,11 @@ case s.TRG: ((o, i) => { const u = x[o]; - (Z.state = s.NOMATCH), + ((Z.state = s.NOMATCH), i < C.length && u.min <= C[i] && C[i] <= u.max && - ((Z.state = s.MATCH), (Z.phraseLength = 1)); + ((Z.state = s.MATCH), (Z.phraseLength = 1))); })(o, w); break; case s.TBS: @@ -53615,7 +53677,7 @@ _ = u.string.length; if (((Z.state = s.NOMATCH), i + _ <= C.length)) { for (let s = 0; s < _; s += 1) if (C[i + s] !== u.string[s]) return; - (Z.state = s.MATCH), (Z.phraseLength = _); + ((Z.state = s.MATCH), (Z.phraseLength = _)); } })(o, w); break; @@ -53632,7 +53694,7 @@ ((u = C[i + s]), u >= 65 && u <= 90 && (u += 32), u !== _.string[s]) ) return; - (Z.state = s.MATCH), (Z.phraseLength = w); + ((Z.state = s.MATCH), (Z.phraseLength = w)); } } else Z.state = s.EMPTY; })(o, w); @@ -53677,10 +53739,10 @@ default: throw new Error(`${L}unrecognized operator`); } - $ || (w + Z.phraseLength > Y && (Y = w + Z.phraseLength)), + ($ || (w + Z.phraseLength > Y && (Y = w + Z.phraseLength)), i.stats && i.stats.collect(ee, Z), i.trace && i.trace.up(ee, Z.state, w, Z.phraseLength), - (V -= 1); + (V -= 1)); }; }, PS = function fnast() { @@ -53699,10 +53761,10 @@ for (; s-- > 0; ) o += ' '; return o; } - (i.callbacks = []), + ((i.callbacks = []), (i.init = (s, o, B) => { let $; - (j.length = 0), (L.length = 0), (x = 0), (u = s), (_ = o), (w = B); + ((j.length = 0), (L.length = 0), (x = 0), (u = s), (_ = o), (w = B)); const V = []; for ($ = 0; $ < u.length; $ += 1) V.push(u[$].lower); for ($ = 0; $ < _.length; $ += 1) V.push(_[$].lower); @@ -53759,15 +53821,15 @@ (i.translate = (o) => { let i, u; for (let _ = 0; _ < L.length; _ += 1) - (u = L[_]), + ((u = L[_]), (i = C[u.callbackIndex]), i && (u.state === s.SEM_PRE ? i(s.SEM_PRE, w, u.phraseIndex, u.phraseLength, o) - : i && i(s.SEM_POST, w, u.phraseIndex, u.phraseLength, o)); + : i && i(s.SEM_POST, w, u.phraseIndex, u.phraseLength, o))); }), (i.setLength = (s) => { - (L.length = s), (j.length = s > 0 ? L[s - 1].stack : 0); + ((L.length = s), (j.length = s > 0 ? L[s - 1].stack : 0)); }), (i.getLength = () => L.length), (i.toXml = () => { @@ -53795,7 +53857,7 @@ (i += '\n'), i ); - }); + })); }, MS = { stringToChars: (s) => [...s].map((s) => s.codePointAt(0)), @@ -53901,7 +53963,7 @@ return TS.SEM_OK; }, NS = new (function grammar() { - (this.grammarObject = 'grammarObject'), + ((this.grammarObject = 'grammarObject'), (this.rules = []), (this.rules[0] = { name: 'server-url-template', @@ -54078,15 +54140,15 @@ (s += 'iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\n'), '; OpenAPI Server URL templating ABNF syntax\nserver-url-template = 1*( literals / server-variable )\nserver-variable = "{" server-variable-name "}"\nserver-variable-name = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\nliterals = 1*( %x21 / %x23-24 / %x26 / %x28-3B / %x3D / %x3F-5B\n / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate\n / pct-encoded)\n ; any Unicode character except: CTL, SP,\n ; DQUOTE, "\'", "%" (aside from pct-encoded),\n ; "<", ">", "\\", "^", "`", "{", "|", "}"\n\n; Characters definitions (from RFC 6570)\nALPHA = %x41-5A / %x61-7A ; A-Z / a-z\nDIGIT = %x30-39 ; 0-9\nHEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n ; case-insensitive\n\npct-encoded = "%" HEXDIG HEXDIG\nunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"\nsub-delims = "!" / "$" / "&" / "\'" / "(" / ")"\n / "*" / "+" / "," / ";" / "="\n\nucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF\n / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD\n / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD\n / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD\n / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD\n / %xD0000-DFFFD / %xE1000-EFFFD\n\niprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\n' ); - }); + })); })(), openapi_server_url_templating_es_parse = (s) => { const o = new IS(); - (o.ast = new PS()), + ((o.ast = new PS()), (o.ast.callbacks['server-url-template'] = server_url_template), (o.ast.callbacks['server-variable'] = callbacks_server_variable), (o.ast.callbacks['server-variable-name'] = server_variable_name), - (o.ast.callbacks.literals = callbacks_literals); + (o.ast.callbacks.literals = callbacks_literals)); return { result: o.parse(NS, 'server-url-template', s), ast: o.ast }; }, openapi_server_url_templating_es_test = (s, { strict: o = !1 } = {}) => { @@ -54098,7 +54160,7 @@ const _ = u.some(([s]) => 'server-variable' === s); if (!o && !_) try { - return new URL(s, 'https://vladimirgorej.com'), !0; + return (new URL(s, 'https://vladimirgorej.com'), !0); } catch { return !1; } @@ -54136,7 +54198,8 @@ return x.join(''); }; const callbacks_slash = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['slash', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['slash', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), path_template = (s, o, i, u, _) => { if (s === TS.SEM_PRE) { @@ -54146,14 +54209,16 @@ return TS.SEM_OK; }, callbacks_path = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['path', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['path', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), path_literal = (s, o, i, u, _) => ( s === TS.SEM_PRE ? _.push(['path-literal', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK ), callbacks_query = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['query', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['query', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), query_marker = (s, o, i, u, _) => ( s === TS.SEM_PRE ? _.push(['query-marker', MS.charsToString(o, i, u)]) : TS.SEM_POST, @@ -54180,7 +54245,7 @@ TS.SEM_OK ), DS = new (function path_templating_grammar() { - (this.grammarObject = 'grammarObject'), + ((this.grammarObject = 'grammarObject'), (this.rules = []), (this.rules[0] = { name: 'path-template', @@ -54412,11 +54477,11 @@ (s += 'HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n'), '; OpenAPI Path Templating ABNF syntax\npath-template = path [ query-marker query ] [ fragment-marker fragment ]\npath = slash *( path-segment slash ) [ path-segment ]\npath-segment = 1*( path-literal / template-expression )\nquery = *( query-literal )\nquery-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" / "&" / "=" )\nquery-marker = "?"\nfragment = *( fragment-literal )\nfragment-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" )\nfragment-marker = "#"\nslash = "/"\npath-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\ntemplate-expression = "{" template-expression-param-name "}"\ntemplate-expression-param-name = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\n\n; Characters definitions (from RFC 3986)\nunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"\npct-encoded = "%" HEXDIG HEXDIG\nsub-delims = "!" / "$" / "&" / "\'" / "(" / ")"\n / "*" / "+" / "," / ";" / "="\nALPHA = %x41-5A / %x61-7A ; A-Z / a-z\nDIGIT = %x30-39 ; 0-9\nHEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n' ); - }); + })); })(), openapi_path_templating_es_parse = (s) => { const o = new IS(); - (o.ast = new PS()), + ((o.ast = new PS()), (o.ast.callbacks['path-template'] = path_template), (o.ast.callbacks.path = callbacks_path), (o.ast.callbacks.query = callbacks_query), @@ -54426,7 +54491,7 @@ (o.ast.callbacks.slash = callbacks_slash), (o.ast.callbacks['path-literal'] = path_literal), (o.ast.callbacks['template-expression'] = template_expression), - (o.ast.callbacks['template-expression-param-name'] = template_expression_param_name); + (o.ast.callbacks['template-expression-param-name'] = template_expression_param_name)); return { result: o.parse(DS, 'path-template', s), ast: o.ast }; }, encodePathComponent = (s) => @@ -54468,15 +54533,15 @@ void 0 !== o && (s.body = o); }, header: function headerBuilder({ req: s, parameter: o, value: i }) { - (s.headers = s.headers || {}), void 0 !== i && (s.headers[o.name] = i); + ((s.headers = s.headers || {}), void 0 !== i && (s.headers[o.name] = i)); }, query: function queryBuilder({ req: s, value: o, parameter: i }) { - (s.query = s.query || {}), !1 === o && 'boolean' === i.type && (o = 'false'); + ((s.query = s.query || {}), !1 === o && 'boolean' === i.type && (o = 'false')); 0 === o && ['number', 'integer'].indexOf(i.type) > -1 && (o = '0'); if (o) s.query[i.name] = { collectionFormat: i.collectionFormat, value: o }; else if (i.allowEmptyValue && void 0 !== o) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } }, path: function pathBuilder({ req: s, value: o, parameter: i, baseURL: u }) { @@ -54490,12 +54555,12 @@ !1 === o && 'boolean' === i.type && (o = 'false'); 0 === o && ['number', 'integer'].indexOf(i.type) > -1 && (o = '0'); if (o) - (s.form = s.form || {}), - (s.form[i.name] = { collectionFormat: i.collectionFormat, value: o }); + ((s.form = s.form || {}), + (s.form[i.name] = { collectionFormat: i.collectionFormat, value: o })); else if (i.allowEmptyValue && void 0 !== o) { s.form = s.form || {}; const o = i.name; - (s.form[o] = s.form[o] || {}), (s.form[o].allowEmptyValue = !0); + ((s.form[o] = s.form[o] || {}), (s.form[o].allowEmptyValue = !0)); } } }; @@ -54549,7 +54614,7 @@ if (u) s.query[i.name] = u; else if (i.allowEmptyValue) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } } else if ((!1 === o && (o = 'false'), 0 === o && (o = '0'), o)) { const { style: u, explode: _, allowReserved: w } = i; @@ -54559,7 +54624,7 @@ }; } else if (i.allowEmptyValue && void 0 !== o) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } } const FS = ['accept', 'authorization', 'content-type']; @@ -54649,9 +54714,9 @@ { type: _ } = i; if (o) if ('apiKey' === _) - 'query' === i.in && (w.query[i.name] = u), + ('query' === i.in && (w.query[i.name] = u), 'header' === i.in && (w.headers[i.name] = u), - 'cookie' === i.in && (w.cookies[i.name] = u); + 'cookie' === i.in && (w.cookies[i.name] = u)); else if ('http' === _) { if (/^basic$/i.test(i.scheme)) { const s = u.username || '', @@ -54664,8 +54729,8 @@ const s = o.token || {}, u = s[i['x-tokenName'] || 'access_token']; let _ = s.token_type; - (_ && 'bearer' !== _.toLowerCase()) || (_ = 'Bearer'), - (w.headers.Authorization = `${_} ${u}`); + ((_ && 'bearer' !== _.toLowerCase()) || (_ = 'Bearer'), + (w.headers.Authorization = `${_} ${u}`)); } }); }), @@ -54703,7 +54768,7 @@ void 0 !== $ ? $ : {}; - (o.form = {}), + ((o.form = {}), Object.keys(u).forEach((i) => { let _; try { @@ -54712,7 +54777,7 @@ _ = u[i]; } o.form[i] = { value: _, encoding: s[i] || {} }; - }); + })); } else if ('string' == typeof u) { var U, z; const s = @@ -54780,14 +54845,14 @@ if (o) if ('apiKey' === C) { const s = 'query' === x.in ? 'query' : 'headers'; - (_[s] = _[s] || {}), (_[s][x.name] = u); + ((_[s] = _[s] || {}), (_[s][x.name] = u)); } else if ('basic' === C) if (u.header) _.headers.authorization = u.header; else { const s = u.username || '', o = u.password || ''; - (u.base64 = VS(`${s}:${o}`)), - (_.headers.authorization = `Basic ${u.base64}`); + ((u.base64 = VS(`${s}:${o}`)), + (_.headers.authorization = `Basic ${u.base64}`)); } else 'oauth2' === C && @@ -54904,10 +54969,10 @@ headers: {}, cookies: {} }; - U && (ae.signal = U), + (U && (ae.signal = U), x && (ae.requestInterceptor = x), C && (ae.responseInterceptor = C), - L && (ae.userFetch = L); + L && (ae.userFetch = L)); const le = (function getOperationRaw(s, o) { return s && s.paths ? (function findOperation(s, o) { @@ -54975,14 +55040,14 @@ ? void 0 : j.servers, z = null == s ? void 0 : s.servers; - (B = isNonEmptyServerList(V) + ((B = isNonEmptyServerList(V) ? V : isNonEmptyServerList(U) ? U : isNonEmptyServerList(z) ? z : [Rc]), - u && ((L = B.find((s) => s.url === u)), L && ($ = u)); + u && ((L = B.find((s) => s.url === u)), L && ($ = u))); $ || (([L] = B), ($ = L.url)); if (openapi_server_url_templating_es_test($, { strict: !0 })) { const s = Object.entries({ ...L.variables }).reduce( @@ -55029,14 +55094,14 @@ (ae.url += ee), !u) ) - return delete ae.cookies, ae; - (ae.url += de), (ae.method = `${pe}`.toUpperCase()), (Y = Y || {}); + return (delete ae.cookies, ae); + ((ae.url += de), (ae.method = `${pe}`.toUpperCase()), (Y = Y || {})); const ye = i.paths[de] || {}; _ && (ae.headers.accept = _); const be = ((s) => { const o = {}; s.forEach((s) => { - o[s.in] || (o[s.in] = {}), (o[s.in][s.name] = s); + (o[s.in] || (o[s.in] = {}), (o[s.in][s.name] = s)); }); const i = []; return ( @@ -55087,7 +55152,7 @@ }, ''); ae.headers.Cookie = s; } - return ae.cookies && delete ae.cookies, serializeRequest(ae); + return (ae.cookies && delete ae.cookies, serializeRequest(ae)); } const stripNonAlpha = (s) => (s ? s.replace(/\W/g, '') : null); const isNonEmptyServerList = (s) => Array.isArray(s) && s.length > 0; @@ -55187,7 +55252,7 @@ if (!HS.createContext) return {}; const s = GS[JS] ?? (GS[JS] = new Map()); let o = s.get(HS.createContext); - return o || ((o = HS.createContext(null)), s.set(HS.createContext, o)), o; + return (o || ((o = HS.createContext(null)), s.set(HS.createContext, o)), o); } var YS = getContext(), notInitialized = () => { @@ -55263,7 +55328,12 @@ (j = U), z && Y ? (function handleNewPropsAndNewState() { - return (L = s(C, j)), o.dependsOnOwnProps && (B = o(u, j)), ($ = i(L, B, j)), $; + return ( + (L = s(C, j)), + o.dependsOnOwnProps && (B = o(u, j)), + ($ = i(L, B, j)), + $ + ); })() : z ? (function handleNewProps() { @@ -55278,7 +55348,7 @@ ? (function handleNewState() { const o = s(C, j), u = !x(o, L); - return (L = o), u && ($ = i(L, B, j)), $; + return ((L = o), u && ($ = i(L, B, j)), $); })() : $ ); @@ -55288,7 +55358,13 @@ ? handleSubsequentCalls(_, w) : (function handleFirstCall(_, w) { return ( - (C = _), (j = w), (L = s(C, j)), (B = o(u, j)), ($ = i(L, B, j)), (V = !0), $ + (C = _), + (j = w), + (L = s(C, j)), + (B = o(u, j)), + ($ = i(L, B, j)), + (V = !0), + $ ); })(_, w); }; @@ -55299,7 +55375,7 @@ function constantSelector() { return i; } - return (constantSelector.dependsOnOwnProps = !1), constantSelector; + return ((constantSelector.dependsOnOwnProps = !1), constantSelector); }; } function getDependsOnOwnProps(s) { @@ -55313,7 +55389,7 @@ return ( (u.dependsOnOwnProps = !0), (u.mapToProps = function detectFactoryAndVerify(o, i) { - (u.mapToProps = s), (u.dependsOnOwnProps = getDependsOnOwnProps(s)); + ((u.mapToProps = s), (u.dependsOnOwnProps = getDependsOnOwnProps(s))); let _ = u(o, i); return ( 'function' == typeof _ && @@ -55350,7 +55426,7 @@ x.onStateChange && x.onStateChange(); } function trySubscribe() { - _++, + (_++, i || ((i = o ? o.addNestedSub(handleChangeWrapper) : s.subscribe(handleChangeWrapper)), (u = (function createListenerCollection() { @@ -55358,18 +55434,18 @@ o = null; return { clear() { - (s = null), (o = null); + ((s = null), (o = null)); }, notify() { defaultNoopBatch(() => { let o = s; - for (; o; ) o.callback(), (o = o.next); + for (; o; ) (o.callback(), (o = o.next)); }); }, get() { const o = []; let i = s; - for (; i; ) o.push(i), (i = i.next); + for (; i; ) (o.push(i), (i = i.next)); return o; }, subscribe(i) { @@ -55387,10 +55463,10 @@ ); } }; - })())); + })()))); } function tryUnsubscribe() { - _--, i && 0 === _ && (i(), (i = void 0), u.clear(), (u = hx)); + (_--, i && 0 === _ && (i(), (i = void 0), u.clear(), (u = hx))); } const x = { addNestedSub: function addNestedSub(s) { @@ -55510,7 +55586,7 @@ var Cx = notInitialized, Ox = [null, null]; function captureWrapperProps(s, o, i, u, _, w) { - (s.current = u), (i.current = !1), _.current && ((_.current = null), w()); + ((s.current = u), (i.current = !1), _.current && ((_.current = null), w())); } function strictEqual(s, o) { return s === o; @@ -55567,7 +55643,7 @@ w = !1; return function mergePropsProxy(o, i, x) { const C = s(o, i, x); - return w ? u(C, _) || (_ = C) : ((w = !0), (_ = C)), _; + return (w ? u(C, _) || (_ = C) : ((w = !0), (_ = C)), _); }; }; })(s) @@ -55652,12 +55728,12 @@ try { i = u(s, _.current); } catch (s) { - (U = s), (V = s); + ((U = s), (V = s)); } - U || (V = null), + (U || (V = null), i === w.current ? x.current || L() - : ((w.current = i), (j.current = i), (x.current = !0), B()); + : ((w.current = i), (j.current = i), (x.current = !0), B())); }; return ( (i.onStateChange = checkForUpdates), @@ -55680,13 +55756,13 @@ be = Cx(ye, fe, V ? () => U(V(), w) : fe); } catch (s) { throw ( - (de.current && + de.current && (s.message += `\nThe error may be correlated with this previous error:\n${de.current.stack}\n\n`), - s) + s ); } mx(() => { - (de.current = void 0), (le.current = void 0), (ie.current = be); + ((de.current = void 0), (le.current = void 0), (ie.current = be)); }); const _e = HS.useMemo(() => HS.createElement(s, { ...be, ref: _ }), [_, s, be]); return HS.useMemo( @@ -55699,7 +55775,7 @@ const o = HS.forwardRef(function forwardConnectRef(s, o) { return HS.createElement(L, { ...s, reactReduxForwardedRef: o }); }); - return (o.displayName = i), (o.WrappedComponent = s), hoistNonReactStatics(o, s); + return ((o.displayName = i), (o.WrappedComponent = s), hoistNonReactStatics(o, s)); } return hoistNonReactStatics(L, s); }; @@ -55730,7 +55806,7 @@ o.trySubscribe(), C !== s.getState() && o.notifyNestedSubs(), () => { - o.tryUnsubscribe(), (o.onStateChange = void 0); + (o.tryUnsubscribe(), (o.onStateChange = void 0)); } ); }, [x, C]); @@ -55738,10 +55814,10 @@ return HS.createElement(j.Provider, { value: x }, i); }; var Ix; - (Ix = KS.useSyncExternalStoreWithSelector), + ((Ix = KS.useSyncExternalStoreWithSelector), ((s) => { Cx = s; - })(Pe.useSyncExternalStore); + })(Pe.useSyncExternalStore)); var Px = __webpack_require__(83488), Mx = __webpack_require__.n(Px); const withSystem = (s) => (o) => { @@ -55751,7 +55827,7 @@ return Pe.createElement(o, Rn()({}, s(), this.props, this.context)); } } - return (WithSystem.displayName = `WithSystem(${i.getDisplayName(o)})`), WithSystem; + return ((WithSystem.displayName = `WithSystem(${i.getDisplayName(o)})`), WithSystem); }, withRoot = (s, o) => (i) => { const { fn: u } = s(); @@ -55764,7 +55840,7 @@ ); } } - return (WithRoot.displayName = `WithRoot(${u.getDisplayName(i)})`), WithRoot; + return ((WithRoot.displayName = `WithRoot(${u.getDisplayName(i)})`), WithRoot); }, withConnect = (s, o, i) => compose( @@ -55787,7 +55863,7 @@ w = i(o, 'root'); class WithMappedContainer extends Pe.Component { constructor(o, i) { - super(o, i), handleProps(s, u, o, {}); + (super(o, i), handleProps(s, u, o, {})); } UNSAFE_componentWillReceiveProps(o) { handleProps(s, u, o, this.props); @@ -55899,11 +55975,11 @@ })() ) ); - _.updateLoadingStatus('success'), + (_.updateLoadingStatus('success'), _.updateSpec(o.text), - u.url() !== s && _.updateUrl(s); + u.url() !== s && _.updateUrl(s)); } - (s = s || u.url()), + ((s = s || u.url()), _.updateLoadingStatus('loading'), i.clear({ source: 'fetch' }), x({ @@ -55913,7 +55989,7 @@ responseInterceptor: C.responseInterceptor || ((s) => s), credentials: 'same-origin', headers: { Accept: 'application/json,*/*' } - }).then(next, next); + }).then(next, next)); }, updateLoadingStatus: (s) => { let o = [null, 'loading', 'failed', 'success', 'failedConfig']; @@ -56043,11 +56119,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -56276,11 +56352,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -56453,7 +56529,7 @@ })(o); if (i) { var _ = o.split('\n'); - _.forEach(function (o, i) { + (_.forEach(function (o, i) { var x = u && $.length + w, C = { type: 'text', value: ''.concat(o, '\n') }; if (0 === i) { @@ -56482,12 +56558,11 @@ $.push(ee); } }), - (V = U); + (V = U)); } U++; }; U < B.length; - ) z(); if (V !== B.length - 1) { @@ -56576,8 +56651,8 @@ if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), - o.includes(i) || ({}.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + ((i = w[u]), + o.includes(i) || ({}.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; })(i, Nx); @@ -56607,7 +56682,7 @@ !$e) ) return Pe.createElement(Se, Xe, We, Pe.createElement(Te, B, qe)); - ((void 0 === pe && _e) || fe) && (pe = !0), (_e = _e || defaultRenderer); + (((void 0 === pe && _e) || fe) && (pe = !0), (_e = _e || defaultRenderer)); var Qe = [{ type: 'text', value: qe }], et = (function getCodeTree(s) { var o = s.astGenerator, @@ -56665,14 +56740,14 @@ var Xx = __webpack_require__(26571); const Zx = __webpack_require__.n(Xx)(), after_load = () => { - Bx.registerLanguage('json', Vx), + (Bx.registerLanguage('json', Vx), Bx.registerLanguage('js', qx), Bx.registerLanguage('xml', zx), Bx.registerLanguage('yaml', Jx), Bx.registerLanguage('http', Yx), Bx.registerLanguage('bash', Kx), Bx.registerLanguage('powershell', Zx), - Bx.registerLanguage('javascript', qx); + Bx.registerLanguage('javascript', qx)); }, Qx = { hljs: { @@ -57124,13 +57199,13 @@ GIT_DIRTY: !0, BUILD_TIME: 'Thu, 07 Nov 2024 14:01:17 GMT' }; - (at.versions = at.versions || {}), + ((at.versions = at.versions || {}), (at.versions.swaggerUI = { version: i, gitRevision: o, gitDirty: s, buildTimestamp: u - }); + })); }, versions = () => ({ afterLoad: versions_after_load }); var ok = __webpack_require__(47248), @@ -57182,7 +57257,7 @@ return { hasError: !0, error: s }; } constructor(...s) { - super(...s), (this.state = { hasError: !1, error: null }); + (super(...s), (this.state = { hasError: !1, error: null })); } componentDidCatch(s, o) { this.props.fn.componentDidCatch(s, o); @@ -57370,7 +57445,7 @@ } class Auths extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = {}); + (super(s, o), (this.state = {})); } onAuthChange = (s) => { let { name: o } = s; @@ -57385,7 +57460,7 @@ s.preventDefault(); let { authActions: o, definitions: i } = this.props, u = i.map((s, o) => o).toArray(); - this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u); + (this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u)); }; close = (s) => { s.preventDefault(); @@ -57553,7 +57628,7 @@ let { onChange: o } = this.props, i = s.target.value, u = Object.assign({}, this.state, { value: i }); - this.setState(u), o(u); + (this.setState(u), o(u)); }; render() { let { schema: s, getComponent: o, errSelectors: i, name: u } = this.props; @@ -57623,7 +57698,7 @@ let { onChange: o } = this.props, { value: i, name: u } = s.target, _ = this.state.value; - (_[u] = i), this.setState({ value: _ }), o(this.state); + ((_[u] = i), this.setState({ value: _ }), o(this.state)); }; render() { let { schema: s, getComponent: o, name: i, errSelectors: u } = this.props; @@ -57872,12 +57947,12 @@ _(stringifyUnlessList(C)), this._setStateForCurrentNamespace({ isModifiedValueSelected: !0 }) ); - 'function' == typeof u && u(s, { isSyntheticChange: o }, ...i), + ('function' == typeof u && u(s, { isSyntheticChange: o }, ...i), this._setStateForCurrentNamespace({ lastDownstreamValue: j, isModifiedValueSelected: (o && x) || (!!w && w !== j) }), - o || ('function' == typeof _ && _(stringifyUnlessList(j))); + o || ('function' == typeof _ && _(stringifyUnlessList(j)))); }; UNSAFE_componentWillReceiveProps(s) { const { currentUserInputValue: o, examples: i, onSelect: u, userHasEditedBody: _ } = s, @@ -57887,8 +57962,8 @@ j = i.filter((s) => s.get('value') === o || stringify(s.get('value')) === o); if (j.size) { let o; - (o = j.has(s.currentKey) ? s.currentKey : j.keySeq().first()), - u(o, { isSyntheticChange: !0 }); + ((o = j.has(s.currentKey) ? s.currentKey : j.keySeq().first()), + u(o, { isSyntheticChange: !0 })); } else o !== this.props.currentUserInputValue && o !== w && @@ -57979,9 +58054,9 @@ i = (function createCodeChallenge(s) { return b64toB64UrlEncoded(kt()('sha256').update(s).digest('base64')); })(o); - $.push('code_challenge=' + i), + ($.push('code_challenge=' + i), $.push('code_challenge_method=S256'), - (s.codeVerifier = o); + (s.codeVerifier = o)); } let { additionalQueryStringParams: Y } = _; for (let s in Y) void 0 !== Y[s] && $.push([s, Y[s]].map(encodeURIComponent).join('=')); @@ -57990,7 +58065,7 @@ ee = w ? Mt()(sanitizeUrl(Z), w, !0).toString() : sanitizeUrl(Z); let ie, ae = [ee, $.join('&')].join(-1 === Z.indexOf('?') ? '?' : '&'); - (ie = + ((ie = 'implicit' === B ? o.preAuthorizeImplicit : _.useBasicAuthenticationWithAccessCodeGrant @@ -58002,7 +58077,7 @@ redirectUrl: V, callback: ie, errCb: i.newAuthErr - }); + })); } class Oauth2 extends Pe.Component { constructor(s, o) { @@ -58015,7 +58090,7 @@ B = (x && x.get('clientSecret')) || C.clientSecret || '', $ = (x && x.get('passwordType')) || 'basic', V = (x && x.get('scopes')) || C.scopes || []; - 'string' == typeof V && (V = V.split(C.scopeSeparator || ' ')), + ('string' == typeof V && (V = V.split(C.scopeSeparator || ' ')), (this.state = { appName: C.appName, name: i, @@ -58026,7 +58101,7 @@ username: j, password: '', passwordType: $ - }); + })); } close = (s) => { s.preventDefault(); @@ -58043,7 +58118,7 @@ } = this.props, w = i(), x = u.getConfigs(); - o.clear({ authId: name, type: 'auth', source: 'auth' }), + (o.clear({ authId: name, type: 'auth', source: 'auth' }), oauth2_authorize_authorize({ auth: this.state, currentServer: _.serverEffectiveValue(_.selectedServer()), @@ -58051,7 +58126,7 @@ errActions: o, configs: w, authConfigs: x - }); + })); }; onScopeChange = (s) => { let { target: o } = s, @@ -58089,7 +58164,7 @@ logout = (s) => { s.preventDefault(); let { authActions: o, errActions: i, name: u } = this.props; - i.clear({ authId: u, type: 'auth', source: 'auth' }), o.logoutWithPersistOption([u]); + (i.clear({ authId: u, type: 'auth', source: 'auth' }), o.logoutWithPersistOption([u])); }; render() { let { @@ -58360,7 +58435,7 @@ class Clear extends Pe.Component { onClick = () => { let { specActions: s, path: o, method: i } = this.props; - s.clearResponse(o, i), s.clearRequest(o, i); + (s.clearResponse(o, i), s.clearRequest(o, i)); }; render() { return Pe.createElement( @@ -58561,28 +58636,28 @@ } class ValidatorImage extends Pe.Component { constructor(s) { - super(s), (this.state = { loaded: !1, error: !1 }); + (super(s), (this.state = { loaded: !1, error: !1 })); } componentDidMount() { const s = new Image(); - (s.onload = () => { + ((s.onload = () => { this.setState({ loaded: !0 }); }), (s.onerror = () => { this.setState({ error: !0 }); }), - (s.src = this.props.src); + (s.src = this.props.src)); } UNSAFE_componentWillReceiveProps(s) { if (s.src !== this.props.src) { const o = new Image(); - (o.onload = () => { + ((o.onload = () => { this.setState({ loaded: !0 }); }), (o.onerror = () => { this.setState({ error: !0 }); }), - (o.src = s.src); + (o.src = s.src)); } } render() { @@ -59091,13 +59166,13 @@ UNSAFE_componentWillReceiveProps(s) { const { response: o, isShown: i } = s, u = this.getResolvedSubtree(); - o !== this.props.response && this.setState({ executeInProgress: !1 }), - i && void 0 === u && this.requestResolvedSubtree(); + (o !== this.props.response && this.setState({ executeInProgress: !1 }), + i && void 0 === u && this.requestResolvedSubtree()); } toggleShown = () => { let { layoutActions: s, tag: o, operationId: i, isShown: u } = this.props; const _ = this.getResolvedSubtree(); - u || void 0 !== _ || this.requestResolvedSubtree(), s.show(['operations', o, i], !u); + (u || void 0 !== _ || this.requestResolvedSubtree(), s.show(['operations', o, i], !u)); }; onCancelClick = () => { this.setState({ tryItOutEnabled: !this.state.tryItOutEnabled }); @@ -59555,12 +59630,12 @@ } class response_Response extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = { responseContentType: '' }); + (super(s, o), (this.state = { responseContentType: '' })); } static defaultProps = { response: (0, qe.fromJS)({}), onContentTypeChange: () => {} }; _onContentTypeChange = (s) => { const { onContentTypeChange: o, controlsAcceptHeader: i } = this.props; - this.setState({ responseContentType: s }), o({ value: s, controlsAcceptHeader: i }); + (this.setState({ responseContentType: s }), o({ value: s, controlsAcceptHeader: i })); }; getTargetExamplesKey = () => { const { response: s, contentType: o, activeExamplesKey: i } = this.props, @@ -59609,9 +59684,9 @@ $e = Re.get('examples', null); if (Y) { const s = Re.get('schema'); - (Se = s ? U(s.toJS()) : null), - (xe = s ? (0, qe.List)(['content', this.state.responseContentType, 'schema']) : w); - } else (Se = u.get('schema')), (xe = u.has('schema') ? w.push('schema') : w); + ((Se = s ? U(s.toJS()) : null), + (xe = s ? (0, qe.List)(['content', this.state.responseContentType, 'schema']) : w)); + } else ((Se = u.get('schema')), (xe = u.has('schema') ? w.push('schema') : w)); let ze, We, He = !1, @@ -59620,12 +59695,12 @@ if (((We = Re.get('schema')?.toJS()), qe.Map.isMap($e) && !$e.isEmpty())) { const s = this.getTargetExamplesKey(), getMediaTypeExample = (s) => s.get('value'); - (ze = getMediaTypeExample($e.get(s, (0, qe.Map)({})))), + ((ze = getMediaTypeExample($e.get(s, (0, qe.Map)({})))), void 0 === ze && (ze = getMediaTypeExample($e.values().next().value)), - (He = !0); + (He = !0)); } else void 0 !== Re.get('example') && ((ze = Re.get('example')), (He = !0)); else { - (We = Se), (Ye = { ...Ye, includeWriteOnly: !0 }); + ((We = Se), (Ye = { ...Ye, includeWriteOnly: !0 })); const s = u.getIn(['examples', Te]); s && ((ze = s), (He = !0)); } @@ -59765,10 +59840,10 @@ if (s !== o) if (o && o instanceof Blob) { var i = new FileReader(); - (i.onload = () => { + ((i.onload = () => { this.setState({ parsedContent: i.result }); }), - i.readAsText(o); + i.readAsText(o)); } else this.setState({ parsedContent: o.toString() }); }; componentDidMount() { @@ -59930,7 +60005,7 @@ } class Parameters extends Pe.Component { constructor(s) { - super(s), (this.state = { callbackVisible: !1, parametersVisible: !0 }); + (super(s), (this.state = { callbackVisible: !1, parametersVisible: !0 })); } static defaultProps = { onTryoutClick: Function.prototype, @@ -59964,13 +60039,13 @@ let { specActions: i, oas3Selectors: u, oas3Actions: _ } = this.props; const w = u.hasUserEditedBody(...o), x = u.shouldRetainRequestBodyValue(...o); - _.setRequestContentType({ value: s, pathMethod: o }), + (_.setRequestContentType({ value: s, pathMethod: o }), _.initRequestBodyValidateError({ pathMethod: o }), w || (x || _.setRequestBodyValue({ value: void 0, pathMethod: o }), i.clearResponse(...o), i.clearRequest(...o), - i.clearValidateParams(o)); + i.clearValidateParams(o))); }; render() { let { @@ -60002,7 +60077,7 @@ fe = Object.values( i.reduce((s, o) => { const i = o.get('in'); - return (s[i] ??= []), s[i].push(o), s; + return ((s[i] ??= []), s[i].push(o), s); }, {}) ).reduce((s, o) => s.concat(o), []); return Pe.createElement( @@ -60240,7 +60315,7 @@ } class ParameterRow extends Pe.Component { constructor(s, o) { - super(s, o), this.setDefaultValue(); + (super(s, o), this.setDefaultValue()); } UNSAFE_componentWillReceiveProps(s) { let o, @@ -60253,7 +60328,7 @@ } else o = x ? x.get('enum') : void 0; let C, j = x ? x.get('value') : void 0; - void 0 !== j ? (C = j) : _.get('required') && o && o.size && (C = o.first()), + (void 0 !== j ? (C = j) : _.get('required') && o && o.size && (C = o.first()), void 0 !== C && C !== j && this.onChangeWrapper( @@ -60261,12 +60336,12 @@ return 'number' == typeof s ? s.toString() : s; })(C) ), - this.setDefaultValue(); + this.setDefaultValue()); } onChangeWrapper = (s, o = !1) => { let i, { onChange: u, rawParam: _ } = this.props; - return (i = '' === s || (s && 0 === s.size) ? null : s), u(_, i, o); + return ((i = '' === s || (s && 0 === s.size) ? null : s), u(_, i, o)); }; _onExampleSelect = (s) => { this.props.oas3Actions.setActiveExamplesMember({ @@ -60322,14 +60397,14 @@ ? x && x.get('default') : w.get('default'); } - void 0 === i || qe.List.isList(i) || (i = stringify(i)), + (void 0 === i || qe.List.isList(i) || (i = stringify(i)), void 0 !== i ? this.onChangeWrapper(i) : x && 'object' === x.get('type') && j && !w.get('examples') && - this.onChangeWrapper(qe.List.isList(j) ? j : stringify(j)); + this.onChangeWrapper(qe.List.isList(j) ? j : stringify(j))); } }; getParamKey() { @@ -60549,7 +60624,7 @@ class Execute extends Pe.Component { handleValidateParameters = () => { let { specSelectors: s, specActions: o, path: i, method: u } = this.props; - return o.validateParams([i, u]), s.validateBeforeExecute([i, u]); + return (o.validateParams([i, u]), s.validateBeforeExecute([i, u])); }; handleValidateRequestBody = () => { let { @@ -60589,15 +60664,15 @@ }; handleValidationResultPass = () => { let { specActions: s, operation: o, path: i, method: u } = this.props; - this.props.onExecute && this.props.onExecute(), - s.execute({ operation: o, path: i, method: u }); + (this.props.onExecute && this.props.onExecute(), + s.execute({ operation: o, path: i, method: u })); }; handleValidationResultFail = () => { let { specActions: s, path: o, method: i } = this.props; - s.clearValidateParams([o, i]), + (s.clearValidateParams([o, i]), setTimeout(() => { s.validateParams([o, i]); - }, 40); + }, 40)); }; handleValidationResult = (s) => { s ? this.handleValidationResultPass() : this.handleValidationResultFail(); @@ -60899,7 +60974,7 @@ C.push('none' + o); continue; } - C.push('block' + o), C.push('col-' + i + o); + (C.push('block' + o), C.push('col-' + i + o)); } } s && C.push('hidden'); @@ -60930,15 +61005,15 @@ static defaultProps = { multiple: !1, allowEmptyValue: !0 }; constructor(s, o) { let i; - super(s, o), + (super(s, o), (i = s.value ? s.value : s.multiple ? [''] : ''), - (this.state = { value: i }); + (this.state = { value: i })); } onChange = (s) => { let o, { onChange: i, multiple: u } = this.props, _ = [].slice.call(s.target.options); - (o = u + ((o = u ? _.filter(function (s) { return s.selected; }).map(function (s) { @@ -60946,7 +61021,7 @@ }) : s.target.value), this.setState({ value: o }), - i && i(o); + i && i(o)); }; UNSAFE_componentWillReceiveProps(s) { s.value !== this.props.value && this.setState({ value: s.value }); @@ -60999,7 +61074,7 @@ } class Overview extends Pe.Component { constructor(...s) { - super(...s), (this.setTagShown = this._setTagShown.bind(this)); + (super(...s), (this.setTagShown = this._setTagShown.bind(this))); } _setTagShown(s, o) { this.props.layoutActions.show(s, o); @@ -61064,7 +61139,7 @@ } class OperationLink extends Pe.Component { constructor(s) { - super(s), (this.onClick = this._onClick.bind(this)); + (super(s), (this.onClick = this._onClick.bind(this))); } _onClick() { let { showOpId: s, showOpIdPrefix: o, onClick: i, shown: u } = this.props; @@ -61341,7 +61416,7 @@ onChangeConsumes: eC }; constructor(s, o) { - super(s, o), (this.state = { isEditBox: !1, value: '' }); + (super(s, o), (this.state = { isEditBox: !1, value: '' })); } componentDidMount() { this.updateValues.call(this, this.props); @@ -61356,7 +61431,7 @@ x = _ ? o.get('value_xml') : o.get('value'); if (void 0 !== x) { let s = !x && w ? '{}' : x; - this.setState({ value: s }), this.onChange(s, { isXml: _, isEditBox: i }); + (this.setState({ value: s }), this.onChange(s, { isXml: _, isEditBox: i })); } else _ ? this.onChange(this.sample('xml'), { isXml: _, isEditBox: i }) @@ -61368,7 +61443,7 @@ return i.getSampleSchema(u, s, { includeWriteOnly: !0 }); }; onChange = (s, { isEditBox: o, isXml: i }) => { - this.setState({ value: s, isEditBox: o }), this._onChange(s, i); + (this.setState({ value: s, isEditBox: o }), this._onChange(s, i)); }; _onChange = (s, o) => { (this.props.onChange || eC)(s, o); @@ -61720,7 +61795,8 @@ var tC; function decodeEntity(s) { return ( - ((tC = tC || document.createElement('textarea')).innerHTML = '&' + s + ';'), tC.value + ((tC = tC || document.createElement('textarea')).innerHTML = '&' + s + ';'), + tC.value ); } var rC = Object.prototype.hasOwnProperty; @@ -61807,7 +61883,7 @@ ? nextToken(s, o + 2) : o; } - (cC.blockquote_open = function () { + ((cC.blockquote_open = function () { return '
\n'; }), (cC.blockquote_close = function (s, o) { @@ -62048,18 +62124,18 @@ }), (cC.dd_close = function () { return '\n'; - }); + })); var uC = (cC.getBreak = function getBreak(s, o) { return (o = nextToken(s, o)) < s.length && 'list_item_close' === s[o].type ? '' : '\n'; }); function Renderer() { - (this.rules = index_browser_assign({}, cC)), (this.getBreak = cC.getBreak); + ((this.rules = index_browser_assign({}, cC)), (this.getBreak = cC.getBreak)); } function Ruler() { - (this.__rules__ = []), (this.__cache__ = null); + ((this.__rules__ = []), (this.__cache__ = null)); } function StateInline(s, o, i, u, _) { - (this.src = s), + ((this.src = s), (this.env = u), (this.options = i), (this.parser = o), @@ -62073,7 +62149,7 @@ (this.isInLabel = !1), (this.linkLevel = 0), (this.linkContent = ''), - (this.labelUnmatchedScopes = 0); + (this.labelUnmatchedScopes = 0)); } function parseLinkLabel(s, o) { var i, @@ -62084,7 +62160,7 @@ C = s.pos, j = s.isInLabel; if (s.isInLabel) return -1; - if (s.labelUnmatchedScopes) return s.labelUnmatchedScopes--, -1; + if (s.labelUnmatchedScopes) return (s.labelUnmatchedScopes--, -1); for (s.pos = o + 1, s.isInLabel = !0, i = 1; s.pos < x; ) { if (91 === (_ = s.src.charCodeAt(s.pos))) i++; else if (93 === _ && 0 === --i) { @@ -62166,7 +62242,7 @@ if (34 !== w && 39 !== w && 40 !== w) return !1; for (o++, 40 === w && (w = 41); o < _; ) { if ((i = s.src.charCodeAt(o)) === w) - return (s.pos = o + 1), (s.linkContent = unescapeMd(s.src.slice(u + 1, o))), !0; + return ((s.pos = o + 1), (s.linkContent = unescapeMd(s.src.slice(u + 1, o))), !0); 92 === i && o + 1 < _ ? (o += 2) : o++; } return !1; @@ -62199,7 +62275,6 @@ ? (($ = _.linkContent), (x = _.pos)) : (($ = ''), (x = L)); x < C && 32 === _.src.charCodeAt(x); - ) x++; return x < C && 10 !== _.src.charCodeAt(x) @@ -62208,7 +62283,7 @@ void 0 === u.references[V] && (u.references[V] = { title: $, href: B }), x); } - (Renderer.prototype.renderInline = function (s, o, i) { + ((Renderer.prototype.renderInline = function (s, o, i) { for (var u = this.rules, _ = s.length, w = 0, x = ''; _--; ) x += u[s[w].type](s, w++, o, i, this); return x; @@ -62228,7 +62303,7 @@ (Ruler.prototype.__compile__ = function () { var s = this, o = ['']; - s.__rules__.forEach(function (s) { + (s.__rules__.forEach(function (s) { s.enabled && s.alt.forEach(function (s) { o.indexOf(s) < 0 && o.push(s); @@ -62236,41 +62311,41 @@ }), (s.__cache__ = {}), o.forEach(function (o) { - (s.__cache__[o] = []), + ((s.__cache__[o] = []), s.__rules__.forEach(function (i) { i.enabled && ((o && i.alt.indexOf(o) < 0) || s.__cache__[o].push(i.fn)); - }); - }); + })); + })); }), (Ruler.prototype.at = function (s, o, i) { var u = this.__find__(s), _ = i || {}; if (-1 === u) throw new Error('Parser rule not found: ' + s); - (this.__rules__[u].fn = o), + ((this.__rules__[u].fn = o), (this.__rules__[u].alt = _.alt || []), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.before = function (s, o, i, u) { var _ = this.__find__(s), w = u || {}; if (-1 === _) throw new Error('Parser rule not found: ' + s); - this.__rules__.splice(_, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), - (this.__cache__ = null); + (this.__rules__.splice(_, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.after = function (s, o, i, u) { var _ = this.__find__(s), w = u || {}; if (-1 === _) throw new Error('Parser rule not found: ' + s); - this.__rules__.splice(_ + 1, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), - (this.__cache__ = null); + (this.__rules__.splice(_ + 1, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.push = function (s, o, i) { var u = i || {}; - this.__rules__.push({ name: s, enabled: !0, fn: o, alt: u.alt || [] }), - (this.__cache__ = null); + (this.__rules__.push({ name: s, enabled: !0, fn: o, alt: u.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.enable = function (s, o) { - (s = Array.isArray(s) ? s : [s]), + ((s = Array.isArray(s) ? s : [s]), o && this.__rules__.forEach(function (s) { s.enabled = !1; @@ -62280,27 +62355,27 @@ if (o < 0) throw new Error('Rules manager: invalid rule name ' + s); this.__rules__[o].enabled = !0; }, this), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.disable = function (s) { - (s = Array.isArray(s) ? s : [s]).forEach(function (s) { + ((s = Array.isArray(s) ? s : [s]).forEach(function (s) { var o = this.__find__(s); if (o < 0) throw new Error('Rules manager: invalid rule name ' + s); this.__rules__[o].enabled = !1; }, this), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.getRules = function (s) { - return null === this.__cache__ && this.__compile__(), this.__cache__[s] || []; + return (null === this.__cache__ && this.__compile__(), this.__cache__[s] || []); }), (StateInline.prototype.pushPending = function () { - this.tokens.push({ type: 'text', content: this.pending, level: this.pendingLevel }), - (this.pending = ''); + (this.tokens.push({ type: 'text', content: this.pending, level: this.pendingLevel }), + (this.pending = '')); }), (StateInline.prototype.push = function (s) { - this.pending && this.pushPending(), + (this.pending && this.pushPending(), this.tokens.push(s), - (this.pendingLevel = this.level); + (this.pendingLevel = this.level)); }), (StateInline.prototype.cacheSet = function (s, o) { for (var i = this.cache.length; i <= s; i++) this.cache.push(0); @@ -62308,7 +62383,7 @@ }), (StateInline.prototype.cacheGet = function (s) { return s < this.cache.length ? this.cache[s] : 0; - }); + })); var pC = ' \n()[]\'".,!?-'; function regEscape(s) { return s.replace(/([-()\[\]{}+?*.$\^|,:# j && + (B.lastIndex > j && C.push({ type: 'text', content: x.slice(j, $.index + $[1].length), @@ -62530,7 +62604,7 @@ }), C.push({ type: 'text', content: $[2], level: L }), C.push({ type: 'abbr_close', level: --L }), - (j = B.lastIndex - $[3].length); + (j = B.lastIndex - $[3].length)); C.length && (j < x.length && C.push({ type: 'text', content: x.slice(j), level: L }), (U[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1)))); @@ -62570,7 +62644,7 @@ for (Z = s.tokens[Y].children, ee.length = 0, o = 0; o < Z.length; o++) if ('text' === (i = Z[o]).type && !mC.test(i.text)) { for (C = Z[o].level, U = ee.length - 1; U >= 0 && !(ee[U].level <= C); U--); - (ee.length = U + 1), (w = 0), (x = (u = i.content).length); + ((ee.length = U + 1), (w = 0), (x = (u = i.content).length)); e: for (; w < x && ((gC.lastIndex = w), (_ = gC.exec(u))); ) if ( ((j = !isLetter(u, _.index - 1)), @@ -62585,7 +62659,7 @@ U-- ) if (B.single === z && ee[U].level === C) { - (B = ee[U]), + ((B = ee[U]), z ? ((Z[B.token].content = replaceAt( Z[B.token].content, @@ -62607,7 +62681,7 @@ _.index, s.options.quotes[1] ))), - (ee.length = U); + (ee.length = U)); continue e; } $ @@ -62619,7 +62693,7 @@ ] ]; function Core() { - (this.options = {}), (this.ruler = new Ruler()); + ((this.options = {}), (this.ruler = new Ruler())); for (var s = 0; s < vC.length; s++) this.ruler.push(vC[s][0], vC[s][1]); } function StateBlock(s, o, i, u, _) { @@ -62664,10 +62738,10 @@ (B = 0), (C = j + 1)); } - this.bMarks.push(x.length), + (this.bMarks.push(x.length), this.eMarks.push(x.length), this.tShift.push(0), - (this.lineMax = this.bMarks.length - 1); + (this.lineMax = this.bMarks.length - 1)); } function skipBulletListMarker(s, o) { var i, u, _; @@ -62692,7 +62766,7 @@ } return u < _ && 32 !== s.src.charCodeAt(u) ? -1 : u; } - (Core.prototype.process = function (s) { + ((Core.prototype.process = function (s) { var o, i, u; for (o = 0, i = (u = this.ruler.getRules('')).length; o < i; o++) u[o](s); }), @@ -62735,13 +62809,13 @@ this.src.slice(w, x) ); for (C = new Array(o - s), _ = 0; L < o; L++, _++) - (j = this.tShift[L]) > i && (j = i), + ((j = this.tShift[L]) > i && (j = i), j < 0 && (j = 0), (w = this.bMarks[L] + j), (x = L + 1 < o || u ? this.eMarks[L] + 1 : this.eMarks[L]), - (C[_] = this.src.slice(w, x)); + (C[_] = this.src.slice(w, x))); return C.join(''); - }); + })); var bC = {}; [ 'article', @@ -62864,7 +62938,6 @@ (B = j = s.bMarks[C] + s.tShift[C]) < ($ = s.eMarks[C]) && s.tShift[C] < s.blkIndent ); - ) if ( s.src.charCodeAt(B) === _ && @@ -62934,14 +63007,14 @@ break; } if (z) break; - C.push(s.bMarks[_]), x.push(s.tShift[_]), (s.tShift[_] = -1337); + (C.push(s.bMarks[_]), x.push(s.tShift[_]), (s.tShift[_] = -1337)); } else - 32 === s.src.charCodeAt(Y) && Y++, + (32 === s.src.charCodeAt(Y) && Y++, C.push(s.bMarks[_]), (s.bMarks[_] = Y), (w = (Y = Y < Z ? s.skipSpaces(Y) : Y) >= Z), x.push(s.tShift[_]), - (s.tShift[_] = Y - s.bMarks[_]); + (s.tShift[_] = Y - s.bMarks[_])); for ( L = s.parentType, s.parentType = 'blockquote', @@ -62954,8 +63027,8 @@ V < x.length; V++ ) - (s.bMarks[V + o] = C[V]), (s.tShift[V + o] = x[V]); - return (s.blkIndent = j), !0; + ((s.bMarks[V + o] = C[V]), (s.tShift[V + o] = x[V])); + return ((s.blkIndent = j), !0); }, ['paragraph', 'blockquote', 'list'] ], @@ -63063,7 +63136,6 @@ s.isEmpty(_) || s.tShift[_] < s.blkIndent ); - ) { for (fe = !1, pe = 0, de = ce.length; pe < de; pe++) if (ce[pe](s, _, i, !0)) { @@ -63157,7 +63229,7 @@ if (C >= j) return !1; if (35 !== (_ = s.src.charCodeAt(C)) || C >= j) return !1; for (w = 1, _ = s.src.charCodeAt(++C); 35 === _ && C < j && w <= 6; ) - w++, (_ = s.src.charCodeAt(++C)); + (w++, (_ = s.src.charCodeAt(++C))); return ( !(w > 6 || (C < j && 32 !== _)) && (u || @@ -63299,7 +63371,7 @@ C < L.length; C++ ) - s.tokens.push({ + (s.tokens.push({ type: 'th_open', align: $[C], lines: [o, o + 1], @@ -63312,7 +63384,7 @@ level: s.level, children: [] }), - s.tokens.push({ type: 'th_close', level: --s.level }); + s.tokens.push({ type: 'th_close', level: --s.level })); for ( s.tokens.push({ type: 'tr_close', level: --s.level }), s.tokens.push({ type: 'thead_close', level: --s.level }), @@ -63330,13 +63402,13 @@ C < L.length; C++ ) - s.tokens.push({ type: 'td_open', align: $[C], level: s.level++ }), + (s.tokens.push({ type: 'td_open', align: $[C], level: s.level++ }), (B = L[C].substring( 124 === L[C].charCodeAt(0) ? 1 : 0, 124 === L[C].charCodeAt(L[C].length - 1) ? L[C].length - 1 : L[C].length ).trim()), s.tokens.push({ type: 'inline', content: B, level: s.level, children: [] }), - s.tokens.push({ type: 'td_close', level: --s.level }); + s.tokens.push({ type: 'td_close', level: --s.level })); s.tokens.push({ type: 'tr_close', level: --s.level }); } return ( @@ -63358,10 +63430,10 @@ if (s.tShift[B] < s.blkIndent) return !1; if ((_ = skipMarker(s, B)) < 0) return !1; if (s.level >= s.options.maxNesting) return !1; - (L = s.tokens.length), + ((L = s.tokens.length), s.tokens.push({ type: 'dl_open', lines: (j = [o, 0]), level: s.level++ }), (x = o), - (w = B); + (w = B)); e: for (;;) { for ( ee = !0, @@ -63376,7 +63448,6 @@ }), s.tokens.push({ type: 'dt_close', level: --s.level }); ; - ) { if ( (s.tokens.push({ type: 'dd_open', lines: (C = [B, 0]), level: s.level++ }), @@ -63487,7 +63558,6 @@ x < i && ((s.line = x = s.skipEmptyLines(x)), !(x >= i)) && !(s.tShift[x] < s.blkIndent); - ) { for (u = 0; u < w && !_[u](s, x, i, !1); u++); if ( @@ -63534,7 +63604,7 @@ w = 0, x = 0; if (!s) return []; - (s = (s = s.replace(kC, ' ')).replace(xC, '\n')).indexOf('\t') >= 0 && + ((s = (s = s.replace(kC, ' ')).replace(xC, '\n')).indexOf('\t') >= 0 && (s = s.replace(SC, function (o, i) { var u; return 10 === s.charCodeAt(i) @@ -63542,7 +63612,7 @@ : ((u = ' '.slice((i - w - x) % 4)), (x = i - w + 1), u); })), (_ = new StateBlock(s, this, o, i, u)), - this.tokenize(_, _.line, _.lineMax); + this.tokenize(_, _.line, _.lineMax)); }; for (var CC = [], OC = 0; OC < 256; OC++) CC.push(0); function isAlphaNum(s) { @@ -63797,11 +63867,11 @@ } s.push({ type: 'hardbreak', level: s.level }); } else - (s.pending = s.pending.slice(0, -1)), - s.push({ type: 'softbreak', level: s.level }); + ((s.pending = s.pending.slice(0, -1)), + s.push({ type: 'softbreak', level: s.level })); else s.push({ type: 'softbreak', level: s.level }); for (_++; _ < u && 32 === s.src.charCodeAt(_); ) _++; - return (s.pos = _), !0; + return ((s.pos = _), !0); } ], [ @@ -63813,18 +63883,17 @@ if (92 !== s.src.charCodeAt(u)) return !1; if (++u < _) { if ((i = s.src.charCodeAt(u)) < 256 && 0 !== CC[i]) - return o || (s.pending += s.src[u]), (s.pos += 2), !0; + return (o || (s.pending += s.src[u]), (s.pos += 2), !0); if (10 === i) { for ( o || s.push({ type: 'hardbreak', level: s.level }), u++; u < _ && 32 === s.src.charCodeAt(u); - ) u++; - return (s.pos = u), !0; + return ((s.pos = u), !0); } } - return o || (s.pending += '\\'), s.pos++, !0; + return (o || (s.pending += '\\'), s.pos++, !0); } ], [ @@ -63856,7 +63925,7 @@ !0 ); } - return o || (s.pending += _), (s.pos += _.length), !0; + return (o || (s.pending += _), (s.pos += _.length), !0); } ], [ @@ -63883,7 +63952,7 @@ if (126 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 126 === s.src.charCodeAt(u); ) u++; - if (u > j + 3) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u > j + 3) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 126 === s.src.charCodeAt(s.pos) && @@ -63935,7 +64004,7 @@ if (43 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 43 === s.src.charCodeAt(u); ) u++; - if (u !== j + 2) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u !== j + 2) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 43 === s.src.charCodeAt(s.pos) && @@ -63987,7 +64056,7 @@ if (61 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 61 === s.src.charCodeAt(u); ) u++; - if (u !== j + 2) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u !== j + 2) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 61 === s.src.charCodeAt(s.pos) && @@ -64031,7 +64100,7 @@ if (95 !== $ && 42 !== $) return !1; if (o) return !1; if (((i = (j = scanDelims(s, B)).delims), !j.can_open)) - return (s.pos += i), o || (s.pending += s.src.slice(B, s.pos)), !0; + return ((s.pos += i), o || (s.pending += s.src.slice(B, s.pos)), !0); if (s.level >= s.options.maxNesting) return !1; for (s.pos = B + i, C = [i]; s.pos < L; ) if (s.src.charCodeAt(s.pos) !== $) s.parser.skipToken(s); @@ -64043,16 +64112,16 @@ break; } if (((x -= w), 0 === C.length)) break; - (s.pos += w), (w = C.pop()); + ((s.pos += w), (w = C.pop())); } if (0 === C.length) { - (i = w), (_ = !0); + ((i = w), (_ = !0)); break; } s.pos += u; continue; } - j.can_open && C.push(u), (s.pos += u); + (j.can_open && C.push(u), (s.pos += u)); } return _ ? ((s.posMax = s.pos), @@ -64165,7 +64234,7 @@ C++ ); else x = ''; - if (C >= V || 41 !== s.src.charCodeAt(C)) return (s.pos = $), !1; + if (C >= V || 41 !== s.src.charCodeAt(C)) return ((s.pos = $), !1); C++; } else { if (s.linkLevel > 0) return !1; @@ -64178,8 +64247,8 @@ _ || (void 0 === _ && (C = u + 1), (_ = s.src.slice(i, u))), !(j = s.env.references[normalizeReference(_)])) ) - return (s.pos = $), !1; - (w = j.href), (x = j.title); + return ((s.pos = $), !1); + ((w = j.href), (x = j.title)); } return ( o || @@ -64370,9 +64439,9 @@ ); } else if ((u = s.src.slice(_).match(BC))) { var x = decodeEntity(u[1]); - if (u[1] !== x) return o || (s.pending += x), (s.pos += u[0].length), !0; + if (u[1] !== x) return (o || (s.pending += x), (s.pos += u[0].length), !0); } - return o || (s.pending += '&'), s.pos++, !0; + return (o || (s.pending += '&'), s.pos++, !0); } ] ]; @@ -64388,7 +64457,7 @@ -1 === ['vbscript', 'javascript', 'file', 'data'].indexOf(o.split(':')[0]) ); } - (ParserInline.prototype.skipToken = function (s) { + ((ParserInline.prototype.skipToken = function (s) { var o, i, u = this.ruler.getRules(''), @@ -64397,7 +64466,7 @@ if ((i = s.cacheGet(w)) > 0) s.pos = i; else { for (o = 0; o < _; o++) if (u[o](s, !0)) return void s.cacheSet(w, s.pos); - s.pos++, s.cacheSet(w, s.pos); + (s.pos++, s.cacheSet(w, s.pos)); } }), (ParserInline.prototype.tokenize = function (s) { @@ -64412,7 +64481,7 @@ (ParserInline.prototype.parse = function (s, o, i, u) { var _ = new StateInline(s, this, o, i, u); this.tokenize(_); - }); + })); var qC = { default: { options: { @@ -64529,7 +64598,7 @@ } }; function StateCore(s, o, i) { - (this.src = o), + ((this.src = o), (this.env = i), (this.options = s.options), (this.tokens = []), @@ -64537,10 +64606,10 @@ (this.inline = s.inline), (this.block = s.block), (this.renderer = s.renderer), - (this.typographer = s.typographer); + (this.typographer = s.typographer)); } function Remarkable(s, o) { - 'string' != typeof s && ((o = s), (s = 'default')), + ('string' != typeof s && ((o = s), (s = 'default')), o && null != o.linkify && console.warn( @@ -64553,37 +64622,37 @@ (this.ruler = new Ruler()), (this.options = {}), this.configure(qC[s]), - this.set(o || {}); + this.set(o || {})); } - (Remarkable.prototype.set = function (s) { + ((Remarkable.prototype.set = function (s) { index_browser_assign(this.options, s); }), (Remarkable.prototype.configure = function (s) { var o = this; if (!s) throw new Error('Wrong `remarkable` preset, check name/content'); - s.options && o.set(s.options), + (s.options && o.set(s.options), s.components && Object.keys(s.components).forEach(function (i) { s.components[i].rules && o[i].ruler.enable(s.components[i].rules, !0); - }); + })); }), (Remarkable.prototype.use = function (s, o) { - return s(this, o), this; + return (s(this, o), this); }), (Remarkable.prototype.parse = function (s, o) { var i = new StateCore(this, s, o); - return this.core.process(i), i.tokens; + return (this.core.process(i), i.tokens); }), (Remarkable.prototype.render = function (s, o) { - return (o = o || {}), this.renderer.render(this.parse(s, o), this.options, o); + return ((o = o || {}), this.renderer.render(this.parse(s, o), this.options, o)); }), (Remarkable.prototype.parseInline = function (s, o) { var i = new StateCore(this, s, o); - return (i.inlineMode = !0), this.core.process(i), i.tokens; + return ((i.inlineMode = !0), this.core.process(i), i.tokens); }), (Remarkable.prototype.renderInline = function (s, o) { - return (o = o || {}), this.renderer.render(this.parseInline(s, o), this.options, o); - }); + return ((o = o || {}), this.renderer.render(this.parseInline(s, o), this.options, o)); + })); function indexOf(s, o) { if (Array.prototype.indexOf) return s.indexOf(o); for (var i = 0, u = s.length; i < u; i++) if (s[i] === o) return i; @@ -64597,30 +64666,30 @@ } var $C = (function () { function HtmlTag(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.tagName = ''), (this.attrs = {}), (this.innerHTML = ''), (this.whitespaceRegex = /\s+/), (this.tagName = s.tagName || ''), (this.attrs = s.attrs || {}), - (this.innerHTML = s.innerHtml || s.innerHTML || ''); + (this.innerHTML = s.innerHtml || s.innerHTML || '')); } return ( (HtmlTag.prototype.setTagName = function (s) { - return (this.tagName = s), this; + return ((this.tagName = s), this); }), (HtmlTag.prototype.getTagName = function () { return this.tagName || ''; }), (HtmlTag.prototype.setAttr = function (s, o) { - return (this.getAttrs()[s] = o), this; + return ((this.getAttrs()[s] = o), this); }), (HtmlTag.prototype.getAttr = function (s) { return this.getAttrs()[s]; }), (HtmlTag.prototype.setAttrs = function (s) { - return Object.assign(this.getAttrs(), s), this; + return (Object.assign(this.getAttrs(), s), this); }), (HtmlTag.prototype.getAttrs = function () { return this.attrs || (this.attrs = {}); @@ -64636,10 +64705,9 @@ _ = i ? i.split(u) : [], w = s.split(u); (o = w.shift()); - ) -1 === indexOf(_, o) && _.push(o); - return (this.getAttrs().class = _.join(' ')), this; + return ((this.getAttrs().class = _.join(' ')), this); }), (HtmlTag.prototype.removeClass = function (s) { for ( @@ -64649,12 +64717,11 @@ _ = i ? i.split(u) : [], w = s.split(u); _.length && (o = w.shift()); - ) { var x = indexOf(_, o); -1 !== x && _.splice(x, 1); } - return (this.getAttrs().class = _.join(' ')), this; + return ((this.getAttrs().class = _.join(' ')), this); }), (HtmlTag.prototype.getClass = function () { return this.getAttrs().class || ''; @@ -64663,7 +64730,7 @@ return -1 !== (' ' + this.getClass() + ' ').indexOf(' ' + s + ' '); }), (HtmlTag.prototype.setInnerHTML = function (s) { - return (this.innerHTML = s), this; + return ((this.innerHTML = s), this); }), (HtmlTag.prototype.setInnerHtml = function (s) { return this.setInnerHTML(s); @@ -64693,13 +64760,13 @@ })(); var VC = (function () { function AnchorTagBuilder(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.newWindow = !1), (this.truncate = {}), (this.className = ''), (this.newWindow = s.newWindow || !1), (this.truncate = s.truncate || {}), - (this.className = s.className || ''); + (this.className = s.className || '')); } return ( (AnchorTagBuilder.prototype.build = function (s) { @@ -64761,7 +64828,7 @@ _ = Math.ceil(u), w = -1 * Math.floor(u), x = ''; - return w < 0 && (x = s.substr(w)), s.substr(0, _) + i + x; + return (w < 0 && (x = s.substr(w)), s.substr(0, _) + i + x); }; if (s.length <= o) return s; var w = o - _, @@ -64854,12 +64921,12 @@ })(), UC = (function () { function Match(s) { - (this.__jsduckDummyDocProp = null), + ((this.__jsduckDummyDocProp = null), (this.matchedText = ''), (this.offset = 0), (this.tagBuilder = s.tagBuilder), (this.matchedText = s.matchedText), - (this.offset = s.offset); + (this.offset = s.offset)); } return ( (Match.prototype.getMatchedText = function () { @@ -64902,9 +64969,9 @@ function __() { this.constructor = s; } - extendStatics(s, o), + (extendStatics(s, o), (s.prototype = - null === o ? Object.create(o) : ((__.prototype = o.prototype), new __())); + null === o ? Object.create(o) : ((__.prototype = o.prototype), new __()))); } var __assign = function () { return ( @@ -64926,7 +64993,7 @@ WC = (function (s) { function EmailMatch(o) { var i = s.call(this, o) || this; - return (i.email = ''), (i.email = o.email), i; + return ((i.email = ''), (i.email = o.email), i); } return ( tslib_es6_extends(EmailMatch, s), @@ -65033,7 +65100,7 @@ (MentionMatch.prototype.getCssClassSuffixes = function () { var o = s.prototype.getCssClassSuffixes.call(this), i = this.getServiceName(); - return i && o.push(i), o; + return (i && o.push(i), o); }), MentionMatch ); @@ -65136,7 +65203,7 @@ return s.replace(this.protocolRelativeRegex, ''); }), (UrlMatch.prototype.removeTrailingSlash = function (s) { - return '/' === s.charAt(s.length - 1) && (s = s.slice(0, -1)), s; + return ('/' === s.charAt(s.length - 1) && (s = s.slice(0, -1)), s); }), (UrlMatch.prototype.removePercentEncoding = function (s) { var o = s @@ -65155,7 +65222,7 @@ ); })(UC), YC = function YC(s) { - (this.__jsduckDummyDocProp = null), (this.tagBuilder = s.tagBuilder); + ((this.__jsduckDummyDocProp = null), (this.tagBuilder = s.tagBuilder)); }, XC = /[A-Za-z]/, ZC = /[\d]/, @@ -65202,7 +65269,7 @@ mO = (function (s) { function EmailMatcher() { var o = (null !== s && s.apply(this, arguments)) || this; - return (o.localPartCharRegex = dO), (o.strictTldRegex = fO), o; + return ((o.localPartCharRegex = dO), (o.strictTldRegex = fO), o); } return ( tslib_es6_extends(EmailMatcher, s), @@ -65219,7 +65286,6 @@ L = 0, B = x; j < w; - ) { var $ = s.charAt(j); switch (L) { @@ -65252,7 +65318,7 @@ } j++; } - return captureMatchIfValidAndReset(), _; + return (captureMatchIfValidAndReset(), _); function stateNonEmailAddress(s) { 'm' === s ? beginEmailMatch(1) : i.test(s) && beginEmailMatch(); } @@ -65309,10 +65375,10 @@ : captureMatchIfValidAndReset(); } function beginEmailMatch(s) { - void 0 === s && (s = 2), (L = s), (B = new gO({ idx: j })); + (void 0 === s && (s = 2), (L = s), (B = new gO({ idx: j }))); } function resetToNonEmailMatchState() { - (L = 0), (B = x); + ((L = 0), (B = x)); } function captureMatchIfValidAndReset() { if (B.hasDomainDot) { @@ -65333,10 +65399,10 @@ ); })(YC), gO = function gO(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.idx = void 0 !== s.idx ? s.idx : -1), (this.hasMailtoPrefix = !!s.hasMailtoPrefix), - (this.hasDomainDot = !!s.hasDomainDot); + (this.hasDomainDot = !!s.hasDomainDot)); }, yO = (function () { function UrlMatchValidator() {} @@ -65473,7 +65539,7 @@ }); if (ee) { var ie = i.indexOf(ee); - (i = i.substr(ie)), (L = L.substr(ie)), (U += ie); + ((i = i.substr(ie)), (L = L.substr(ie)), (U += ie)); } var ae = L ? 'scheme' : B ? 'www' : 'tld', le = !!L; @@ -65494,7 +65560,6 @@ }, j = this; null !== (o = i.exec(s)); - ) _loop_1(); return C; @@ -65534,7 +65599,7 @@ wO = (function (s) { function HashtagMatcher(o) { var i = s.call(this, o) || this; - return (i.serviceName = 'twitter'), (i.serviceName = o.serviceName), i; + return ((i.serviceName = 'twitter'), (i.serviceName = o.serviceName), i); } return ( tslib_es6_extends(HashtagMatcher, s), @@ -65548,7 +65613,6 @@ x = -1, C = 0; w < _; - ) { var j = s.charAt(w); switch (C) { @@ -65569,7 +65633,7 @@ } w++; } - return captureMatchIfValid(), u; + return (captureMatchIfValid(), u); function stateNone(s) { '#' === s ? ((C = 2), (x = w)) : lO.test(s) && (C = 1); } @@ -65616,7 +65680,7 @@ kO = (function (s) { function PhoneMatcher() { var o = (null !== s && s.apply(this, arguments)) || this; - return (o.matcherRegex = xO), o; + return ((o.matcherRegex = xO), o); } return ( tslib_es6_extends(PhoneMatcher, s), @@ -65624,7 +65688,6 @@ for ( var o, i = this.matcherRegex, u = this.tagBuilder, _ = []; null !== (o = i.exec(s)); - ) { var w = o[0], x = w.replace(/[^0-9,;#]/g, ''), @@ -65718,7 +65781,6 @@ $ = 0, V = C; j < L; - ) { var U = s.charAt(j); switch (B) { @@ -65932,21 +65994,21 @@ '>' === s ? emitTagAndPreviousTextNode() : '<' === s && startNewTag(); } function resetToDataState() { - (B = 0), (V = C); + ((B = 0), (V = C)); } function startNewTag() { - (B = 1), (V = new MO({ idx: j })); + ((B = 1), (V = new MO({ idx: j }))); } function emitTagAndPreviousTextNode() { var o = s.slice($, V.idx); - o && _(o, $), + (o && _(o, $), 'comment' === V.type ? w(V.idx) : 'doctype' === V.type ? x(V.idx) : (V.isOpening && i(V.name, V.idx), V.isClosing && u(V.name, V.idx)), resetToDataState(), - ($ = j + 1); + ($ = j + 1)); } function captureTagName() { var o = V.idx + (V.isClosing ? 2 : 1); @@ -65955,20 +66017,20 @@ $ < j && (function emitText() { var o = s.slice($, j); - _(o, $), ($ = j + 1); + (_(o, $), ($ = j + 1)); })(); } var MO = function MO(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.idx = void 0 !== s.idx ? s.idx : -1), (this.type = s.type || 'tag'), (this.name = s.name || ''), (this.isOpening = !!s.isOpening), - (this.isClosing = !!s.isClosing); + (this.isClosing = !!s.isClosing)); }, TO = (function () { function Autolinker(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.version = Autolinker.version), (this.urls = {}), (this.email = !0), @@ -66001,17 +66063,17 @@ 'boolean' == typeof s.decodePercentEncoding ? s.decodePercentEncoding : this.decodePercentEncoding), - (this.sanitizeHtml = s.sanitizeHtml || !1); + (this.sanitizeHtml = s.sanitizeHtml || !1)); var o = this.mention; if (!1 !== o && -1 === ['twitter', 'instagram', 'soundcloud', 'tiktok'].indexOf(o)) throw new Error("invalid `mention` cfg '".concat(o, "' - see docs")); var i = this.hashtag; if (!1 !== i && -1 === SO.indexOf(i)) throw new Error("invalid `hashtag` cfg '".concat(i, "' - see docs")); - (this.truncate = this.normalizeTruncateCfg(s.truncate)), + ((this.truncate = this.normalizeTruncateCfg(s.truncate)), (this.className = s.className || this.className), (this.replaceFn = s.replaceFn || this.replaceFn), - (this.context = s.context || this); + (this.context = s.context || this)); } return ( (Autolinker.link = function (s, o) { @@ -66067,10 +66129,10 @@ if (!o.global) throw new Error("`splitRegex` must have the 'g' flag set"); for (var i, u = [], _ = 0; (i = o.exec(s)); ) - u.push(s.substring(_, i.index)), + (u.push(s.substring(_, i.index)), u.push(i[0]), - (_ = i.index + i[0].length); - return u.push(s.substring(_)), u; + (_ = i.index + i[0].length)); + return (u.push(s.substring(_)), u); })(s, /( | |<|<|>|>|"|"|')/gi), x = i; w.forEach(function (s, i) { @@ -66150,7 +66212,7 @@ ); }), (Autolinker.prototype.parseText = function (s, o) { - void 0 === o && (o = 0), (o = o || 0); + (void 0 === o && (o = 0), (o = o || 0)); for (var i = this.getMatchers(), u = [], _ = 0, w = i.length; _ < w; _++) { for (var x = i[_].parseMatches(s), C = 0, j = x.length; C < j; C++) x[C].setOffset(o + x[C].getOffset()); @@ -66163,11 +66225,11 @@ this.sanitizeHtml && (s = s.replace(//g, '>')); for (var o = this.parse(s), i = [], u = 0, _ = 0, w = o.length; _ < w; _++) { var x = o[_]; - i.push(s.substring(u, x.getOffset())), + (i.push(s.substring(u, x.getOffset())), i.push(this.createMatchReturnVal(x)), - (u = x.getOffset() + x.getMatchedText().length); + (u = x.getOffset() + x.getMatchedText().length)); } - return i.push(s.substring(u)), i.join(''); + return (i.push(s.substring(u)), i.join('')); }), (Autolinker.prototype.createMatchReturnVal = function (s) { var o; @@ -66305,8 +66367,8 @@ C.push({ type: 'text', content: V[j].text, level: B }), C.push({ type: 'link_close', level: --B }), (x = x.slice(L + V[j].text.length))); - x.length && C.push({ type: 'text', content: x, level: B }), - (z[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1))); + (x.length && C.push({ type: 'text', content: x, level: B }), + (z[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1)))); } } else for (o--; _[o].level !== w.level && 'link_open' !== _[o].type; ) o--; } @@ -66317,7 +66379,7 @@ LO = __webpack_require__.n(DO); LO().addHook && LO().addHook('beforeSanitizeElements', function (s) { - return s.href && s.setAttribute('rel', 'noopener noreferrer'), s; + return (s.href && s.setAttribute('rel', 'noopener noreferrer'), s); }); const BO = function Markdown({ source: s, @@ -66806,7 +66868,7 @@ }, setIsIncludedOptions = (s) => { let o = { key: s, shouldDispatchInit: !1, defaultValue: !0 }; - return 'no value' === u.get(s, 'no value') && (o.shouldDispatchInit = !0), o; + return ('no value' === u.get(s, 'no value') && (o.shouldDispatchInit = !0), o); }, ee = w('Markdown', !0), ie = w('modelExample'), @@ -66824,7 +66886,7 @@ Se = _e.get('examples', null), xe = Se?.map((s, i) => { const u = s?.get('value', null); - return u && (s = s.set('value', getDefaultRequestBodyValue(o, L, i, j), u)), s; + return (u && (s = s.set('value', getDefaultRequestBodyValue(o, L, i, j), u)), s); }); if (((_ = qe.List.isList(_) ? _ : (0, qe.List)()), !_e.size)) return null; const Te = 'object' === _e.getIn(['schema', 'type']), @@ -66884,10 +66946,10 @@ ce = i.getIn([x, 'errors']) || _, pe = u.get(x) || !1; let ye = j.getSampleSchema(C, !1, { includeWriteOnly: !0 }); - !1 === ye && (ye = 'false'), + (!1 === ye && (ye = 'false'), 0 === ye && (ye = '0'), 'string' != typeof ye && 'object' === Z && (ye = stringify(ye)), - 'string' == typeof ye && 'array' === Z && (ye = JSON.parse(ye)); + 'string' == typeof ye && 'array' === Z && (ye = JSON.parse(ye))); const be = 'string' === Z && ('binary' === ie || 'base64' === ie); return Pe.createElement( 'tr', @@ -67074,7 +67136,7 @@ (s.find((s) => s.get('url') === o) || (0, qe.OrderedMap)()).get('variables') || (0, qe.OrderedMap)(), C = 0 !== x.size; - (0, Pe.useEffect)(() => { + ((0, Pe.useEffect)(() => { o || i(s.first()?.get('url')); }, []), (0, Pe.useEffect)(() => { @@ -67083,7 +67145,7 @@ (_.get('variables') || (0, qe.OrderedMap)()).map((s, i) => { u({ server: o, key: i, val: s.get('default') || '' }); }); - }, [o, s]); + }, [o, s])); const j = (0, Pe.useCallback)( (s) => { i(s.target.value); @@ -67204,13 +67266,13 @@ class RequestBodyEditor extends Pe.PureComponent { static defaultProps = { onChange: tA, userHasEditedBody: !1 }; constructor(s, o) { - super(s, o), + (super(s, o), (this.state = { value: stringify(s.value) || s.defaultValue }), - s.onChange(s.value); + s.onChange(s.value)); } applyDefaultValue = (s) => { const { onChange: o, defaultValue: i } = s || this.props; - return this.setState({ value: i }), o(i); + return (this.setState({ value: i }), o(i)); }; onChange = (s) => { this.props.onChange(stringify(s)); @@ -67220,10 +67282,10 @@ this.setState({ value: o }, () => this.onChange(o)); }; UNSAFE_componentWillReceiveProps(s) { - this.props.value !== s.value && + (this.props.value !== s.value && s.value !== this.state.value && this.setState({ value: stringify(s.value) }), - !s.value && s.defaultValue && this.state.value && this.applyDefaultValue(s); + !s.value && s.defaultValue && this.state.value && this.applyDefaultValue(s)); } render() { let { getComponent: s, errors: o } = this.props, @@ -67257,7 +67319,7 @@ let { onChange: o } = this.props, { value: i, name: u } = s.target, _ = Object.assign({}, this.state.value); - u ? (_[u] = i) : (_ = i), this.setState({ value: _ }, () => o(this.state)); + (u ? (_[u] = i) : (_ = i), this.setState({ value: _ }, () => o(this.state))); }; render() { let { schema: s, getComponent: o, errSelectors: i, name: u } = this.props; @@ -67375,7 +67437,7 @@ class operation_servers_OperationServers extends Pe.Component { setSelectedServer = (s) => { const { path: o, method: i } = this.props; - return this.forceUpdate(), this.props.setSelectedServer(s, `${o}:${i}`); + return (this.forceUpdate(), this.props.setSelectedServer(s, `${o}:${i}`)); }; setServerVariableValue = (s) => { const { path: o, method: i } = this.props; @@ -67447,7 +67509,7 @@ operationLink: eA }, nA = new Remarkable('commonmark'); - nA.block.ruler.enable(['table']), nA.set({ linkTarget: '_blank' }); + (nA.block.ruler.enable(['table']), nA.set({ linkTarget: '_blank' })); const sA = OAS3ComponentWrapFactory( ({ source: s, @@ -67712,11 +67774,11 @@ var i, u; if ('string' != typeof o) { const { server: _, namespace: w } = o; - (u = _), + ((u = _), (i = w ? s.getIn([w, 'serverVariableValues', u]) - : s.getIn(['serverVariableValues', u])); - } else (u = o), (i = s.getIn(['serverVariableValues', u])); + : s.getIn(['serverVariableValues', u]))); + } else ((u = o), (i = s.getIn(['serverVariableValues', u]))); i = i || (0, qe.OrderedMap)(); let _ = u; return ( @@ -67814,7 +67876,7 @@ w.reduce((s, o) => s.setIn([o, 'errors'], (0, qe.fromJS)(_)), s) ); } - return console.warn('unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR'), s; + return (console.warn('unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR'), s); }, [bA]: (s, { payload: { path: o, method: i } }) => { const u = s.getIn(['requestData', o, i, 'bodyValue']); @@ -68225,7 +68287,7 @@ }; class auths_Auths extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = {}); + (super(s, o), (this.state = {})); } onAuthChange = (s) => { let { name: o } = s; @@ -68240,7 +68302,7 @@ s.preventDefault(); let { authActions: o, definitions: i } = this.props, u = i.map((s, o) => o).toArray(); - this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u); + (this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u)); }; close = (s) => { s.preventDefault(); @@ -68744,7 +68806,7 @@ ? qe.Map.isMap(o) ? Object.entries(s.toJS()).reduce((s, [i, u]) => { const _ = o.get(i); - return (s[i] = _?.toJS() || u), s; + return ((s[i] = _?.toJS() || u), s); }, {}) : s.toJS() : {} @@ -68819,7 +68881,7 @@ B((s) => !s); }, []), ee = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69017,7 +69079,7 @@ B((s) => !s); }, []), ee = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69107,7 +69169,7 @@ B((s) => !s); }, []), ae = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69495,21 +69557,21 @@ gt = useComponent('KeywordReadOnly'), yt = useComponent('KeywordWriteOnly'), vt = useComponent('ExpandDeepButton'); - (0, Pe.useEffect)(() => { + ((0, Pe.useEffect)(() => { $(C); }, [C]), (0, Pe.useEffect)(() => { $(B); - }, [B]); + }, [B])); const bt = (0, Pe.useCallback)( (s, o) => { - L(o), !o && $(!1), u(s, o, !1); + (L(o), !o && $(!1), u(s, o, !1)); }, [u] ), _t = (0, Pe.useCallback)( (s, o) => { - L(o), $(o), u(s, o, !0); + (L(o), $(o), u(s, o, !0)); }, [u] ); @@ -69835,7 +69897,7 @@ w((s) => !s); }, []), V = (0, Pe.useCallback)((s, o) => { - w(o), C(o); + (w(o), C(o)); }, []); return 0 === Object.keys(o).length ? null @@ -69929,7 +69991,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -69991,7 +70053,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70053,7 +70115,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70185,7 +70247,7 @@ w((s) => !s); }, []), V = (0, Pe.useCallback)((s, o) => { - w(o), C(o); + (w(o), C(o)); }, []); return 'object' != typeof o || 0 === Object.keys(o).length ? null @@ -70257,7 +70319,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70883,7 +70945,7 @@ ] .filter(Boolean) .join(' | '); - return o.delete(s), x || 'any'; + return (o.delete(s), x || 'any'); }, isBooleanJSONSchema = (s) => 'boolean' == typeof s, hasKeyword = (s, o) => null !== s && 'object' == typeof s && Object.hasOwn(s, o), @@ -70971,15 +71033,15 @@ if (x || j) return `${B ? '<' : '≤'} ${B ? _ : i}`; return null; })(s); - null !== u && o.push({ scope: 'number', value: u }), - s?.format && o.push({ scope: 'string', value: s.format }); + (null !== u && o.push({ scope: 'number', value: u }), + s?.format && o.push({ scope: 'string', value: s.format })); const _ = stringifyConstraintRange('characters', s?.minLength, s?.maxLength); - null !== _ && o.push({ scope: 'string', value: _ }), + (null !== _ && o.push({ scope: 'string', value: _ }), s?.pattern && o.push({ scope: 'string', value: `matches ${s?.pattern}` }), s?.contentMediaType && o.push({ scope: 'string', value: `media type: ${s.contentMediaType}` }), s?.contentEncoding && - o.push({ scope: 'string', value: `encoding: ${s.contentEncoding}` }); + o.push({ scope: 'string', value: `encoding: ${s.contentEncoding}` })); const w = stringifyConstraintRange( s?.hasUniqueItems ? 'unique items' : 'items', s?.minItems, @@ -70989,7 +71051,7 @@ const x = stringifyConstraintRange('contained items', s?.minContains, s?.maxContains); null !== x && o.push({ scope: 'array', value: x }); const C = stringifyConstraintRange('properties', s?.minProperties, s?.maxProperties); - return null !== C && o.push({ scope: 'object', value: C }), o; + return (null !== C && o.push({ scope: 'object', value: C }), o); }, getDependentRequired = (s, o) => o?.dependentRequired @@ -71067,7 +71129,9 @@ }, HOC = (o) => Pe.createElement(tI.Provider, { value: i }, Pe.createElement(s, o)); return ( - (HOC.contexts = { JSONSchemaContext: tI }), (HOC.displayName = s.displayName), HOC + (HOC.contexts = { JSONSchemaContext: tI }), + (HOC.displayName = s.displayName), + HOC ); }, json_schema_2020_12 = () => ({ @@ -71147,7 +71211,7 @@ (Number.isInteger(u) && u > 0 && (j = s.slice(0, u)), Number.isInteger(i) && i > 0) ) for (let s = 0; j.length < i; s += 1) j.push(j[s % j.length]); - return !0 === _ && (j = Array.from(new Set(j))), j; + return (!0 === _ && (j = Array.from(new Set(j))), j); })(o, s), object = () => { throw new Error('Not implemented'); @@ -71263,7 +71327,7 @@ x = 0; for (let s = 0; s < o.length; s++) for (w = (w << 8) | o.charCodeAt(s), x += 8; x >= 5; ) - (_ += i.charAt((w >>> (x - 5)) & 31)), (x -= 5); + ((_ += i.charAt((w >>> (x - 5)) & 31)), (x -= 5)); x > 0 && ((_ += i.charAt((w << (5 - x)) & 31)), (u = (8 - ((8 * o.length) % 5)) % 5)); for (let s = 0; s < u; s++) _ += '='; return _; @@ -71567,7 +71631,7 @@ u = inferTypeFromValue(o); i = 'string' == typeof u ? u : i; } - return o.delete(s), i || MI; + return (o.delete(s), i || MI); }, type_getType = (s) => inferType(s), typeCast = (s) => @@ -71620,14 +71684,14 @@ TI = merge_merge, main_sampleFromSchemaGeneric = (s, o = {}, i = void 0, u = !1) => { if (null == s && void 0 === i) return; - 'function' == typeof s?.toJS && (s = s.toJS()), (s = typeCast(s)); + ('function' == typeof s?.toJS && (s = s.toJS()), (s = typeCast(s))); let _ = void 0 !== i || hasExample(s); const w = !_ && Array.isArray(s.oneOf) && s.oneOf.length > 0, x = !_ && Array.isArray(s.anyOf) && s.anyOf.length > 0; if (!_ && (w || x)) { const i = typeCast(random_pick(w ? s.oneOf : s.anyOf)); - !(s = TI(s, i, o)).xml && i.xml && (s.xml = i.xml), - hasExample(s) && hasExample(i) && (_ = !0); + (!(s = TI(s, i, o)).xml && i.xml && (s.xml = i.xml), + hasExample(s) && hasExample(i) && (_ = !0)); } const C = {}; let { xml: j, properties: L, additionalProperties: B, items: $, contains: V } = s || {}, @@ -71748,9 +71812,9 @@ ((ce[s]?.readOnly && !z) || (ce[s]?.writeOnly && !Y) || (ce[s]?.xml?.attribute ? (C[ce[s].xml.name || s] = _[s]) : pe(s, _[s]))); - return hs()(C) || le[Z].push({ _attr: C }), le; + return (hs()(C) || le[Z].push({ _attr: C }), le); } - return (le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le; + return ((le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le); } if ('array' === U) { let i = []; @@ -71806,10 +71870,10 @@ pe(s)); if ((u && C && le[Z].push({ _attr: C }), hasExceededMaxProperties())) return le; if (predicates_isBooleanJSONSchema(B) && B) - u + (u ? le[Z].push({ additionalProp: 'Anything can be here' }) : (le.additionalProp1 = {}), - de++; + de++); else if (isJSONSchemaObject(B)) { const i = B, _ = main_sampleFromSchemaGeneric(i, o, void 0, u); @@ -71824,7 +71888,7 @@ if (hasExceededMaxProperties()) return le; if (u) { const o = {}; - (o['additionalProp' + s] = _.notagname), le[Z].push(o); + ((o['additionalProp' + s] = _.notagname), le[Z].push(o)); } else le['additionalProp' + s] = _; de++; } @@ -71873,10 +71937,10 @@ x = w.jsonSchema202012.getJsonSampleSchema(o, i, u, _); let C; try { - (C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), - '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1)); + ((C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), + '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1))); } catch (s) { - return console.error(s), 'error: could not generate yaml example'; + return (console.error(s), 'error: could not generate yaml example'); } return C.replace(/\t/g, ' '); }, @@ -71977,7 +72041,7 @@ const s = {}; return ( (s.promise = new Promise((o, i) => { - (s.resolve = o), (s.reject = i); + ((s.resolve = o), (s.reject = i)); })), s ); @@ -72198,13 +72262,13 @@ const _ = []; for (const s of o) { const o = { ...s }; - Object.hasOwn(o, 'domNode') && ((i = o.domNode), delete o.domNode), + (Object.hasOwn(o, 'domNode') && ((i = o.domNode), delete o.domNode), Object.hasOwn(o, 'urls.primaryName') ? ((u = o['urls.primaryName']), delete o['urls.primaryName']) : Array.isArray(o.urls) && Object.hasOwn(o.urls, 'primaryName') && ((u = o.urls.primaryName), delete o.urls.primaryName), - _.push(o); + _.push(o)); } const w = We()(s, ..._); return ( @@ -72223,7 +72287,7 @@ x.register([u.plugins, w]); const C = x.getSystem(), persistConfigs = (s) => { - x.setConfigs(s), C.configsActions.loaded(); + (x.setConfigs(s), C.configsActions.loaded()); }, updateSpec = (s) => { !o.url && 'object' == typeof s.spec && Object.keys(s.spec).length > 0 @@ -72250,12 +72314,12 @@ const { configUrl: s } = u, i = await sources_url({ url: s, system: C })(u), _ = SwaggerUI.config.merge({}, u, i, o); - persistConfigs(_), null !== i && updateSpec(_), render(_); + (persistConfigs(_), null !== i && updateSpec(_), render(_)); })(), C) : (persistConfigs(u), updateSpec(u), render(u), C); } - (SwaggerUI.System = Store), + ((SwaggerUI.System = Store), (SwaggerUI.config = { defaults: FI, merge: config_merge, @@ -72289,7 +72353,7 @@ SyntaxHighlighting: syntax_highlighting, Versions: versions, SafeRender: safe_render - }); + })); const WI = SwaggerUI; })(), (_ = _.default) diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 3c29462349e..f70f3e862b5 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -61,7 +61,7 @@ def upload_file(file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[by contents = file.read() if not contents: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) - file_path = f'{UPLOAD_DIR}/{filename}' + file_path = os.path.join(UPLOAD_DIR, filename) with open(file_path, 'wb') as f: f.write(contents) return contents, file_path @@ -74,8 +74,8 @@ def get_file(file_path: str) -> str: @staticmethod def delete_file(file_path: str) -> None: """Handles deletion of the file from local storage.""" - filename = file_path.split('/')[-1] - file_path = f'{UPLOAD_DIR}/{filename}' + filename = os.path.basename(file_path) + file_path = os.path.join(UPLOAD_DIR, filename) if os.path.isfile(file_path): os.remove(file_path) else: @@ -140,7 +140,7 @@ def sanitize_tag_value(s: str) -> str: def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: """Handles uploading of the file to S3 storage.""" - _, file_path = LocalStorageProvider.upload_file(file, filename, tags) + contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) s3_key = os.path.join(self.key_prefix, filename) try: self.s3_client.upload_file(file_path, self.bucket_name, s3_key) @@ -153,7 +153,7 @@ def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tu Tagging=tagging, ) return ( - open(file_path, 'rb').read(), + contents, f's3://{self.bucket_name}/{s3_key}', ) except ClientError as e: @@ -202,7 +202,7 @@ def _extract_s3_key(self, full_file_path: str) -> str: return '/'.join(full_file_path.split('//')[1].split('/')[1:]) def _get_local_file_path(self, s3_key: str) -> str: - return f'{UPLOAD_DIR}/{s3_key.split("/")[-1]}' + return os.path.join(UPLOAD_DIR, s3_key.split('/')[-1]) class GCSStorageProvider(StorageProvider): @@ -234,7 +234,7 @@ def get_file(self, file_path: str) -> str: """Handles downloading of the file from GCS storage.""" try: filename = file_path.removeprefix('gs://').split('/')[1] - local_file_path = f'{UPLOAD_DIR}/{filename}' + local_file_path = os.path.join(UPLOAD_DIR, filename) blob = self.bucket.get_blob(filename) blob.download_to_filename(local_file_path) @@ -298,7 +298,7 @@ def get_file(self, file_path: str) -> str: """Handles downloading of the file from Azure Blob Storage.""" try: filename = file_path.split('/')[-1] - local_file_path = f'{UPLOAD_DIR}/{filename}' + local_file_path = os.path.join(UPLOAD_DIR, filename) blob_client = self.container_client.get_blob_client(filename) with open(local_file_path, 'wb') as download_file: download_file.write(blob_client.download_blob().readall()) diff --git a/backend/open_webui/test/test_oauth_google_groups.py b/backend/open_webui/test/test_oauth_google_groups.py new file mode 100644 index 00000000000..9bc1de9af25 --- /dev/null +++ b/backend/open_webui/test/test_oauth_google_groups.py @@ -0,0 +1,266 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import aiohttp +from open_webui.utils.oauth import OAuthManager +from open_webui.config import AppConfig + + +class TestOAuthGoogleGroups: + """Basic tests for Google OAuth Groups functionality""" + + def setup_method(self): + """Setup test fixtures""" + self.oauth_manager = OAuthManager(app=MagicMock()) + + @pytest.mark.asyncio + async def test_fetch_google_groups_success(self): + """Test successful Google groups fetching with proper aiohttp mocking""" + # Mock response data from Google Cloud Identity API + mock_response_data = { + "memberships": [ + { + "groupKey": {"id": "admin@company.com"}, + "group": "groups/123", + "displayName": "Admin Group" + }, + { + "groupKey": {"id": "users@company.com"}, + "group": "groups/456", + "displayName": "Users Group" + } + ] + } + + # Create properly structured async mocks + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + # Mock the async context manager for session.get() + mock_get_context = MagicMock() + mock_get_context.__aenter__ = AsyncMock(return_value=mock_response) + mock_get_context.__aexit__ = AsyncMock(return_value=None) + + # Mock the session + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_get_context) + + # Mock the async context manager for ClientSession + mock_session_context = MagicMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session_context): + groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity( + access_token="test_token", + user_email="user@company.com" + ) + + # Verify the results + assert groups == ["admin@company.com", "users@company.com"] + + # Verify the HTTP call was made correctly + mock_session.get.assert_called_once() + call_args = mock_session.get.call_args + + # Check the URL contains the user email (URL encoded) + url_arg = call_args[0][0] # First positional argument + assert "user%40company.com" in url_arg # @ is encoded as %40 + assert "searchTransitiveGroups" in url_arg + + # Check headers contain the bearer token + headers_arg = call_args[1]["headers"] # headers keyword argument + assert headers_arg["Authorization"] == "Bearer test_token" + assert headers_arg["Content-Type"] == "application/json" + + @pytest.mark.asyncio + async def test_fetch_google_groups_api_error(self): + """Test handling of API errors when fetching groups""" + # Mock failed response + mock_response = MagicMock() + mock_response.status = 403 + mock_response.text = AsyncMock(return_value="Permission denied") + + # Mock the async context manager for session.get() + mock_get_context = MagicMock() + mock_get_context.__aenter__ = AsyncMock(return_value=mock_response) + mock_get_context.__aexit__ = AsyncMock(return_value=None) + + # Mock the session + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_get_context) + + # Mock the async context manager for ClientSession + mock_session_context = MagicMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session_context): + groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity( + access_token="test_token", + user_email="user@company.com" + ) + + # Should return empty list on error + assert groups == [] + + @pytest.mark.asyncio + async def test_fetch_google_groups_network_error(self): + """Test handling of network errors when fetching groups""" + # Mock the session that raises an exception when get() is called + mock_session = MagicMock() + mock_session.get.side_effect = aiohttp.ClientError("Network error") + + # Mock the async context manager for ClientSession + mock_session_context = MagicMock() + mock_session_context.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_context.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session_context): + groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity( + access_token="test_token", + user_email="user@company.com" + ) + + # Should return empty list on network error + assert groups == [] + + @pytest.mark.asyncio + async def test_get_user_role_with_google_groups(self): + """Test role assignment using Google groups""" + # Mock configuration + mock_config = MagicMock() + mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True + mock_config.OAUTH_ROLES_CLAIM = "groups" + mock_config.OAUTH_ALLOWED_ROLES = ["users@company.com"] + mock_config.OAUTH_ADMIN_ROLES = ["admin@company.com"] + mock_config.DEFAULT_USER_ROLE = "pending" + mock_config.OAUTH_EMAIL_CLAIM = "email" + + user_data = {"email": "user@company.com"} + + # Mock Google OAuth scope check and Users class + with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \ + patch("open_webui.utils.oauth.GOOGLE_OAUTH_SCOPE") as mock_scope, \ + patch("open_webui.utils.oauth.Users") as mock_users, \ + patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch: + + mock_scope.value = "openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly" + mock_fetch.return_value = ["admin@company.com", "users@company.com"] + mock_users.get_num_users.return_value = 5 # Not first user + + role = await self.oauth_manager.get_user_role( + user=None, + user_data=user_data, + provider="google", + access_token="test_token" + ) + + # Should assign admin role since user is in admin group + assert role == "admin" + mock_fetch.assert_called_once_with("test_token", "user@company.com") + + @pytest.mark.asyncio + async def test_get_user_role_fallback_to_claims(self): + """Test fallback to traditional claims when Google groups fail""" + mock_config = MagicMock() + mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True + mock_config.OAUTH_ROLES_CLAIM = "groups" + mock_config.OAUTH_ALLOWED_ROLES = ["users"] + mock_config.OAUTH_ADMIN_ROLES = ["admin"] + mock_config.DEFAULT_USER_ROLE = "pending" + mock_config.OAUTH_EMAIL_CLAIM = "email" + + user_data = { + "email": "user@company.com", + "groups": ["users"] + } + + with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \ + patch("open_webui.utils.oauth.GOOGLE_OAUTH_SCOPE") as mock_scope, \ + patch("open_webui.utils.oauth.Users") as mock_users, \ + patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch: + + # Mock scope without Cloud Identity + mock_scope.value = "openid email profile" + mock_users.get_num_users.return_value = 5 # Not first user + + role = await self.oauth_manager.get_user_role( + user=None, + user_data=user_data, + provider="google", + access_token="test_token" + ) + + # Should use traditional claims since Cloud Identity scope not present + assert role == "user" + mock_fetch.assert_not_called() + + @pytest.mark.asyncio + async def test_get_user_role_non_google_provider(self): + """Test that non-Google providers use traditional claims""" + mock_config = MagicMock() + mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True + mock_config.OAUTH_ROLES_CLAIM = "roles" + mock_config.OAUTH_ALLOWED_ROLES = ["user"] + mock_config.OAUTH_ADMIN_ROLES = ["admin"] + mock_config.DEFAULT_USER_ROLE = "pending" + + user_data = {"roles": ["user"]} + + with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \ + patch("open_webui.utils.oauth.Users") as mock_users, \ + patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch: + + mock_users.get_num_users.return_value = 5 # Not first user + + role = await self.oauth_manager.get_user_role( + user=None, + user_data=user_data, + provider="microsoft", + access_token="test_token" + ) + + # Should use traditional claims for non-Google providers + assert role == "user" + mock_fetch.assert_not_called() + + @pytest.mark.asyncio + async def test_update_user_groups_with_google_groups(self): + """Test group management using Google groups from user_data""" + mock_config = MagicMock() + mock_config.OAUTH_GROUPS_CLAIM = "groups" + mock_config.OAUTH_BLOCKED_GROUPS = "[]" + mock_config.ENABLE_OAUTH_GROUP_CREATION = False + + # Mock user with Google groups data + mock_user = MagicMock() + mock_user.id = "user123" + + user_data = { + "google_groups": ["developers@company.com", "employees@company.com"] + } + + # Mock existing groups and user groups + mock_existing_group = MagicMock() + mock_existing_group.name = "developers@company.com" + mock_existing_group.id = "group1" + mock_existing_group.user_ids = [] + mock_existing_group.permissions = {"read": True} + mock_existing_group.description = "Developers group" + + with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \ + patch("open_webui.utils.oauth.Groups") as mock_groups: + + mock_groups.get_groups_by_member_id.return_value = [] + mock_groups.get_groups.return_value = [mock_existing_group] + + await self.oauth_manager.update_user_groups( + user=mock_user, + user_data=user_data, + default_permissions={"read": True} + ) + + # Should use Google groups instead of traditional claims + mock_groups.get_groups_by_member_id.assert_called_once_with("user123") + mock_groups.update_group_by_id.assert_called() diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 73589810d64..ef408ab8afa 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -37,7 +37,7 @@ from open_webui.models.messages import Messages, Message from open_webui.models.groups import Groups from open_webui.models.memories import Memories -from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT from open_webui.utils.sanitize import sanitize_code log = logging.getLogger(__name__) @@ -56,19 +56,30 @@ async def get_current_timestamp( """ Get the current Unix timestamp in seconds. - :return: JSON with current_timestamp (seconds) and current_iso (ISO format) + :return: JSON with current_timestamp (seconds), current_iso (UTC ISO format), and user_local_iso (user's local time) """ try: import datetime + from zoneinfo import ZoneInfo now = datetime.datetime.now(datetime.timezone.utc) - return json.dumps( - { - 'current_timestamp': int(now.timestamp()), - 'current_iso': now.isoformat(), - }, - ensure_ascii=False, - ) + result = { + 'current_timestamp': int(now.timestamp()), + 'current_iso': now.isoformat(), + } + + # Include the user's local time if timezone is available + tz_name = __user__.get('timezone') if __user__ else None + if tz_name: + try: + user_tz = ZoneInfo(tz_name) + user_now = now.astimezone(user_tz) + result['user_local_iso'] = user_now.isoformat() + result['user_timezone'] = tz_name + except Exception: + pass + + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'get_current_timestamp error: {e}') return json.dumps({'error': str(e)}) @@ -110,15 +121,27 @@ async def calculate_timestamp( adjusted_ts = int(adjusted.timestamp()) - return json.dumps( - { - 'current_timestamp': current_ts, - 'current_iso': now.isoformat(), - 'calculated_timestamp': adjusted_ts, - 'calculated_iso': adjusted.isoformat(), - }, - ensure_ascii=False, - ) + result = { + 'current_timestamp': current_ts, + 'current_iso': now.isoformat(), + 'calculated_timestamp': adjusted_ts, + 'calculated_iso': adjusted.isoformat(), + } + + # Include the user's local time if timezone is available + tz_name = __user__.get('timezone') if __user__ else None + if tz_name: + try: + from zoneinfo import ZoneInfo + + user_tz = ZoneInfo(tz_name) + result['user_local_iso'] = now.astimezone(user_tz).isoformat() + result['calculated_local_iso'] = adjusted.astimezone(user_tz).isoformat() + result['user_timezone'] = tz_name + except Exception: + pass + + return json.dumps(result, ensure_ascii=False) except ImportError: # Fallback without dateutil import datetime @@ -128,15 +151,26 @@ async def calculate_timestamp( total_days = days_ago + (weeks_ago * 7) + (months_ago * 30) + (years_ago * 365) adjusted = now - datetime.timedelta(days=total_days) adjusted_ts = int(adjusted.timestamp()) - return json.dumps( - { - 'current_timestamp': current_ts, - 'current_iso': now.isoformat(), - 'calculated_timestamp': adjusted_ts, - 'calculated_iso': adjusted.isoformat(), - }, - ensure_ascii=False, - ) + result = { + 'current_timestamp': current_ts, + 'current_iso': now.isoformat(), + 'calculated_timestamp': adjusted_ts, + 'calculated_iso': adjusted.isoformat(), + } + + tz_name = __user__.get('timezone') if __user__ else None + if tz_name: + try: + from zoneinfo import ZoneInfo + + user_tz = ZoneInfo(tz_name) + result['user_local_iso'] = now.astimezone(user_tz).isoformat() + result['calculated_local_iso'] = adjusted.astimezone(user_tz).isoformat() + result['user_timezone'] = tz_name + except Exception: + pass + + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'calculate_timestamp error: {e}') return json.dumps({'error': str(e)}) @@ -149,7 +183,7 @@ async def calculate_timestamp( async def search_web( query: str, - count: int = 5, + count: Optional[int] = None, __request__: Request = None, __user__: dict = None, ) -> str: @@ -158,7 +192,7 @@ async def search_web( or topics not covered in internal documents. :param query: The search query to look up - :param count: Number of results to return (default: 5) + :param count: Number of results to return (default: admin-configured value) :return: JSON with search results containing title, link, and snippet for each result """ if __request__ is None: @@ -168,12 +202,9 @@ async def search_web( engine = __request__.app.state.config.WEB_SEARCH_ENGINE user = UserModel(**__user__) if __user__ else None - # Enforce maximum result count from config to prevent abuse - count = ( - count - if count < __request__.app.state.config.WEB_SEARCH_RESULT_COUNT - else __request__.app.state.config.WEB_SEARCH_RESULT_COUNT - ) + configured = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT + max_count = 5 if configured is None else configured + count = max(1, min(count, max_count)) if count is not None else max_count results = await asyncio.to_thread(_search_web, __request__, engine, query, user) @@ -207,9 +238,13 @@ async def fetch_url( content, _ = await asyncio.to_thread(get_content_from_url, __request__, url) # Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH) - max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None) - if max_length and max_length > 0 and len(content) > max_length: - content = content[:max_length] + '\n\n[Content truncated...]' + # Guard: content may be None if the web loader silently failed + if content is not None: + max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None) + if max_length and max_length > 0 and len(content) > max_length: + content = content[:max_length] + '\n\n[Content truncated...]' + else: + content = '' return content except Exception as e: @@ -253,7 +288,7 @@ async def generate_image( # Persist files to DB if chat context is available if __chat_id__ and __message_id__ and images: - db_files = Chats.add_message_files_by_id_and_message_id( + db_files = await Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, image_files, @@ -320,7 +355,7 @@ async def edit_image( # Persist files to DB if chat context is available if __chat_id__ and __message_id__ and images: - db_files = Chats.add_message_files_by_id_and_message_id( + db_files = await Chats.add_message_files_by_id_and_message_id( __chat_id__, __message_id__, image_files, @@ -393,7 +428,8 @@ async def execute_code( if CODE_INTERPRETER_BLOCKED_MODULES: import textwrap - blocking_code = textwrap.dedent(f""" + blocking_code = textwrap.dedent( + f""" import builtins BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} @@ -409,7 +445,8 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): return _real_import(name, globals, locals, fromlist, level) builtins.__import__ = restricted_import - """) + """ + ) code = blocking_code + '\n' + code engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') @@ -434,9 +471,15 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): # Parse the output - pyodide returns dict with stdout, stderr, result if isinstance(output, dict): - stdout = output.get('stdout', '') - stderr = output.get('stderr', '') - result = output.get('result', '') + # Handle error responses from event_caller (e.g. session disconnected, timeout) + if output.get('error') and not output.get('stdout') and not output.get('result'): + stderr = output['error'] + stdout = '' + result = '' + else: + stdout = output.get('stdout', '') + stderr = output.get('stderr', '') + result = output.get('result', '') else: stdout = '' stderr = '' @@ -474,14 +517,14 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): from open_webui.models.users import Users from open_webui.utils.files import get_image_url_from_base64 - user = Users.get_user_by_id(__user__['id']) + user = await Users.get_user_by_id(__user__['id']) # Extract and upload images from stdout if stdout and isinstance(stdout, str): stdout_lines = stdout.split('\n') for idx, line in enumerate(stdout_lines): if 'data:image/png;base64' in line: - image_url = get_image_url_from_base64( + image_url = await get_image_url_from_base64( __request__, line, __metadata__ or {}, @@ -496,7 +539,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): result_lines = result.split('\n') for idx, line in enumerate(result_lines): if 'data:image/png;base64' in line: - image_url = get_image_url_from_base64( + image_url = await get_image_url_from_base64( __request__, line, __metadata__ or {}, @@ -651,10 +694,10 @@ async def delete_memory( try: user = UserModel(**__user__) if __user__ else None - result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) + result = await Memories.delete_memory_by_id_and_user_id(memory_id, user.id) if result: - VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) + await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return json.dumps( {'status': 'success', 'message': f'Memory {memory_id} deleted'}, ensure_ascii=False, @@ -681,7 +724,7 @@ async def list_memories( try: user = UserModel(**__user__) if __user__ else None - memories = Memories.get_memories_by_user_id(user.id) + memories = await Memories.get_memories_by_user_id(user.id) if memories: result = [ @@ -731,9 +774,9 @@ async def search_notes( try: user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] - result = Notes.search_notes( + result = await Notes.search_notes( user_id=user_id, filter={ 'query': query, @@ -761,14 +804,26 @@ async def search_notes( content_snippet = '' if note.data and note.data.get('content', {}).get('md'): md_content = note.data['content']['md'] - lower_content = md_content.lower() - lower_query = query.lower() - idx = lower_content.find(lower_query) - if idx != -1: - start = max(0, idx - 50) - end = min(len(md_content), idx + len(query) + 100) + content_lower = md_content.lower() + + # Find the first matching word to center the snippet around. + search_words = query.lower().split() + match_pos = -1 + match_len = len(query) + for word in search_words: + found_pos = content_lower.find(word) + if found_pos != -1: + match_pos = found_pos + match_len = len(word) + break + + if match_pos != -1: + snippet_start = max(0, match_pos - 50) + snippet_end = min(len(md_content), match_pos + match_len + 100) content_snippet = ( - ('...' if start > 0 else '') + md_content[start:end] + ('...' if end < len(md_content) else '') + ('...' if snippet_start > 0 else '') + + md_content[snippet_start:snippet_end] + + ('...' if snippet_end < len(md_content) else '') ) else: content_snippet = md_content[:150] + ('...' if len(md_content) > 150 else '') @@ -809,18 +864,18 @@ async def view_note( return json.dumps({'error': 'User context not available'}) try: - note = Notes.get_note_by_id(note_id) + note = await Notes.get_note_by_id(note_id) if not note: return json.dumps({'error': 'Note not found'}) # Check access permission user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] from open_webui.models.access_grants import AccessGrants - if note.user_id != user_id and not AccessGrants.has_access( + if note.user_id != user_id and not await AccessGrants.has_access( user_id=user_id, resource_type='note', resource_id=note.id, @@ -879,7 +934,7 @@ async def write_note( access_grants=[], # Private by default - only owner can access ) - new_note = Notes.insert_new_note(user_id, form) + new_note = await Notes.insert_new_note(user_id, form) if not new_note: return json.dumps({'error': 'Failed to create note'}) @@ -922,18 +977,18 @@ async def replace_note_content( try: from open_webui.models.notes import NoteUpdateForm - note = Notes.get_note_by_id(note_id) + note = await Notes.get_note_by_id(note_id) if not note: return json.dumps({'error': 'Note not found'}) # Check write permission user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] from open_webui.models.access_grants import AccessGrants - if note.user_id != user_id and not AccessGrants.has_access( + if note.user_id != user_id and not await AccessGrants.has_access( user_id=user_id, resource_type='note', resource_id=note.id, @@ -948,7 +1003,7 @@ async def replace_note_content( update_data['title'] = title form = NoteUpdateForm(**update_data) - updated_note = Notes.update_note_by_id(note_id, form) + updated_note = await Notes.update_note_by_id(note_id, form) if not updated_note: return json.dumps({'error': 'Failed to update note'}) @@ -999,7 +1054,7 @@ async def search_chats( try: user_id = __user__.get('id') - chats = Chats.get_chats_by_user_id_and_search_text( + chats = await Chats.get_chats_by_user_id_and_search_text( user_id=user_id, search_text=query, include_archived=False, @@ -1074,7 +1129,7 @@ async def view_chat( try: user_id = __user__.get('id') - chat = Chats.get_chat_by_id_and_user_id(chat_id, user_id) + chat = await Chats.get_chat_by_id_and_user_id(chat_id, user_id) if not chat: return json.dumps({'error': 'Chat not found or access denied'}) @@ -1146,7 +1201,7 @@ async def search_channels( user_id = __user__.get('id') # Get all channels the user has access to - all_channels = Channels.get_channels_by_user_id(user_id) + all_channels = await Channels.get_channels_by_user_id(user_id) # Filter by query lower_query = query.lower() @@ -1202,7 +1257,7 @@ async def search_channel_messages( user_id = __user__.get('id') # Get all channels the user has access to - user_channels = Channels.get_channels_by_user_id(user_id) + user_channels = await Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] channel_map = {c.id: c for c in user_channels} @@ -1214,7 +1269,7 @@ async def search_channel_messages( end_ts = end_timestamp * 1_000_000_000 if end_timestamp else None # Search messages using the model method - matching_messages = Messages.search_messages_by_channel_ids( + matching_messages = await Messages.search_messages_by_channel_ids( channel_ids=channel_ids, query=query, start_timestamp=start_ts, @@ -1275,18 +1330,18 @@ async def view_channel_message( try: user_id = __user__.get('id') - message = Messages.get_message_by_id(message_id) + message = await Messages.get_message_by_id(message_id) if not message: return json.dumps({'error': 'Message not found'}) # Verify user has access to the channel - channel = Channels.get_channel_by_id(message.channel_id) + channel = await Channels.get_channel_by_id(message.channel_id) if not channel: return json.dumps({'error': 'Channel not found'}) # Check if user has access to the channel - user_channels = Channels.get_channels_by_user_id(user_id) + user_channels = await Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if message.channel_id not in channel_ids: @@ -1337,24 +1392,24 @@ async def view_channel_thread( user_id = __user__.get('id') # Get the parent message - parent_message = Messages.get_message_by_id(parent_message_id) + parent_message = await Messages.get_message_by_id(parent_message_id) if not parent_message: return json.dumps({'error': 'Message not found'}) # Verify user has access to the channel - channel = Channels.get_channel_by_id(parent_message.channel_id) + channel = await Channels.get_channel_by_id(parent_message.channel_id) if not channel: return json.dumps({'error': 'Channel not found'}) - user_channels = Channels.get_channels_by_user_id(user_id) + user_channels = await Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if parent_message.channel_id not in channel_ids: return json.dumps({'error': 'Access denied'}) # Get all thread replies - thread_replies = Messages.get_thread_replies_by_message_id(parent_message_id) + thread_replies = await Messages.get_thread_replies_by_message_id(parent_message_id) # Build the response messages = [] @@ -1428,9 +1483,9 @@ async def list_knowledge_bases( from open_webui.models.knowledge import Knowledges user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] - result = Knowledges.search_knowledge_bases( + result = await Knowledges.search_knowledge_bases( user_id, filter={ 'query': '', @@ -1443,7 +1498,7 @@ async def list_knowledge_bases( knowledge_bases = [] for knowledge_base in result.items: - files = Knowledges.get_files_by_id(knowledge_base.id) + files = await Knowledges.get_files_by_id(knowledge_base.id) file_count = len(files) if files else 0 knowledge_bases.append( @@ -1487,9 +1542,9 @@ async def search_knowledge_bases( from open_webui.models.knowledge import Knowledges user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] - result = Knowledges.search_knowledge_bases( + result = await Knowledges.search_knowledge_bases( user_id, filter={ 'query': query, @@ -1502,7 +1557,7 @@ async def search_knowledge_bases( knowledge_bases = [] for knowledge_base in result.items: - files = Knowledges.get_files_by_id(knowledge_base.id) + files = await Knowledges.get_files_by_id(knowledge_base.id) file_count = len(files) if files else 0 knowledge_bases.append( @@ -1528,9 +1583,11 @@ async def search_knowledge_files( skip: int = 0, __request__: Request = None, __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, ) -> str: """ - Search files across knowledge bases the user has access to. + Search files by filename across knowledge bases the user has access to. + When the model has attached knowledge, searches only within attached KBs and files. :param query: The search query to find matching files by filename :param knowledge_id: Optional KB id to limit search to a specific knowledge base @@ -1546,12 +1603,93 @@ async def search_knowledge_files( try: from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.access_grants import AccessGrants user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_role = __user__.get('role', 'user') + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] + + # When model has attached knowledge, scope to attached KBs/files only + if __model_knowledge__: + attached_kb_ids = set() + attached_file_ids = set() + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + if item_type == 'collection': + attached_kb_ids.add(item_id) + elif item_type == 'file': + attached_file_ids.add(item_id) + + # If knowledge_id specified, verify it's in the attached set + if knowledge_id: + if knowledge_id not in attached_kb_ids: + return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'}) + attached_kb_ids = {knowledge_id} + + all_files = [] + + # Search within attached KBs + for kb_id in attached_kb_ids: + knowledge = await Knowledges.get_knowledge_by_id(kb_id) + if not knowledge: + continue + + if not ( + user_role == 'admin' + or knowledge.user_id == user_id + or await AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + continue + + result = await Knowledges.search_files_by_id( + knowledge_id=kb_id, + user_id=user_id, + filter={'query': query}, + skip=0, + limit=count + skip, + ) + + for file in result.items: + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'knowledge_id': knowledge.id, + 'knowledge_name': knowledge.name, + 'updated_at': file.updated_at, + } + ) + + # Search within directly attached files (filename match) + if not knowledge_id and attached_file_ids: + query_lower = query.lower() if query else '' + for file_id in attached_file_ids: + file = await Files.get_file_by_id(file_id) + if file and (not query_lower or query_lower in file.filename.lower()): + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) + + # Apply pagination across combined results + all_files = all_files[skip : skip + count] + return json.dumps(all_files, ensure_ascii=False) + # No attached knowledge - search all accessible KBs if knowledge_id: - result = Knowledges.search_files_by_id( + result = await Knowledges.search_files_by_id( knowledge_id=knowledge_id, user_id=user_id, filter={'query': query}, @@ -1559,7 +1697,7 @@ async def search_knowledge_files( limit=count, ) else: - result = Knowledges.search_knowledge_files( + result = await Knowledges.search_knowledge_files( filter={ 'query': query, 'user_id': user_id, @@ -1587,17 +1725,26 @@ async def search_knowledge_files( return json.dumps({'error': str(e)}) +# Hard cap for view_file / view_knowledge_file output +MAX_VIEW_FILE_CHARS = 100_000 +DEFAULT_VIEW_FILE_MAX_CHARS = 10_000 + + async def view_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, __model_knowledge__: Optional[list[dict]] = None, ) -> str: """ - Get the full content of a file by its ID. + Get the content of a file by its ID. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: return json.dumps({'error': 'Request context not available'}) @@ -1605,6 +1752,22 @@ async def view_file( if not __user__: return json.dumps({'error': 'User context not available'}) + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) + try: from open_webui.models.files import Files from open_webui.utils.access_control.files import has_access_to_file @@ -1612,7 +1775,7 @@ async def view_file( user_id = __user__.get('id') user_role = __user__.get('role', 'user') - file = Files.get_file_by_id(file_id) + file = await Files.get_file_by_id(file_id) if not file: return json.dumps({'error': 'File not found'}) @@ -1622,7 +1785,7 @@ async def view_file( and not any( item.get('type') == 'file' and item.get('id') == file_id for item in (__model_knowledge__ or []) ) - and not has_access_to_file( + and not await has_access_to_file( file_id=file_id, access_type='read', user=UserModel(**__user__), @@ -1634,16 +1797,27 @@ async def view_file( if file.data: content = file.data.get('content', '') - return json.dumps( - { - 'id': file.id, - 'filename': file.filename, - 'content': content, - 'updated_at': file.updated_at, - 'created_at': file.created_at, - }, - ensure_ascii=False, - ) + total_chars = len(content) + sliced = content[offset : offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars + + result = { + 'id': file.id, + 'filename': file.filename, + 'content': sliced, + 'updated_at': file.updated_at, + 'created_at': file.created_at, + } + + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) + + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_file error: {e}') return json.dumps({'error': str(e)}) @@ -1651,14 +1825,18 @@ async def view_file( async def view_knowledge_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, ) -> str: """ - Get the full content of a file from a knowledge base. + Get the content of a file from a knowledge base. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: return json.dumps({'error': 'Request context not available'}) @@ -1666,6 +1844,22 @@ async def view_knowledge_file( if not __user__: return json.dumps({'error': 'User context not available'}) + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) + try: from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges @@ -1673,14 +1867,14 @@ async def view_knowledge_file( user_id = __user__.get('id') user_role = __user__.get('role', 'user') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] - file = Files.get_file_by_id(file_id) + file = await Files.get_file_by_id(file_id) if not file: return json.dumps({'error': 'File not found'}) # Check access via any KB containing this file - knowledges = Knowledges.get_knowledges_by_file_id(file_id) + knowledges = await Knowledges.get_knowledges_by_file_id(file_id) has_knowledge_access = False knowledge_info = None @@ -1688,7 +1882,7 @@ async def view_knowledge_file( if ( user_role == 'admin' or knowledge_base.user_id == user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge_base.id, @@ -1708,10 +1902,14 @@ async def view_knowledge_file( if file.data: content = file.data.get('content', '') + total_chars = len(content) + sliced = content[offset : offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars + result = { 'id': file.id, 'filename': file.filename, - 'content': content, + 'content': sliced, 'updated_at': file.updated_at, 'created_at': file.created_at, } @@ -1719,12 +1917,130 @@ async def view_knowledge_file( result['knowledge_id'] = knowledge_info['id'] result['knowledge_name'] = knowledge_info['name'] + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) + return json.dumps(result, ensure_ascii=False) except Exception as e: log.exception(f'view_knowledge_file error: {e}') return json.dumps({'error': str(e)}) +async def list_knowledge( + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, +) -> str: + """ + List all knowledge bases, files, and notes attached to the current model. + Use this first to discover what knowledge is available before querying or reading files. + + :return: JSON with knowledge_bases, files, and notes attached to this model + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + if not __model_knowledge__: + return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []}) + + try: + from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.notes import Notes + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] + + knowledge_bases = [] + files = [] + notes = [] + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + + if item_type == 'collection': + knowledge = await Knowledges.get_knowledge_by_id(item_id) + if knowledge and ( + user_role == 'admin' + or knowledge.user_id == user_id + or await AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + kb_files = await Knowledges.get_files_by_id(knowledge.id) + file_count = len(kb_files) if kb_files else 0 + + kb_entry = { + 'id': knowledge.id, + 'name': knowledge.name, + 'description': knowledge.description or '', + 'file_count': file_count, + } + + # Include file listing for each KB + if kb_files: + kb_entry['files'] = [{'id': f.id, 'filename': f.filename} for f in kb_files] + + knowledge_bases.append(kb_entry) + + elif item_type == 'file': + file = await Files.get_file_by_id(item_id) + if file: + files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) + + elif item_type == 'note': + note = await Notes.get_note_by_id(item_id) + if note and ( + user_role == 'admin' + or note.user_id == user_id + or await AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='read', + ) + ): + notes.append( + { + 'id': note.id, + 'title': note.title, + } + ) + + return json.dumps( + { + 'knowledge_bases': knowledge_bases, + 'files': files, + 'notes': notes, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'list_knowledge error: {e}') + return json.dumps({'error': str(e)}) + + async def query_knowledge_files( query: str, knowledge_ids: Optional[list[str]] = None, @@ -1776,7 +2092,7 @@ async def query_knowledge_files( user_id = __user__.get('id') user_role = __user__.get('role', 'user') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] embedding_function = __request__.app.state.EMBEDDING_FUNCTION if not embedding_function: @@ -1793,11 +2109,11 @@ async def query_knowledge_files( if item_type == 'collection': # Knowledge base - use KB ID as collection name - knowledge = Knowledges.get_knowledge_by_id(item_id) + knowledge = await Knowledges.get_knowledge_by_id(item_id) if knowledge and ( user_role == 'admin' or knowledge.user_id == user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge.id, @@ -1809,17 +2125,17 @@ async def query_knowledge_files( elif item_type == 'file': # Individual file - use file-{id} as collection name - file = Files.get_file_by_id(item_id) + file = await Files.get_file_by_id(item_id) if file: collection_names.append(f'file-{item_id}') elif item_type == 'note': # Note - always return full content as context - note = Notes.get_note_by_id(item_id) + note = await Notes.get_note_by_id(item_id) if note and ( user_role == 'admin' or note.user_id == user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user_id, resource_type='note', resource_id=note.id, @@ -1839,11 +2155,11 @@ async def query_knowledge_files( elif knowledge_ids: # User specified specific KBs for knowledge_id in knowledge_ids: - knowledge = Knowledges.get_knowledge_by_id(knowledge_id) + knowledge = await Knowledges.get_knowledge_by_id(knowledge_id) if knowledge and ( user_role == 'admin' or knowledge.user_id == user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user_id, resource_type='knowledge', resource_id=knowledge.id, @@ -1854,7 +2170,7 @@ async def query_knowledge_files( collection_names.append(knowledge_id) else: # No model knowledge and no specific IDs - search all accessible KBs - result = Knowledges.search_knowledge_bases( + result = await Knowledges.search_knowledge_bases( user_id, filter={ 'query': '', @@ -1874,6 +2190,7 @@ async def query_knowledge_files( # Query vector collections if any if collection_names: query_results = await query_collection( + __request__, collection_names=collection_names, queries=[query], embedding_function=embedding_function, @@ -1929,10 +2246,10 @@ async def query_knowledge_bases( import heapq from open_webui.models.knowledge import Knowledges from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION - from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT + from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT user_id = __user__.get('id') - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] query_embedding = await __request__.app.state.EMBEDDING_FUNCTION(query) # Min-heap of (distance, knowledge_base_id) - only holds top `count` results @@ -1942,7 +2259,7 @@ async def query_knowledge_bases( page_size = 100 while True: - accessible_knowledge_bases = Knowledges.search_knowledge_bases( + accessible_knowledge_bases = await Knowledges.search_knowledge_bases( user_id, filter={'user_id': user_id, 'group_ids': user_group_ids}, skip=page_offset, @@ -1954,7 +2271,7 @@ async def query_knowledge_bases( accessible_ids = [kb.id for kb in accessible_knowledge_bases.items] - search_results = VECTOR_DB_CLIENT.search( + search_results = await ASYNC_VECTOR_DB_CLIENT.search( collection_name=KNOWLEDGE_BASES_COLLECTION, vectors=[query_embedding], filter={'knowledge_base_id': {'$in': accessible_ids}}, @@ -1986,7 +2303,7 @@ async def query_knowledge_bases( matching_knowledge_bases = [] for distance, knowledge_base_id in sorted_results: - knowledge_base = Knowledges.get_knowledge_by_id(knowledge_base_id) + knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id) if knowledge_base: matching_knowledge_bases.append( { @@ -2010,15 +2327,15 @@ async def query_knowledge_bases( async def view_skill( - name: str, + id: str, __request__: Request = None, __user__: dict = None, ) -> str: """ - Load the full instructions of a skill by its name from the available skills manifest. + Load the full instructions of a skill by its id from the available skills manifest. Use this when you need detailed instructions for a skill listed in . - :param name: The name of the skill to load (as shown in the manifest) + :param id: The id of the skill to load (as shown in the manifest) :return: The full skill instructions as markdown content """ if __request__ is None: @@ -2033,17 +2350,17 @@ async def view_skill( user_id = __user__.get('id') - # Direct DB lookup by unique name - skill = Skills.get_skill_by_name(name) + # Direct DB lookup by id (case-insensitive since IDs are stored lowercase) + skill = await Skills.get_skill_by_id(id.lower()) if not skill or not skill.is_active: - return json.dumps({'error': f"Skill '{name}' not found"}) + return json.dumps({'error': f"Skill '{id}' not found"}) # Check user access user_role = __user__.get('role', 'user') if user_role != 'admin' and skill.user_id != user_id: - user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] - if not AccessGrants.has_access( + user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( user_id=user_id, resource_type='skill', resource_id=skill.id, @@ -2062,3 +2379,942 @@ async def view_skill( except Exception as e: log.exception(f'view_skill error: {e}') return json.dumps({'error': str(e)}) + + +# ============================================================================= +# TASK MANAGEMENT TOOLS +# ============================================================================= + +from pydantic import BaseModel, Field +from typing import Literal + +VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'} + + +class TaskItem(BaseModel): + id: Optional[str] = Field(None, description='Unique identifier for the task. Auto-generated if omitted.') + content: str = Field(..., description='Task description.') + status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description='Task status.') + + +def _task_summary(all_tasks: list[dict]) -> dict: + """Build summary counts for a task list.""" + pending = sum(1 for t in all_tasks if t['status'] == 'pending') + in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress') + completed = sum(1 for t in all_tasks if t['status'] == 'completed') + cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled') + return { + 'total': len(all_tasks), + 'pending': pending, + 'in_progress': in_progress, + 'completed': completed, + 'cancelled': cancelled, + } + + +async def _emit_tasks(event_emitter, all_tasks: list[dict]): + """Persist task state to the UI.""" + if event_emitter: + await event_emitter( + { + 'type': 'chat:message:tasks', + 'data': { + 'tasks': all_tasks, + }, + } + ) + + +async def create_tasks( + tasks: list[TaskItem], + __chat_id__: str = None, + __message_id__: str = None, + __event_emitter__: callable = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Create a task checklist to track progress on multi-step work. + Call this once at the start to define all steps, then use + update_task to mark each task as you complete it. + + :param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated). + :return: JSON with the full task list and summary counts + """ + if __chat_id__ is None: + return json.dumps({'error': 'Chat context not available'}) + + try: + all_tasks = [] + for idx, task in enumerate(tasks): + if hasattr(task, 'model_dump'): + d = task.model_dump(exclude_none=True) + elif isinstance(task, dict): + d = task + else: + d = dict(task) + + content = str(d.get('content', '')).strip() + if not content: + continue + + item_id = str(d.get('id', '') or '').strip() or str(idx + 1) + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' + + all_tasks.append({'id': item_id, 'content': content, 'status': status}) + + await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) + await _emit_tasks(__event_emitter__, all_tasks) + + return json.dumps( + {'tasks': all_tasks, 'summary': _task_summary(all_tasks)}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'tasks error: {e}') + return json.dumps({'error': str(e)}) + + +async def update_task( + id: str, + status: str = 'completed', + __chat_id__: str = None, + __message_id__: str = None, + __event_emitter__: callable = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Mark a single task as completed, in_progress, pending, or cancelled. + Call this after finishing each step. You MUST call this for every + task, including the very last one. + + :param id: The task ID to update + :param status: New status: completed, in_progress, pending, or cancelled (default: completed) + :return: JSON with the updated task list and summary counts + """ + if __chat_id__ is None: + return json.dumps({'error': 'Chat context not available'}) + + try: + status = status.strip().lower() + if status not in VALID_TASK_STATUSES: + return json.dumps( + {'error': f'Invalid status: {status}. Must be one of: {", ".join(sorted(VALID_TASK_STATUSES))}'} + ) + + all_tasks = await Chats.get_chat_tasks_by_id(__chat_id__) + + found = False + for task in all_tasks: + if task['id'] == id: + task['status'] = status + found = True + break + + if not found: + return json.dumps({'error': f'Task with id "{id}" not found'}) + + await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) + await _emit_tasks(__event_emitter__, all_tasks) + + return json.dumps( + {'tasks': all_tasks, 'summary': _task_summary(all_tasks)}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'update_task_status error: {e}') + return json.dumps({'error': str(e)}) + + +# ============================================================================= +# AUTOMATION TOOLS +# ============================================================================= + + +async def create_automation( + name: str, + prompt: str, + rrule: str, + __request__: Request = None, + __user__: dict = None, + __metadata__: dict = None, +) -> str: + """ + Create a scheduled automation that runs a prompt on a recurring or one-time schedule. + Use this when the user wants to schedule a task to run automatically. + The automation will use the current chat model. + + The rrule parameter must be a valid iCalendar RRULE string. Common examples: + - Every day at 9am: "DTSTART:20250101T090000\\nRRULE:FREQ=DAILY" + - Every Monday at 8am: "DTSTART:20250106T080000\\nRRULE:FREQ=WEEKLY;BYDAY=MO" + - Every hour: "RRULE:FREQ=HOURLY;INTERVAL=1" + - Every 30 minutes: "RRULE:FREQ=MINUTELY;INTERVAL=30" + - Once at a specific time: "DTSTART:20250415T140000\\nRRULE:FREQ=DAILY;COUNT=1" + - First day of every month: "DTSTART:20250101T090000\\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1" + + The DTSTART time should reflect the desired execution time. Use COUNT=1 for one-time automations. + + :param name: A short descriptive name for the automation + :param prompt: The prompt/instructions to execute on each run + :param rrule: An iCalendar RRULE string defining the schedule + :return: JSON with the created automation details including id, next scheduled runs + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.automations import Automations, AutomationForm, AutomationData + from open_webui.models.users import Users + from open_webui.utils.automations import validate_rrule, next_run_ns, next_n_runs_ns + + user_id = __user__.get('id') + user = await Users.get_user_by_id(user_id) + if not user: + return json.dumps({'error': 'User not found'}) + + # Fall back to model dict ID since __metadata__ may predate model_id assignment + metadata = __metadata__ or {} + model_id = metadata.get('model_id') or ( + metadata.get('model', {}).get('id') if isinstance(metadata.get('model'), dict) else None + ) + if not model_id: + return json.dumps({'error': 'Could not detect current model'}) + + # Validate the RRULE + try: + validate_rrule(rrule, tz=user.timezone) + except ValueError as e: + return json.dumps({'error': f'Invalid schedule: {e}'}) + + tz = user.timezone + form = AutomationForm( + name=name, + data=AutomationData( + prompt=prompt, + model_id=model_id, + rrule=rrule, + ), + is_active=True, + ) + + automation = await Automations.insert(user_id, form, next_run_ns(rrule, tz=tz)) + + return json.dumps( + { + 'status': 'success', + 'id': automation.id, + 'name': automation.name, + 'model_id': model_id, + 'is_active': automation.is_active, + 'next_runs': next_n_runs_ns(rrule, tz=tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'create_automation error: {e}') + return json.dumps({'error': str(e)}) + + +async def update_automation( + automation_id: str, + name: Optional[str] = None, + prompt: Optional[str] = None, + rrule: Optional[str] = None, + model_id: Optional[str] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Update an existing automation. Only the provided fields are changed; omitted fields stay the same. + + :param automation_id: The ID of the automation to update + :param name: New name for the automation (optional) + :param prompt: New prompt/instructions (optional) + :param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples. + :param model_id: New model ID to use (optional) + :return: JSON with the updated automation details + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.automations import Automations, AutomationForm, AutomationData + from open_webui.models.users import Users + from open_webui.utils.automations import validate_rrule, next_run_ns, next_n_runs_ns + + user_id = __user__.get('id') + user = await Users.get_user_by_id(user_id) + + automation = await Automations.get_by_id(automation_id) + if not automation: + return json.dumps({'error': 'Automation not found'}) + if automation.user_id != user_id: + return json.dumps({'error': 'Access denied'}) + + # Merge provided fields with existing values + new_name = name if name is not None else automation.name + new_prompt = prompt if prompt is not None else automation.data.get('prompt', '') + new_model_id = model_id if model_id is not None else automation.data.get('model_id', '') + new_rrule = rrule if rrule is not None else automation.data.get('rrule', '') + + # Validate RRULE if changed + if rrule is not None: + try: + validate_rrule(new_rrule, tz=user.timezone if user else None) + except ValueError as e: + return json.dumps({'error': f'Invalid schedule: {e}'}) + + tz = user.timezone if user else None + form = AutomationForm( + name=new_name, + data=AutomationData( + prompt=new_prompt, + model_id=new_model_id, + rrule=new_rrule, + ), + is_active=automation.is_active, + ) + + updated = await Automations.update_by_id(automation_id, form, next_run_ns(new_rrule, tz=tz)) + + return json.dumps( + { + 'status': 'success', + 'id': updated.id, + 'name': updated.name, + 'model_id': new_model_id, + 'is_active': updated.is_active, + 'next_runs': next_n_runs_ns(new_rrule, tz=tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'update_automation error: {e}') + return json.dumps({'error': str(e)}) + + +async def list_automations( + status: Optional[str] = None, + count: int = 10, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + List the user's scheduled automations. + + :param status: Filter by status: "active", "paused", or omit for all + :param count: Maximum number of automations to return (default: 10) + :return: JSON list of automations with id, name, prompt snippet, schedule, status, and next runs + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.automations import Automations + from open_webui.models.users import Users + from open_webui.utils.automations import next_n_runs_ns + + user_id = __user__.get('id') + user = await Users.get_user_by_id(user_id) + + result = await Automations.search_automations( + user_id=user_id, + status=status, + skip=0, + limit=count, + ) + + automations = [] + for item in result.items: + rrule = item.data.get('rrule', '') + prompt_text = item.data.get('prompt', '') + snippet = prompt_text[:100] + ('...' if len(prompt_text) > 100 else '') + + automations.append( + { + 'id': item.id, + 'name': item.name, + 'prompt_snippet': snippet, + 'model_id': item.data.get('model_id', ''), + 'rrule': rrule, + 'is_active': item.is_active, + 'last_run_at': item.last_run_at, + 'next_runs': next_n_runs_ns(rrule, tz=user.timezone if user else None), + } + ) + + return json.dumps( + {'automations': automations, 'total': result.total}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'list_automations error: {e}') + return json.dumps({'error': str(e)}) + + +async def toggle_automation( + automation_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Pause or resume a scheduled automation. If active, it will be paused. If paused, it will be resumed. + + :param automation_id: The ID of the automation to toggle + :return: JSON with the updated automation status + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.automations import Automations + from open_webui.models.users import Users + from open_webui.utils.automations import next_run_ns + + user_id = __user__.get('id') + user = await Users.get_user_by_id(user_id) + + automation = await Automations.get_by_id(automation_id) + if not automation: + return json.dumps({'error': 'Automation not found'}) + if automation.user_id != user_id: + return json.dumps({'error': 'Access denied'}) + + rrule = automation.data.get('rrule', '') + toggled = await Automations.toggle( + automation_id, + next_run_ns(rrule, tz=user.timezone if user else None), + ) + + return json.dumps( + { + 'status': 'success', + 'id': toggled.id, + 'name': toggled.name, + 'is_active': toggled.is_active, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'toggle_automation error: {e}') + return json.dumps({'error': str(e)}) + + +async def delete_automation( + automation_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Delete a scheduled automation and all its run history. + + :param automation_id: The ID of the automation to delete + :return: JSON confirming the automation was deleted + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.automations import Automations, AutomationRuns + + user_id = __user__.get('id') + + automation = await Automations.get_by_id(automation_id) + if not automation: + return json.dumps({'error': 'Automation not found'}) + if automation.user_id != user_id: + return json.dumps({'error': 'Access denied'}) + + name = automation.name + await AutomationRuns.delete_by_automation(automation_id) + await Automations.delete(automation_id) + + return json.dumps( + { + 'status': 'success', + 'message': f'Automation "{name}" deleted', + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'delete_automation error: {e}') + return json.dumps({'error': str(e)}) + + +# ============================================================================= +# CALENDAR TOOLS +# ============================================================================= + + +def _get_user_tz(user_dict: dict): + """Get the user's timezone as a ZoneInfo, falling back to UTC.""" + from zoneinfo import ZoneInfo + + tz_name = None + if user_dict: + tz_name = user_dict.get('timezone') + if tz_name: + try: + return ZoneInfo(tz_name) + except Exception: + pass + return ZoneInfo('UTC') + + +def _dt_to_ns(dt_str: str, tz) -> int: + """Convert a datetime string to nanoseconds since epoch, interpreting in the given timezone.""" + from datetime import datetime + + dt = datetime.fromisoformat(dt_str) + # If naive (no timezone info), localize to user's timezone + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + return int(dt.timestamp() * 1_000) * 1_000_000 + + +def _ns_to_dt(ns: int, tz) -> str: + """Convert nanoseconds since epoch to a datetime string in the given timezone.""" + from datetime import datetime + + seconds = ns / 1_000_000_000 + dt = datetime.fromtimestamp(seconds, tz=tz) + return dt.strftime('%Y-%m-%d %H:%M') + + +def _event_to_dict(event, tz) -> dict: + """Convert a calendar event model to a human-friendly dict with local timestamps.""" + alert_minutes = None + if event.meta and 'alert_minutes' in event.meta: + alert_minutes = event.meta['alert_minutes'] + return { + 'id': event.id, + 'calendar_id': event.calendar_id, + 'title': event.title, + 'description': event.description or '', + 'start': _ns_to_dt(event.start_at, tz), + 'end': _ns_to_dt(event.end_at, tz) if event.end_at else None, + 'all_day': event.all_day, + 'location': event.location or '', + 'reminder_minutes': alert_minutes if alert_minutes is not None else 10, + 'color': event.color, + 'is_cancelled': event.is_cancelled, + } + + +async def search_calendar_events( + query: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + count: int = 10, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search calendar events, reminders, and scheduled items by text and/or date range. + Use this to check what's coming up, find a specific event or reminder, or list + the user's schedule for a time period. + + :param query: Search text to match against event title, description, or location (optional) + :param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional) + :param end: Only return events starting before this datetime, e.g. "2026-04-27 00:00" (optional) + :param count: Maximum number of events to return (default: 10) + :return: JSON list of matching events with id, title, description, start, end, calendar_id, location + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import CalendarEvents + + user_id = __user__.get('id') + tz = _get_user_tz(__user__) + + if isinstance(count, str): + try: + count = int(count) + except ValueError: + count = 10 + + if start or end: + # Date range query — use get_events_by_range + try: + start_ns = _dt_to_ns(start, tz) if start else 0 + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}'}) + + try: + end_ns = ( + _dt_to_ns(end, tz) + if end + else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 + ) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}'}) + + items = await CalendarEvents.get_events_by_range( + user_id=user_id, + start=start_ns, + end=end_ns, + ) + + # Apply text filter if query is also provided + if query: + q = query.lower() + items = [ + e + for e in items + if q in (e.title or '').lower() + or q in (e.description or '').lower() + or q in (e.location or '').lower() + ] + + events = [_event_to_dict(item, tz) for item in items[:count]] + return json.dumps( + {'events': events, 'total': len(items)}, + ensure_ascii=False, + ) + else: + # Text-only search + result = await CalendarEvents.search_events( + user_id=user_id, + query=query, + skip=0, + limit=count, + ) + + events = [_event_to_dict(item, tz) for item in result.items] + return json.dumps( + {'events': events, 'total': result.total}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'search_calendar_events error: {e}') + return json.dumps({'error': str(e)}) + + +async def create_calendar_event( + title: str, + start: str, + end: Optional[str] = None, + description: Optional[str] = None, + calendar_id: Optional[str] = None, + all_day: bool = False, + location: Optional[str] = None, + reminder_minutes: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Create a calendar event, reminder, or alarm. Use this when the user wants to + schedule an event, set a reminder, create an alarm, or says things like + "remind me", "don't let me forget", "notify me at", or "add to my calendar". + For simple reminders, omit end/location/all_day and set reminder_minutes to 0. + + :param title: Event or reminder title (e.g. "Team standup", "Take medicine", "Call mom") + :param start: Start datetime in the user's local time (e.g. "2026-04-20 09:00") + :param end: End datetime in the user's local time (optional — omit for reminders or point-in-time events) + :param description: Event description or notes (optional) + :param calendar_id: Target calendar ID (optional, uses default calendar if omitted) + :param all_day: Whether this is an all-day event (default: false) + :param location: Event location (optional) + :param reminder_minutes: Minutes before the event to send a notification (optional, default: 10). Use 0 for "at time of event", -1 for no notification. + :return: JSON with the created event details including id + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventForm + + user_id = __user__.get('id') + + # Resolve calendar_id: use provided, or fall back to default + if not calendar_id: + calendars = await Calendars.get_calendars_by_user(user_id) + default_cal = next((c for c in calendars if c.is_default), None) + if not default_cal and calendars: + default_cal = calendars[0] + if not default_cal: + return json.dumps({'error': 'No calendars found. Cannot create event.'}) + calendar_id = default_cal.id + + # Verify access + cal = await Calendars.get_calendar_by_id(calendar_id) + if not cal: + return json.dumps({'error': 'Calendar not found'}) + if cal.user_id != user_id and __user__.get('role') != 'admin': + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied to this calendar'}) + + # Coerce boolean from LLM + if isinstance(all_day, str): + all_day = all_day.lower() in ('true', '1', 'yes') + + # Convert datetime strings to nanoseconds using user's timezone + tz = _get_user_tz(__user__) + try: + start_ns = _dt_to_ns(start, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'}) + + end_ns = None + if end: + try: + end_ns = _dt_to_ns(end, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'}) + elif not all_day: + # Default to 1 hour duration + end_ns = start_ns + 3_600_000_000_000 + + # Build meta with reminder setting + meta = {} + if reminder_minutes is not None: + if isinstance(reminder_minutes, str): + try: + reminder_minutes = int(reminder_minutes) + except ValueError: + reminder_minutes = 10 + meta['alert_minutes'] = reminder_minutes + else: + meta['alert_minutes'] = 10 + + form = CalendarEventForm( + calendar_id=calendar_id, + title=title, + description=description, + start_at=start_ns, + end_at=end_ns, + all_day=all_day, + location=location, + meta=meta, + ) + + event = await CalendarEvents.insert_new_event(user_id, form) + if not event: + return json.dumps({'error': 'Failed to create event'}) + + return json.dumps( + { + 'status': 'success', + **_event_to_dict(event, tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'create_calendar_event error: {e}') + return json.dumps({'error': str(e)}) + + +async def update_calendar_event( + event_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + start: Optional[str] = None, + end: Optional[str] = None, + all_day: Optional[bool] = None, + location: Optional[str] = None, + is_cancelled: Optional[bool] = None, + reminder_minutes: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Update an existing calendar event. Only provided fields are changed; + omitted fields stay the same. + + :param event_id: The ID of the event to update + :param title: New event title (optional) + :param description: New event description (optional) + :param start: New start datetime string in your local time, e.g. "2026-04-20 09:00" (optional) + :param end: New end datetime string in your local time (optional) + :param all_day: Whether this is an all-day event (optional) + :param location: New event location (optional) + :param is_cancelled: Set to true to cancel the event (optional) + :param reminder_minutes: Minutes before the event to send a reminder notification (optional). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before). + :return: JSON with the updated event details + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventUpdateForm + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_id = __user__.get('id') + + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + return json.dumps({'error': 'Event not found'}) + + # Check write access to the event's calendar + if event.user_id != user_id and __user__.get('role') != 'admin': + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if not cal: + return json.dumps({'error': 'Access denied'}) + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied'}) + + # Coerce boolean strings from LLM + if isinstance(all_day, str): + all_day = all_day.lower() in ('true', '1', 'yes') + if isinstance(is_cancelled, str): + is_cancelled = is_cancelled.lower() in ('true', '1', 'yes') + + # Convert datetime strings to nanoseconds using user's timezone + tz = _get_user_tz(__user__) + start_ns = None + if start is not None: + try: + start_ns = _dt_to_ns(start, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid start datetime: {e}'}) + + end_ns = None + if end is not None: + try: + end_ns = _dt_to_ns(end, tz) + except (ValueError, TypeError) as e: + return json.dumps({'error': f'Invalid end datetime: {e}'}) + + # Build meta update with reminder setting if provided + meta = None + if reminder_minutes is not None: + if isinstance(reminder_minutes, str): + try: + reminder_minutes = int(reminder_minutes) + except ValueError: + reminder_minutes = None + if reminder_minutes is not None: + meta = {'alert_minutes': reminder_minutes} + + form = CalendarEventUpdateForm( + title=title, + description=description, + start_at=start_ns, + end_at=end_ns, + all_day=all_day, + location=location, + is_cancelled=is_cancelled, + meta=meta, + ) + + updated = await CalendarEvents.update_event_by_id(event_id, form) + if not updated: + return json.dumps({'error': 'Failed to update event'}) + + return json.dumps( + { + 'status': 'success', + **_event_to_dict(updated, tz), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'update_calendar_event error: {e}') + return json.dumps({'error': str(e)}) + + +async def delete_calendar_event( + event_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Delete a calendar event permanently. + + :param event_id: The ID of the event to delete + :return: JSON confirming the event was deleted + """ + if __request__ is None: + return json.dumps({'error': 'Request context not available'}) + + if not __user__: + return json.dumps({'error': 'User context not available'}) + + try: + from open_webui.models.calendar import Calendars, CalendarEvents + from open_webui.models.access_grants import AccessGrants + from open_webui.models.groups import Groups + + user_id = __user__.get('id') + + event = await CalendarEvents.get_event_by_id(event_id) + if not event: + return json.dumps({'error': 'Event not found'}) + + # Check write access + if event.user_id != user_id and __user__.get('role') != 'admin': + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if not cal: + return json.dumps({'error': 'Access denied'}) + user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] + if not await AccessGrants.has_access( + user_id=user_id, + resource_type='calendar', + resource_id=cal.id, + permission='write', + user_group_ids=set(user_group_ids), + ): + return json.dumps({'error': 'Access denied'}) + + title = event.title + result = await CalendarEvents.delete_event_by_id(event_id) + if not result: + return json.dumps({'error': 'Failed to delete event'}) + + return json.dumps( + { + 'status': 'success', + 'message': f'Event "{title}" deleted', + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'delete_calendar_event error: {e}') + return json.dumps({'error': str(e)}) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 3ee394acc9d..06e8d0aba04 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -5,12 +5,13 @@ from open_webui.models.groups import Groups from open_webui.models.access_grants import ( has_public_read_access_grant, + has_public_write_access_grant, has_user_access_grant, strip_user_access_grants, ) from open_webui.config import DEFAULT_USER_PERMISSIONS -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession def fill_missing_permissions(permissions: dict[str, Any], default_permissions: dict[str, Any]) -> dict[str, Any]: @@ -27,10 +28,10 @@ def fill_missing_permissions(permissions: dict[str, Any], default_permissions: d return permissions -def get_permissions( +async def get_permissions( user_id: str, default_permissions: dict[str, Any], - db: Session | None = None, + db: AsyncSession | None = None, ) -> dict[str, Any]: """ Get all permissions for a user by combining the permissions of all groups the user is a member of. @@ -52,7 +53,7 @@ def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str permissions[key] = permissions[key] or value # Use the most permissive value (True > False) return permissions - user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) # Deep copy default permissions to avoid modifying the original dict permissions = json.loads(json.dumps(default_permissions)) @@ -67,11 +68,11 @@ def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str return permissions -def has_permission( +async def has_permission( user_id: str, permission_key: str, default_permissions: dict[str, Any] = {}, - db: Session | None = None, + db: AsyncSession | None = None, ) -> bool: """ Check if a user has a specific permission by checking the group permissions @@ -92,7 +93,7 @@ def get_permission(permissions: dict[str, Any], keys: list[str]) -> bool: permission_hierarchy = permission_key.split('.') # Retrieve user group permissions - user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) for group in user_groups: if get_permission(group.permissions or {}, permission_hierarchy): @@ -103,12 +104,12 @@ def get_permission(permissions: dict[str, Any], keys: list[str]) -> bool: return get_permission(default_permissions, permission_hierarchy) -def has_access( +async def has_access( user_id: str, permission: str = 'read', access_grants: list | None = None, user_group_ids: set[str] | None = None, - db: Session | None = None, + db: AsyncSession | None = None, ) -> bool: """ Check if a user has the specified permission using an in-memory access_grants list. @@ -125,7 +126,7 @@ def has_access( return False if user_group_ids is None: - user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_groups = await Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} for grant in access_grants: @@ -143,7 +144,7 @@ def has_access( return False -def has_connection_access( +async def has_connection_access( user: UserModel, connection: dict, user_group_ids: set[str] | None = None, @@ -162,10 +163,10 @@ def has_connection_access( return True if user_group_ids is None: - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} access_grants = (connection.get('config') or {}).get('access_grants', []) - return has_access(user.id, 'read', access_grants, user_group_ids) + return await has_access(user.id, 'read', access_grants, user_group_ids) def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_key: str = 'access_grants') -> None: @@ -209,13 +210,13 @@ def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_ke data.pop(ac_key, None) -def filter_allowed_access_grants( +async def filter_allowed_access_grants( default_permissions: dict[str, Any], user_id: str, user_role: str, access_grants: list, public_permission_key: str, - db: Session | None = None, + db: AsyncSession | None = None, ) -> list: """ Checks if the user has the required permissions to grant access to a resource. @@ -225,7 +226,9 @@ def filter_allowed_access_grants( return access_grants # Check if user can share publicly - if has_public_read_access_grant(access_grants) and not has_permission( + if ( + has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants) + ) and not await has_permission( user_id, public_permission_key, default_permissions, @@ -243,7 +246,7 @@ def filter_allowed_access_grants( ] # Strip individual user sharing if user lacks permission - if has_user_access_grant(access_grants) and not has_permission( + if has_user_access_grant(access_grants) and not await has_permission( user_id, 'access_grants.allow_users', default_permissions, @@ -252,3 +255,92 @@ def filter_allowed_access_grants( access_grants = strip_user_access_grants(access_grants) return access_grants + + +async def has_base_model_access( + user_id: str, + model_info, + *, + user_group_ids: set[str] | None = None, + db=None, +) -> bool: + """ + Walk the ``base_model_id`` chain and verify the caller has read access + at every hop. + + Returns ``True`` when access is granted (or the chain ends at a raw + provider model that has no per-model ACL). Returns ``False`` the + moment a registered base model denies access. + """ + from open_webui.models.models import Models + from open_webui.models.access_grants import AccessGrants + + base_model_id = getattr(model_info, 'base_model_id', None) + seen = {model_info.id} + while base_model_id and base_model_id not in seen: + seen.add(base_model_id) + base_model_info = await Models.get_model_by_id(base_model_id, db=db) + if base_model_info is None: + break # Raw provider model — no per-model ACL + if not ( + user_id == base_model_info.user_id + or await AccessGrants.has_access( + user_id=user_id, + resource_type='model', + resource_id=base_model_info.id, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + ): + return False + base_model_id = getattr(base_model_info, 'base_model_id', None) + return True + + +async def check_model_access( + user: UserModel, + model_info, + bypass_filter: bool = False, +) -> None: + """ + Enforce per-model read access for the given user. + + Raises HTTPException(403) if the user is not authorized. + Does nothing if bypass_filter is True. + + Args: + user: The authenticated user. + model_info: The model record from await Models.get_model_by_id(), + or None if the model is not registered. + bypass_filter: If True, skip all access checks (used by + internal callers and BYPASS_MODEL_ACCESS_CONTROL). + """ + from fastapi import HTTPException + + if bypass_filter: + return + + if model_info: + if user.role == 'user': + from open_webui.models.access_grants import AccessGrants + + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} + if not ( + user.id == model_info.user_id + or await AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=model_info.id, + permission='read', + user_group_ids=user_group_ids, + ) + ): + raise HTTPException(status_code=403, detail='Model not found') + + # Enforce access on chained base models + if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids): + raise HTTPException(status_code=403, detail='Model not found') + else: + if user.role != 'admin': + raise HTTPException(status_code=403, detail='Model not found') diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index a7e35fd506c..fb318e3c66f 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -9,16 +9,16 @@ from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) -def has_access_to_file( +async def has_access_to_file( file_id: str | None, access_type: str, user: UserModel, - db: Session | None = None, + db: AsyncSession | None = None, ) -> bool: """ Check if a user has the specified access to a file through any of: @@ -30,7 +30,7 @@ def has_access_to_file( NOTE: This does NOT check direct file ownership — callers should check file.user_id == user.id separately before calling this. """ - file = Files.get_file_by_id(file_id, db=db) + file = await Files.get_file_by_id(file_id, db=db) log.debug(f'Checking if user has {access_type} access to file') if not file: return False @@ -40,10 +40,10 @@ def has_access_to_file( return True # Check if the file is associated with any knowledge bases the user has access to - knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db) - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db) + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} for knowledge_base in knowledge_bases: - if knowledge_base.user_id == user.id or AccessGrants.has_access( + if knowledge_base.user_id == user.id or await AccessGrants.has_access( user_id=user.id, resource_type='knowledge', resource_id=knowledge_base.id, @@ -55,27 +55,70 @@ def has_access_to_file( knowledge_base_id = file.meta.get('collection_name') if file.meta else None if knowledge_base_id: - knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db) + knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db) for knowledge_base in knowledge_bases: if knowledge_base.id == knowledge_base_id: return True # Check if the file is associated with any channels the user has access to - channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) + channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) if access_type == 'read' and channels: return True # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats - chats = Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True + shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db) + if shared_chat_ids: + accessible_ids = await AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type='shared_chat', + resource_ids=shared_chat_ids, + permission='read', + user_group_ids=user_group_ids, + db=db, + ) + if accessible_ids: + return True # Check if the file is directly attached to a shared workspace model - for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db): + for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db): knowledge_items = getattr(model.meta, 'knowledge', None) or [] for item in knowledge_items: if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True return False + + +async def get_accessible_folder_files( + entries: list[dict] | None, + user: UserModel, + db: AsyncSession | None = None, +) -> list[dict]: + """Filter folder.data['files'] entries to those the caller can read. + + Each entry is expected to have 'type' ('file' or 'collection') and 'id'. + Admins bypass all checks. Unknown types are kept as-is. + """ + if not entries: + return [] + if user.role == 'admin': + return list(entries) + + accessible: list[dict] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + entry_type = entry.get('type') + entry_id = entry.get('id') + if not entry_id: + accessible.append(entry) + continue + if entry_type == 'file': + if await has_access_to_file(entry_id, 'read', user, db=db): + accessible.append(entry) + elif entry_type == 'collection': + if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db): + accessible.append(entry) + else: + accessible.append(entry) + return accessible diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py index 5c5712fa0fe..7b1789580b0 100644 --- a/backend/open_webui/utils/actions.py +++ b/backend/open_webui/utils/actions.py @@ -26,7 +26,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A else: sub_action_id = None - action = Functions.get_function_by_id(action_id) + action = await Functions.get_function_by_id(action_id) if not action: raise Exception(f'Action not found: {action_id}') @@ -47,7 +47,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A raise Exception('Model not found') model = models[model_id] - __event_emitter__ = get_event_emitter( + __event_emitter__ = await get_event_emitter( { 'chat_id': data['chat_id'], 'message_id': data['id'], @@ -55,7 +55,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A 'user_id': user.id, } ) - __event_call__ = get_event_call( + __event_call__ = await get_event_call( { 'chat_id': data['chat_id'], 'message_id': data['id'], @@ -64,10 +64,10 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A } ) - function_module, _, _ = get_function_module_from_cache(request, action_id) + function_module, _, _ = await get_function_module_from_cache(request, action_id) if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): - valves = Functions.get_function_valves_by_id(action_id) + valves = await Functions.get_function_valves_by_id(action_id) function_module.valves = function_module.Valves(**(valves if valves else {})) if hasattr(function_module, 'action'): @@ -98,7 +98,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A try: if hasattr(function_module, 'UserValves'): __user__['valves'] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id(action_id, user.id) + **await Functions.get_user_valves_by_id_and_user_id(action_id, user.id) ) except Exception as e: log.exception(f'Failed to get user values: {e}') @@ -111,7 +111,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A data = action(**params) # Process action result for Rich UI embeds (HTMLResponse, tuple with headers) - processed_result, _, action_embeds = process_tool_result( + processed_result, _, action_embeds = await process_tool_result( request, action_id, data, diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 5ba4099fb40..a01184143f9 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -181,17 +181,99 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: ) elif block_type == 'tool_result': # Tool results become separate tool messages in OpenAI format - tool_content = block.get('content', '') - if isinstance(tool_content, list): - tool_text_parts = [] - for tc in tool_content: - if isinstance(tc, dict) and tc.get('type') == 'text': - tool_text_parts.append(tc.get('text', '')) - tool_content = '\n'.join(tool_text_parts) + tool_result_content = block.get('content', '') + tool_content: str | list = '' + + if isinstance(tool_result_content, str): + tool_content = tool_result_content + elif isinstance(tool_result_content, list): + # Build a multimodal content array to preserve + # images and other non-text content types. + converted_parts = [] + for content_block in tool_result_content: + if not isinstance(content_block, dict): + continue + content_type = content_block.get('type', 'text') + + if content_type == 'text': + converted_parts.append( + { + 'type': 'text', + 'text': content_block.get('text', ''), + } + ) + elif content_type == 'image': + source = content_block.get('source', {}) + if source.get('type') == 'base64': + media_type = source.get('media_type', 'image/png') + data = source.get('data', '') + converted_parts.append( + { + 'type': 'image_url', + 'image_url': { + 'url': f'data:{media_type};base64,{data}', + }, + } + ) + elif source.get('type') == 'url': + converted_parts.append( + { + 'type': 'image_url', + 'image_url': { + 'url': source.get('url', ''), + }, + } + ) + elif content_type == 'document': + # Documents have no direct OpenAI equivalent; + # convert to a text representation. + document_source = content_block.get('source', {}) + document_title = content_block.get('title', 'Document') + document_context = content_block.get('context', '') + document_text = f'[Document: {document_title}]' + if document_context: + document_text += f'\n{document_context}' + if document_source.get('type') == 'text' and document_source.get('data'): + document_text += f'\n{document_source["data"]}' + converted_parts.append({'type': 'text', 'text': document_text}) + elif content_type == 'search_result': + # Convert search results to a text + # representation with source attribution. + search_title = content_block.get('title', '') + search_url = content_block.get('source', '') + search_content_blocks = content_block.get('content', []) + search_texts = [] + for search_block in search_content_blocks: + if isinstance(search_block, dict) and search_block.get('type') == 'text': + search_texts.append(search_block.get('text', '')) + search_body = '\n'.join(search_texts) + search_text = f'[Search Result: {search_title}]' + if search_url: + search_text += f'\nSource: {search_url}' + if search_body: + search_text += f'\n{search_body}' + converted_parts.append({'type': 'text', 'text': search_text}) + + # Flatten to string when only text parts are present + if all(part.get('type') == 'text' for part in converted_parts): + tool_content = '\n'.join(part.get('text', '') for part in converted_parts) + elif converted_parts: + tool_content = converted_parts + else: + tool_content = '' # Propagate error status if present if block.get('is_error'): - tool_content = f'Error: {tool_content}' + if isinstance(tool_content, str): + tool_content = f'Error: {tool_content}' + elif isinstance(tool_content, list): + tool_content.insert( + 0, + { + 'type': 'text', + 'text': 'Error: ', + }, + ) messages.append( { diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py new file mode 100644 index 00000000000..3b478d8b4ab --- /dev/null +++ b/backend/open_webui/utils/asgi_middleware.py @@ -0,0 +1,283 @@ +""" +Pure-ASGI replacements for the project's previous +`@app.middleware('http')` / `BaseHTTPMiddleware` middlewares. + +Why this matters +---------------- +Starlette's `BaseHTTPMiddleware` (which `@app.middleware('http')` is +sugar for) runs the downstream app inside an `anyio` task group. When +the wrapper exits — for any reason: response complete, client +disconnect, an outer middleware bailing out — the task group cancels +the inner task. That `CancelledError` then propagates into whatever +the inner task was doing, including in-flight DB queries, embedding +calls and disk I/O. + +In Open WebUI this surfaces as: + +* SQLAlchemy logging multi-page `NotImplementedError: + terminate_force_close()` tracebacks at ERROR every time a request is + cancelled mid-DB-call (the aiosqlite connector cleanup path). +* Spurious cancellations cascading through the four stacked + `@app.middleware('http')` wrappers. + +Pure ASGI middleware does not introduce a cancel scope around the +downstream app, so client disconnects propagate the way ASGI was +designed to (via `receive()` returning `http.disconnect`) instead of +being injected as `CancelledError` into arbitrary `await` points. + +Reference: https://www.starlette.io/middleware/#limitations +""" + +from __future__ import annotations + +import logging +import re +import time +from urllib.parse import parse_qs, urlencode + +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.security import HTTPAuthorizationCredentials +from starlette.datastructures import MutableHeaders +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from open_webui.env import CUSTOM_API_KEY_HEADER +from open_webui.internal.db import ScopedSession +from open_webui.utils.auth import get_http_authorization_cred + +log = logging.getLogger(__name__) + + +class CommitSessionMiddleware: + """Commit and release the thread-local sync `ScopedSession` after each + HTTP request. + + Most requests now use the async session; the sync ScopedSession is + only touched by startup, healthchecks, and a handful of legacy + helpers (notably the pgvector / opengauss vector-DB clients). The + middleware exists so that PostgreSQL connections do not accumulate + as "idle in transaction" and so that any pending sync work made + inside the request is durably persisted. + + Failure semantics + ----------------- + * Downstream raised → roll back any pending sync work, release the + connection, and re-raise so the outer exception middleware can + turn it into an error response. We never commit work on a + request that did not complete successfully. + * Downstream returned → commit pending sync work; on commit + failure, log loudly, roll back, and re-raise. Note that in pure + ASGI the response messages have already been emitted by the + time `await self.app(...)` returns, so a commit failure cannot + retroactively change what the client sees on the wire — but + re-raising still surfaces the error in logs and to ASGI servers + that expose it. We deliberately do not buffer the response to + gate it on commit success, because that would defeat streaming + responses (chat completions, SSE) which are core to the app. + + For request paths where commit-before-send is required, manage the + sync session explicitly inside the handler instead of relying on + this middleware. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + path = scope.get('path', '') + # Keep health probes independent from sync session commit/remove + # so DB pressure cannot delay or fail probe responses. + if path in {'/health', '/ready', '/health/db'}: + await self.app(scope, receive, send) + return + + try: + await self.app(scope, receive, send) + except BaseException: + # Downstream did not complete successfully. Roll back any + # pending sync writes, release the connection, and let the + # exception propagate. + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after downstream error') + finally: + ScopedSession.remove() + raise + + # Downstream completed. Commit pending sync work. + try: + ScopedSession.commit() + except Exception: + log.exception('CommitSessionMiddleware: post-request commit failed; response was already sent to client') + try: + ScopedSession.rollback() + except Exception: + log.exception('CommitSessionMiddleware: rollback failed after commit failure') + raise + finally: + # CRITICAL: remove() returns the connection to the pool. + # Without this, connections remain "checked out" and + # accumulate as "idle in transaction" in PostgreSQL. + ScopedSession.remove() + + +class AuthTokenMiddleware: + """Extract the bearer/cookie/API-key credential and stash it on + `request.state.token`. + + The header used for API-key transport is controlled by the + ``CUSTOM_API_KEY_HEADER`` environment variable (default ``x-api-key``). + This is useful when Open WebUI sits behind a reverse proxy that + consumes the ``Authorization`` header for its own authentication — + set the env var to a unique header (e.g. ``X-OpenWebUI-Key``) so + the middleware checks that instead and avoids the 401 short-circuit. + + Routes that depend on `get_verified_user` etc. read this state. + Also exposes `request.state.enable_api_keys` (snapshotted at request + entry from runtime config) and stamps an `X-Process-Time` response + header. + """ + + def __init__(self, app: ASGIApp, *, fastapi_app) -> None: + self.app = app + self._fastapi_app = fastapi_app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + start_time = time.monotonic() + request = Request(scope) + + token = get_http_authorization_cred(request.headers.get('Authorization')) + if token is None: + cookie_token = request.cookies.get('token') + if cookie_token: + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=cookie_token) + if token is None: + api_key = request.headers.get(CUSTOM_API_KEY_HEADER) + if api_key: + token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key) + + request.state.token = token + request.state.enable_api_keys = self._fastapi_app.state.config.ENABLE_API_KEYS + + async def send_with_timing(message: Message) -> None: + if message['type'] == 'http.response.start': + process_time = int(time.monotonic() - start_time) + headers = MutableHeaders(scope=message) + headers['X-Process-Time'] = str(process_time) + await send(message) + + await self.app(scope, receive, send_with_timing) + + +class WebsocketUpgradeGuardMiddleware: + """Reject HTTP requests to `/ws/socket.io` that claim + `transport=websocket` but lack the proper `Upgrade`/`Connection` + headers. + + Works around https://github.com/miguelgrinberg/python-engineio/issues/367 + where engineio mishandles such requests. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http': + await self.app(scope, receive, send) + return + + path = scope.get('path', '') + if '/ws/socket.io' in path: + query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + query_params = parse_qs(query_string) + if query_params.get('transport', [''])[0] == 'websocket': + headers = _scope_headers(scope) + upgrade = headers.get('upgrade', '').lower() + connection_tokens = [token.strip() for token in headers.get('connection', '').lower().split(',')] + if upgrade != 'websocket' or 'upgrade' not in connection_tokens: + response = JSONResponse( + status_code=400, + content={'detail': 'Invalid WebSocket upgrade request'}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +class RedirectMiddleware: + """Rewrites a couple of legacy entry-points to the SPA's own routes: + + * ``GET /watch?v=ID`` (YouTube) → ``/?youtube=ID`` + * ``GET /?shared=…`` (PWA share-target) → ``/?youtube=…`` / + ``/?load-url=…`` / ``/?q=…`` + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope['type'] != 'http' or scope.get('method', '').upper() != 'GET': + await self.app(scope, receive, send) + return + + path = scope.get('path', '') + query_string = scope.get('query_string', b'').decode('latin-1', errors='replace') + query_params = parse_qs(query_string) + + redirect_params: dict[str, str] = {} + if path.endswith('/watch') and 'v' in query_params and query_params['v']: + redirect_params['youtube'] = query_params['v'][0] + + if 'shared' in query_params and query_params['shared']: + text = query_params['shared'][0] + if text: + url_match = re.match(r'https://\S+', text) + if url_match: + # Local import: youtube loader pulls heavy deps and is + # only needed when a share-target actually contains a + # YouTube URL. + from open_webui.retrieval.loaders.youtube import _parse_video_id + + youtube_video_id = _parse_video_id(url_match[0]) + if youtube_video_id: + redirect_params['youtube'] = youtube_video_id + else: + redirect_params['load-url'] = url_match[0] + else: + redirect_params['q'] = text + + if redirect_params: + redirect_url = f'/?{urlencode(redirect_params)}' + response = RedirectResponse(url=redirect_url) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +def _scope_headers(scope: Scope) -> dict[str, str]: + """Return ASGI scope headers as a lower-cased str→str dict. + + ASGI delivers headers as a list of (bytes, bytes) pairs. For + convenience, fold duplicate keys with comma-joining (matching + HTTP/1.1 semantics). + """ + decoded: dict[str, str] = {} + for raw_key, raw_value in scope.get('headers', []): + key = raw_key.decode('latin-1').lower() + value = raw_value.decode('latin-1') + if key in decoded: + decoded[key] = f'{decoded[key]}, {value}' + else: + decoded[key] = value + return decoded diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 1200d813afe..5686c88d5d9 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -24,7 +24,7 @@ from loguru import logger from starlette.requests import Request -from open_webui.env import AUDIT_LOG_LEVEL, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE +from open_webui.env import AUDIT_LOG_LEVEL, ENABLE_AUDIT_GET_REQUESTS, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE from open_webui.utils.auth import get_current_user, get_http_authorization_cred from open_webui.models.users import UserModel @@ -117,7 +117,7 @@ class AuditLoggingMiddleware: ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle. """ - AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'} + DEFAULT_AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'} def __init__( self, @@ -127,12 +127,16 @@ def __init__( included_paths: Optional[list[str]] = None, max_body_size: int = MAX_BODY_LOG_SIZE, audit_level: AuditLevel = AuditLevel.NONE, + audit_get_requests: bool = False, ) -> None: self.app = app self.audit_logger = AuditLogger(logger) self.excluded_paths = excluded_paths or [] self.included_paths = included_paths or [] self.max_body_size = max_body_size + self.audited_methods = set(self.DEFAULT_AUDITED_METHODS) + if audit_get_requests: + self.audited_methods.add('GET') self.audit_level = audit_level if self.included_paths and self.excluded_paths: @@ -202,7 +206,10 @@ async def _get_authenticated_user(self, request: Request) -> Optional[UserModel] return None def _should_skip_auditing(self, request: Request) -> bool: - if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE': + if AUDIT_LOG_LEVEL == 'NONE': + return True + + if request.method not in self.audited_methods: return True ALWAYS_LOG_ENDPOINTS = { diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 1a78f3143f0..e0f331a9df6 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -19,8 +19,6 @@ from pytz import UTC from typing import Optional, Union, List, Dict -from opentelemetry import trace - from open_webui.utils.access_control import has_permission from open_webui.models.users import Users @@ -30,6 +28,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( + ENABLE_OTEL, ENABLE_PASSWORD_VALIDATION, OFFLINE_MODE, LICENSE_BLOB, @@ -143,8 +142,15 @@ def nt(b): pn, pt = nt(pb) data = json.loads(aesgcm.decrypt(pn, pt, None).decode()) - if not data.get('exp') and data.get('exp') < datetime.now().date(): - return False + + exp = data.get('exp') + if exp: + if isinstance(exp, str): + from datetime import date + + exp = date.fromisoformat(exp) + if exp < datetime.now().date(): + return False data_handler(data) return True @@ -188,6 +194,9 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: ) +# Let the one who signed this token be remembered at every gate, +# and may the claims therein honor the creator long after +# the session has closed. def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: payload = data.copy() @@ -196,7 +205,7 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st payload.update({'exp': expire}) jti = str(uuid.uuid4()) - payload.update({'jti': jti}) + payload.update({'jti': jti, 'iat': datetime.now(UTC)}) encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM) return encoded_jwt @@ -211,15 +220,34 @@ def decode_token(token: str) -> Optional[dict]: async def is_valid_token(request, decoded) -> bool: - # Require Redis to check revoked tokens + """ + Check whether a JWT has been revoked. Two mechanisms: + 1. Per-token (jti) — used by user-initiated sign-out (known jti). + 2. Per-user (revoked_at) — used by OIDC back-channel logout when + individual jti values are unknown; rejects tokens with iat <= revoked_at. + """ if request.app.state.redis: + # Per-token revocation jti = decoded.get('jti') - if jti: revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked') if revoked: return False + # Per-user revocation (OIDC back-channel logout) + user_id = decoded.get('id') + if user_id: + revoked_at = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') + if revoked_at: + try: + revoked_at_ts = int(revoked_at) + token_iat = decoded.get('iat') + # No iat means legacy token — reject since we can't verify issue time + if token_iat is None or token_iat <= revoked_at_ts: + return False + except (ValueError, TypeError): + pass + return True @@ -293,15 +321,18 @@ async def get_current_user( # auth by api key if token.startswith('sk-'): - user = get_current_user_by_api_key(request, token) + user = await get_current_user_by_api_key(request, token) # Add user info to current span - current_span = trace.get_current_span() - if current_span: - current_span.set_attribute('client.user.id', user.id) - current_span.set_attribute('client.user.email', user.email) - current_span.set_attribute('client.user.role', user.role) - current_span.set_attribute('client.auth.type', 'api_key') + if ENABLE_OTEL: + from opentelemetry import trace + + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'api_key') return user @@ -322,7 +353,7 @@ async def get_current_user( detail='Invalid token', ) - user = Users.get_user_by_id(data['id']) + user = await Users.get_user_by_id(data['id']) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -338,17 +369,21 @@ async def get_current_user( ) # Add user info to current span - current_span = trace.get_current_span() - if current_span: - current_span.set_attribute('client.user.id', user.id) - current_span.set_attribute('client.user.email', user.email) - current_span.set_attribute('client.user.role', user.role) - current_span.set_attribute('client.auth.type', 'jwt') - - # Refresh the user's last active timestamp asynchronously - # to prevent blocking the request - if background_tasks: - background_tasks.add_task(Users.update_last_active_by_id, user.id) + if ENABLE_OTEL: + from opentelemetry import trace + + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'jwt') + + # Refresh the user's last active timestamp + # Fire-and-forget via asyncio.create_task to avoid blocking + import asyncio + + asyncio.create_task(Users.update_last_active_by_id(user.id)) return user else: raise HTTPException( @@ -370,9 +405,9 @@ async def get_current_user( raise e -def get_current_user_by_api_key(request, api_key: str): +async def get_current_user_by_api_key(request, api_key: str): # Each function call manages its own short-lived session internally - user = Users.get_user_by_api_key(api_key) + user = await Users.get_user_by_api_key(api_key) if user is None: raise HTTPException( @@ -382,7 +417,7 @@ def get_current_user_by_api_key(request, api_key: str): if not request.state.enable_api_keys or ( user.role != 'admin' - and not has_permission( + and not await has_permission( user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS, @@ -390,15 +425,33 @@ def get_current_user_by_api_key(request, api_key: str): ): raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED) + # Enforce endpoint restrictions — checked here (not in middleware) + # so it applies regardless of how the API key was transported + # (Authorization header, cookie, x-api-key header, etc.). + if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: + allowed_paths = [ + path.strip() for path in str(request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip() + ] + request_path = request.url.path + is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths) + if not is_allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Add user info to current span - current_span = trace.get_current_span() - if current_span: - current_span.set_attribute('client.user.id', user.id) - current_span.set_attribute('client.user.email', user.email) - current_span.set_attribute('client.user.role', user.role) - current_span.set_attribute('client.auth.type', 'api_key') - - Users.update_last_active_by_id(user.id) + if ENABLE_OTEL: + from opentelemetry import trace + + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'api_key') + + await Users.update_last_active_by_id(user.id) return user @@ -420,7 +473,7 @@ def get_admin_user(user=Depends(get_current_user)): return user -def create_admin_user(email: str, password: str, name: str = 'Admin'): +async def create_admin_user(email: str, password: str, name: str = 'Admin'): """ Create an admin user from environment variables. Used for headless/automated deployments. @@ -430,14 +483,14 @@ def create_admin_user(email: str, password: str, name: str = 'Admin'): if not email or not password: return None - if Users.has_users(): + if await Users.has_users(): log.debug('Users already exist, skipping admin creation') return None log.info(f'Creating admin account from environment variables: {email}') try: hashed = get_password_hash(password) - user = Auths.insert_new_auth( + user = await Auths.insert_new_auth( email=email.lower(), password=hashed, name=name, diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py new file mode 100644 index 00000000000..05955d54d9a --- /dev/null +++ b/backend/open_webui/utils/automations.py @@ -0,0 +1,593 @@ +""" +Automation utilities and unified scheduler. + +RRULE helpers, scheduler worker loop, and execution logic. +Follows the utils/.py pattern (cf. utils/channels.py, utils/task.py). + +The scheduler_worker_loop handles all time-based background work: + - Automation execution (claim_due → execute) + - Calendar event alerts (upcoming events → socket + webhook notifications) + +Environment: + SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10) + CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5) +""" + +import asyncio +import logging +import os +import random +import time +from datetime import datetime +from typing import Optional +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from dateutil.rrule import rrulestr +from fastapi import Request +from starlette.datastructures import Headers + +from open_webui.constants import ERROR_MESSAGES +from open_webui.models.automations import Automations, AutomationRuns, AutomationModel +from open_webui.models.chats import ChatForm, Chats +from open_webui.models.users import Users +from open_webui.utils.task import prompt_template +from open_webui.internal.db import get_async_db + +log = logging.getLogger(__name__) + +SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10'))) +CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10')) + + +#################### +# RRULE Helpers +#################### + + +def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]: + """Safely resolve a timezone string to ZoneInfo. + + Returns None (→ server-local fallback) when *tz* is empty, None, + or an unrecognised IANA key. Logs a warning on bad keys so + misconfiguration is visible in the server logs. + """ + if not tz: + return None + try: + return ZoneInfo(tz) + except (KeyError, Exception): + log.warning('Unknown timezone %r — falling back to server time', tz) + return None + + +def _parse_rule(s: str): + """Parse RRULE with clock-aligned DTSTART for sub-daily frequencies. + + MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00) + so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10). + """ + raw = s.replace('RRULE:', '') + parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p) + freq = parts.get('FREQ', '') + + if freq in ('MINUTELY', 'HOURLY'): + epoch = datetime(2000, 1, 1, 0, 0, 0) + return rrulestr(s, dtstart=epoch, ignoretz=True) + return rrulestr(s, ignoretz=True) + + +def validate_rrule(s: str, tz: str = None) -> None: + """Raise ValueError if the RRULE is malformed or exhausted. + + When *tz* is provided the "now" reference uses the user's local + clock so that near-future schedules are not incorrectly rejected + on servers whose system clock is ahead (e.g. UTC vs US timezones). + """ + try: + rule = _parse_rule(s) + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) + zi = _resolve_tz(tz) + now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() + if rule.after(now) is None: + raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS) + + +def next_run_ns(s: str, tz: str = None) -> Optional[int]: + """Next occurrence as epoch nanoseconds, respecting user timezone.""" + zi = _resolve_tz(tz) + now = datetime.now(zi) if zi else datetime.now() + dt = _parse_rule(s).after(now.replace(tzinfo=None)) + if dt is None: + return None + if zi: + dt = dt.replace(tzinfo=zi) + return int(dt.timestamp() * 1_000_000_000) + + +def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: + """Compute next N occurrences for UI preview. + + Uses the user's timezone for the starting "now" so that the + preview matches the user's local clock (same as next_run_ns). + """ + zi = _resolve_tz(tz) + rule = _parse_rule(s) + result = [] + now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now() + dt = now + for _ in range(n): + dt = rule.after(dt) + if not dt: + break + if zi: + dt_tz = dt.replace(tzinfo=zi) + result.append(int(dt_tz.timestamp() * 1_000_000_000)) + else: + result.append(int(dt.timestamp() * 1_000_000_000)) + return result + + +def rrule_interval_seconds(s: str) -> Optional[int]: + """Approximate interval between recurrences in seconds. + + Returns None for one-shot (COUNT=1) schedules or rules + with fewer than two future occurrences. + """ + if 'COUNT=1' in s: + return None + rule = _parse_rule(s) + now = datetime.now() + first = rule.after(now) + if first is None: + return None + second = rule.after(first) + if second is None: + return None + return int((second - first).total_seconds()) + + +############################ +# Worker Loop +############################ + + +# Keep the old name as an alias so any stale imports still work. +async def automation_worker_loop(app) -> None: + """Deprecated alias — use scheduler_worker_loop.""" + await scheduler_worker_loop(app) + + +async def scheduler_worker_loop(app) -> None: + """Unified background scheduler for all time-based work. + + Handles: + 1. Automation execution (ENABLE_AUTOMATIONS) + 2. Calendar event alerts (ENABLE_CALENDAR) + + Runs on every instance. Poll interval is configurable via + SCHEDULER_POLL_INTERVAL env var (default: 10 seconds). + """ + log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)') + while True: + try: + # ── Automations ── + if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False): + try: + async with get_async_db() as db: + batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db) + if batch: + log.info(f'Claimed {len(batch)} due automation(s)') + for automation in batch: + asyncio.create_task(execute_automation(app, automation)) + except Exception: + log.exception('Scheduler: automation error') + + # ── Calendar Alerts ── + if getattr(app.state.config, 'ENABLE_CALENDAR', False): + try: + await _check_calendar_alerts(app) + except Exception: + log.exception('Scheduler: calendar alert error') + + except Exception: + log.exception('Scheduler worker error') + + # Jitter to spread load across instances + await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)) + + +########################## +# Execute +#################### + + +def _build_request(app) -> Request: + """Build a minimal ASGI Request for chat_completion. + + Mirrors the mock-request pattern used in main.py lifespan + (model pre-fetch, tool server init) for consistency. + """ + scope = { + 'type': 'http', + 'asgi': {'version': '3.0', 'spec_version': '2.0'}, + 'method': 'POST', + 'path': '/api/v1/automations/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 0), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, + } + request = Request(scope) + # Ensure request.state is initialized with required attributes + request.state.token = None + request.state.enable_api_keys = False + return request + + +def _resolve_model_tool_ids(app, model_id: str) -> list[str]: + """Read model-attached tool_ids from model config. + + The frontend does this in Chat.svelte (model.info.meta.toolIds). + The backend never auto-resolves them, so we must do it explicitly. + """ + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', []) + return list(tool_ids) if tool_ids else [] + + +def _resolve_model_features(app, model_id: str) -> dict: + """Read model default features from model config. + + The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds + + model.info.meta.capabilities). Enables features like web_search, + code_interpreter, image_generation when the model has them as defaults + AND the capability is enabled AND the admin has enabled the feature. + """ + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + meta = model.get('info', {}).get('meta', {}) + + default_feature_ids = meta.get('defaultFeatureIds', []) + if not default_feature_ids: + return {} + + capabilities = meta.get('capabilities', {}) + config = app.state.config + features = {} + + # code_interpreter is excluded: it requires the frontend event emitter + # and does not work in headless backend execution. + feature_checks = { + 'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False), + 'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False), + } + + for feature_id in default_feature_ids: + if feature_id in feature_checks: + # Feature must be: in defaultFeatureIds + capability enabled + admin enabled + if capabilities.get(feature_id) and feature_checks[feature_id]: + features[feature_id] = True + + return features + + +def _resolve_model_filter_ids(app, model_id: str) -> list[str]: + """Read model default filter_ids from model config.""" + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', []) + return list(filter_ids) if filter_ids else [] + + +def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]: + """Read model default terminal_id from model config. + + The frontend does this in Chat.svelte (model.info.meta.terminalId). + """ + models = getattr(app.state, 'MODELS', {}) + model = models.get(model_id, {}) + return model.get('info', {}).get('meta', {}).get('terminalId') or None + + +async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None: + """Set the working directory on a terminal server via the proxy. + + Routes through the open-webui terminal proxy endpoint so that + auth headers, orchestrator policy routing, and X-User-Id are + handled correctly — same path the frontend uses. + """ + import aiohttp + from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL + + connections = getattr(getattr(app, 'state', None), 'config', None) + if connections is None: + return + connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or [] + connection = next((c for c in connections if c.get('id') == server_id), None) + if connection is None: + log.warning(f'Terminal server {server_id} not found for CWD set') + return + + base_url = (connection.get('url') or '').rstrip('/') + if not base_url: + return + + # Build target URL — route through orchestrator policy if configured + policy_id = connection.get('policy_id') + if connection.get('server_type') == 'orchestrator' and policy_id: + target_url = f'{base_url}/p/{policy_id}/files/cwd' + else: + target_url = f'{base_url}/files/cwd' + + headers = {'Content-Type': 'application/json', 'X-User-Id': user.id} + if chat_id: + headers['X-Session-Id'] = chat_id + + auth_type = connection.get('auth_type', 'bearer') + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.post( + target_url, + json={'path': cwd}, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as resp: + if resp.status != 200: + body = await resp.text() + log.warning(f'Failed to set terminal CWD to {cwd}: HTTP {resp.status} — {body[:200]}') + except Exception as e: + log.warning(f'Failed to set terminal CWD: {e}') + + +async def execute_automation(app, automation: AutomationModel) -> None: + """Execute an automation through the full chat completion pipeline. + + Creates a real chat, then calls chat_completion exactly like the frontend: + session_id + chat_id + message_id → async task → pipeline handles everything + (filters, model params, knowledge/RAG, tools, DB saves, webhooks). + """ + try: + user = await Users.get_user_by_id(automation.user_id) + if not user: + await _record_run(automation.id, 'error', error='User not found') + return + + prompt = await prompt_template(automation.data['prompt'], user) + model_id = automation.data['model_id'] + terminal_config = automation.data.get('terminal') + + # Generate proper UUIDs for messages (same as frontend) + user_msg_id = str(uuid4()) + assistant_msg_id = str(uuid4()) + + chat_id = str(uuid4()) + chat = await Chats.insert_new_chat( + chat_id, + automation.user_id, + ChatForm( + chat={ + 'title': automation.name, + 'models': [model_id], + 'history': { + 'currentId': assistant_msg_id, + 'messages': { + user_msg_id: { + 'id': user_msg_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + 'childrenIds': [assistant_msg_id], + 'timestamp': int(time.time()), + 'models': [model_id], + }, + assistant_msg_id: { + 'id': assistant_msg_id, + 'parentId': user_msg_id, + 'role': 'assistant', + 'content': '', + 'done': False, + 'model': model_id, + 'childrenIds': [], + 'timestamp': int(time.time()), + }, + }, + }, + 'messages': [ + {'role': 'user', 'content': prompt}, + ], + 'meta': {'automation_id': automation.id}, + } + ), + ) + + if not chat: + await _record_run(automation.id, 'error', error='Failed to create chat') + return + + # Notify frontend to refresh chat list + from open_webui.socket.main import sio + + await sio.emit( + 'events', + { + 'chat_id': chat.id, + 'message_id': user_msg_id, + 'data': {'type': 'chat:list'}, + }, + room=f'user:{automation.user_id}', + ) + + # Resolve model defaults (frontend does this, backend doesn't) + tool_ids = _resolve_model_tool_ids(app, model_id) + features = _resolve_model_features(app, model_id) + filter_ids = _resolve_model_filter_ids(app, model_id) + + # Resolve terminal from model config + terminal_id = _resolve_model_terminal_id(app, model_id) + + # Build the same payload the frontend sends to /api/chat/completions + form_data = { + 'model': model_id, + 'messages': [{'role': 'user', 'content': prompt}], + 'stream': True, + 'chat_id': chat.id, + 'id': assistant_msg_id, + 'parent_id': None, # Root message (chat already created above) + 'user_message': { + 'id': user_msg_id, + 'parentId': None, + 'role': 'user', + 'content': prompt, + }, + 'session_id': f'automation:{automation.id}', + 'background_tasks': {}, + } + if tool_ids: + form_data['tool_ids'] = tool_ids + if features: + form_data['features'] = features + if filter_ids: + form_data['filter_ids'] = filter_ids + if terminal_id: + form_data['terminal_id'] = terminal_id + + # Call the full chat completion pipeline (same as POST /api/chat/completions). + # The handler reference is stored on app.state to avoid circular imports. + request = _build_request(app) + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + + # Notify user + from open_webui.socket.main import sio + + await sio.emit( + 'automation:result', + { + 'automation_id': automation.id, + 'name': automation.name, + 'chat_id': chat.id, + 'status': 'success', + }, + room=f'user:{automation.user_id}', + ) + + await _record_run(automation.id, 'success', chat_id=chat.id) + + except Exception as e: + log.exception(f'Automation {automation.id} failed') + await _record_run(automation.id, 'error', error=str(e)[:4000]) + + +#################### +# Internals +#################### + + +async def _check_calendar_alerts(app) -> None: + """Check for upcoming calendar events and send alert notifications. + + De-duplication is DB-backed via meta.alerted_at — survives restarts + and works across multiple instances. + """ + from open_webui.models.calendar import CalendarEvents, CalendarEventUpdateForm + from open_webui.socket.main import sio + + now_ns = int(time.time_ns()) + default_lookahead_ns = CALENDAR_ALERT_LOOKAHEAD_MINUTES * 60 * 1_000_000_000 + + async with get_async_db() as db: + upcoming = await CalendarEvents.get_upcoming_events(now_ns, default_lookahead_ns, db=db) + + if not upcoming: + return + + for event, user_tz in upcoming: + # Skip if already alerted for this start time + if event.meta and event.meta.get('alerted_at'): + continue + + # Compute minutes until event starts + minutes_until = max(0, int((event.start_at - now_ns) / (60 * 1_000_000_000))) + + alert_data = { + 'event_id': event.id, + 'title': event.title, + 'description': event.description or '', + 'start_at': event.start_at, + 'minutes_until': minutes_until, + 'calendar_id': event.calendar_id, + 'location': event.location or '', + } + + await sio.emit( + 'events', + { + 'data': { + 'type': 'calendar:alert', + 'data': alert_data, + }, + }, + room=f'user:{event.user_id}', + ) + + # Mark as alerted in DB so it survives restarts / multi-instance + try: + await CalendarEvents.update_event_by_id( + event.id, + CalendarEventUpdateForm(meta={'alerted_at': now_ns}), + ) + except Exception: + log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True) + + # Send webhook notification if user has one configured + try: + webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI') + enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False) + + if enable_user_webhooks: + user = await Users.get_user_by_id(event.user_id) + if user and user.settings: + webhook_url = ( + user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None) + if isinstance(user.settings, dict) + else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)( + 'notifications', {} + ).get('webhook_url', None) + if hasattr(user.settings, 'ui') + else None + ) + if webhook_url: + from open_webui.utils.webhook import post_webhook + + time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now' + await post_webhook( + webui_name, + webhook_url, + f'{event.title} — starting {time_str}', + { + 'action': 'calendar_alert', + 'title': event.title, + 'minutes_until': minutes_until, + 'event_id': event.id, + }, + ) + except Exception: + log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True) + + +async def _record_run( + automation_id: str, + status: str, + chat_id: str = None, + error: str = None, +): + """Insert a run record into automation_run.""" + async with get_async_db() as db: + await AutomationRuns.insert(automation_id, status, chat_id=chat_id, error=error, db=db) diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py new file mode 100644 index 00000000000..9484c58dc0f --- /dev/null +++ b/backend/open_webui/utils/calendar.py @@ -0,0 +1,83 @@ +""" +Calendar utilities. + +RRULE expansion reusing the automation infra. +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +from open_webui.utils.automations import _parse_rule + +log = logging.getLogger(__name__) + + +def expand_recurring_event( + event_dict: dict, + range_start_ns: int, + range_end_ns: int, + tz: Optional[str] = None, + max_instances: int = 5000, +) -> list[dict]: + """Expand a recurring event into individual instances within a date range. + + Takes an event dict (from CalendarEventModel.model_dump()) and produces + one dict per occurrence, with adjusted start_at / end_at. + """ + from dateutil.rrule import rrulestr + + rrule_str = event_dict.get('rrule') + if not rrule_str: + return [event_dict] + + range_start_dt = datetime.fromtimestamp(range_start_ns / 1_000_000_000) + range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000) + scan_start = range_start_dt - timedelta(days=1) + + try: + # Parse with dtstart near the range so we never iterate from epoch + rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True) + except Exception: + log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') + return [event_dict] + + original_start_ns = event_dict['start_at'] + original_end_ns = event_dict.get('end_at') + duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None + + instances = [] + dt = rule.after(scan_start, inc=True) + + while dt and dt < range_end_dt and len(instances) < max_instances: + if tz: + try: + dt_tz = dt.replace(tzinfo=ZoneInfo(tz)) + instance_start_ns = int(dt_tz.timestamp() * 1_000_000_000) + except Exception: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + else: + instance_start_ns = int(dt.timestamp() * 1_000_000_000) + + if instance_start_ns >= range_start_ns: + instance = { + **event_dict, + 'start_at': instance_start_ns, + 'end_at': (instance_start_ns + duration_ns) if duration_ns else None, + 'instance_id': f'{event_dict["id"]}_{instance_start_ns}', + } + instances.append(instance) + + dt = rule.after(dt) + + return instances + + +def ns_from_date(year: int, month: int, day: int, tz: Optional[str] = None) -> int: + """Create epoch nanoseconds from a date.""" + if tz: + dt = datetime(year, month, day, tzinfo=ZoneInfo(tz)) + else: + dt = datetime(year, month, day) + return int(dt.timestamp() * 1_000_000_000) diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 79a7991eca7..ec35d3ea042 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -10,7 +10,7 @@ import uuid import asyncio -from fastapi import Request, status +from fastapi import HTTPException, Request, status from starlette.responses import Response, StreamingResponse, JSONResponse @@ -56,6 +56,8 @@ log = logging.getLogger(__name__) +# When the question has been asked, let silence not be the +# answer. But if the answer must wait, let it come honest. async def generate_direct_chat_completion( request: Request, form_data: dict, @@ -70,7 +72,12 @@ async def generate_direct_chat_completion( session_id = metadata.get('session_id') request_id = str(uuid.uuid4()) # Generate a unique request ID - event_caller = get_event_call(metadata) + event_caller = await get_event_call(metadata) + if event_caller is None: + raise Exception( + 'Direct connection requires an active WebSocket session; ' + 'cannot generate completion in this context (e.g. background task).' + ) channel = f'{user_id}:{session_id}:{request_id}' logging.info(f'WebSocket channel: {channel}') @@ -178,10 +185,14 @@ async def generate_chat_completion( } if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): + # Merge the direct connection model into server models so that + # task functions (title, tags, etc.) can resolve a server-side + # task model while still having the direct model available. models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } - log.debug(f'direct connection to model: {models}') + log.debug(f'direct connection to model: {request.state.model["id"]}') else: models = request.app.state.MODELS @@ -191,37 +202,51 @@ async def generate_chat_completion( model = models[model_id] - if getattr(request.state, 'direct', False): + if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'): return await generate_direct_chat_completion(request, form_data, user=user, models=models) else: # Check if user has access to the model if not bypass_filter and user.role == 'user': try: - check_model_access(user, model) + await check_model_access(user, model) except Exception as e: raise e - if model.get('owned_by') == 'arena': + # Arena model — sub-model was already resolved by process_chat_payload. + # Inject selected_model_id into the response for the frontend. + metadata = form_data.get('metadata', {}) + selected_model_id = metadata.pop('selected_model_id', None) + # Also clear from request.state.metadata to prevent the merge at + # lines 177-179 from re-adding it on the recursive call. + if hasattr(request.state, 'metadata'): + request.state.metadata.pop('selected_model_id', None) + + # Fallback: if generate_chat_completion is called with an arena model + # from a path that did NOT go through process_chat_payload (e.g., + # background tasks for title/follow-up/tags generation), resolve now. + if not selected_model_id and model.get('owned_by') == 'arena': model_ids = model.get('info', {}).get('meta', {}).get('model_ids') filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode') if model_ids and filter_mode == 'exclude': model_ids = [ - model['id'] - for model in list(request.app.state.MODELS.values()) - if model.get('owned_by') != 'arena' and model['id'] not in model_ids + available_model['id'] + for available_model in list(request.app.state.MODELS.values()) + if available_model.get('owned_by') != 'arena' and available_model['id'] not in model_ids ] - selected_model_id = None if isinstance(model_ids, list) and model_ids: selected_model_id = random.choice(model_ids) else: model_ids = [ - model['id'] for model in list(request.app.state.MODELS.values()) if model.get('owned_by') != 'arena' + available_model['id'] + for available_model in list(request.app.state.MODELS.values()) + if available_model.get('owned_by') != 'arena' ] selected_model_id = random.choice(model_ids) form_data['model'] = selected_model_id + if selected_model_id: if form_data.get('stream') == True: async def stream_wrapper(stream): @@ -294,12 +319,17 @@ async def chat_completed(request: Request, form_data: dict, user: Any): if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { + **request.app.state.MODELS, request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS data = form_data + + if not data.get('id'): + raise Exception('Missing message id') + model_id = data['model'] if model_id not in models: raise Exception('Model not found') @@ -308,9 +338,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any): try: data = await process_pipeline_outlet_filter(request, data, user, models) + except HTTPException: + raise except Exception as e: raise Exception(f'Error: {e}') + if not data.get('id'): + raise Exception('Missing message id') + metadata = { 'chat_id': data['chat_id'], 'message_id': data['id'], @@ -320,8 +355,8 @@ async def chat_completed(request: Request, form_data: dict, user: Any): } extra_params = { - '__event_emitter__': get_event_emitter(metadata), - '__event_call__': get_event_call(metadata), + '__event_emitter__': await get_event_emitter(metadata), + '__event_call__': await get_event_call(metadata), '__user__': user.model_dump() if isinstance(user, UserModel) else {}, '__metadata__': metadata, '__request__': request, @@ -329,8 +364,8 @@ async def chat_completed(request: Request, form_data: dict, user: Any): } try: - filter_ids = get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) - filter_functions = Functions.get_functions_by_ids(filter_ids) + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) result, _ = await process_filter_functions( request=request, diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 3e30c419ae0..52ddea24a73 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -8,6 +8,8 @@ import websockets from pydantic import BaseModel +from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS + logger = logging.getLogger(__name__) @@ -88,7 +90,7 @@ async def sign_in(self) -> None: async with self.session.post( 'login', data={'_xsrf': xsrf_token, 'password': self.password}, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: response.raise_for_status() self.session.cookie_jar.update_cookies(response.cookies) diff --git a/backend/open_webui/utils/embeddings.py b/backend/open_webui/utils/embeddings.py index 251b5edf7e8..1717886326c 100644 --- a/backend/open_webui/utils/embeddings.py +++ b/backend/open_webui/utils/embeddings.py @@ -68,7 +68,7 @@ async def generate_embeddings( # Access filtering if not getattr(request.state, 'direct', False): if not bypass_filter and user.role == 'user': - check_model_access(user, model) + await check_model_access(user, model) # Ollama backend — use /api/embed which supports batch input natively if model.get('owned_by') == 'ollama': diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 3bb918e8daf..6b821d58b61 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -20,42 +20,76 @@ from open_webui.routers.files import upload_file_handler from open_webui.retrieval.web.utils import validate_url +import asyncio import mimetypes import base64 import io import re -import requests +from open_webui.env import ( + AIOHTTP_CLIENT_ALLOW_REDIRECTS, + AIOHTTP_CLIENT_SESSION_SSL, + ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK, +) +from open_webui.utils.session_pool import get_session BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE) MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE) - -def get_image_base64_from_url(url: str) -> Optional[str]: +# Extension-based MIME fallback, only used when ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK is True. +_IMAGE_MIME_FALLBACK = { + '.webp': 'image/webp', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.ico': 'image/x-icon', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', +} + + +async def get_image_base64_from_url(url: str) -> Optional[str]: try: if url.startswith('http'): - # Validate URL to prevent SSRF attacks against local/private networks + # Validate URL to prevent SSRF attacks against local/private networks. + # allow_redirects=False prevents redirect-based SSRF: validate_url() is + # called only on the originally-submitted URL; following 3xx redirects + # without re-validation would let an attacker reach private IPs via a + # public host that redirects internally (e.g. cloud-metadata exfil). validate_url(url) # Download the image from the URL - response = requests.get(url) - response.raise_for_status() - image_data = response.content - encoded_string = base64.b64encode(image_data).decode('utf-8') - content_type = response.headers.get('Content-Type', 'image/png') - return f'data:{content_type};base64,{encoded_string}' + session = await get_session() + async with session.get( + url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS + ) as response: + response.raise_for_status() + image_data = await response.read() + encoded_string = base64.b64encode(image_data).decode('utf-8') + content_type = response.headers.get('Content-Type', 'image/png') + return f'data:{content_type};base64,{encoded_string}' else: - file = Files.get_file_by_id(url) + file = await Files.get_file_by_id(url) if not file: return None - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) if file_path.is_file(): with open(file_path, 'rb') as image_file: encoded_string = base64.b64encode(image_file.read()).decode('utf-8') - content_type, _ = mimetypes.guess_type(file_path.name) + content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') + if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: + content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) + if not content_type: + return None return f'data:{content_type};base64,{encoded_string}' else: return None @@ -64,13 +98,13 @@ def get_image_base64_from_url(url: str) -> Optional[str]: return None -def get_image_url_from_base64(request, base64_image_string, metadata, user): +async def get_image_url_from_base64(request, base64_image_string, metadata, user): if BASE64_IMAGE_URL_PREFIX.match(base64_image_string): image_url = '' # Extract base64 image data from the line - image_data, content_type = get_image_data(base64_image_string) + image_data, content_type = await get_image_data(base64_image_string) if image_data is not None: - _, image_url = upload_image( + _, image_url = await upload_image( request, image_data, content_type, @@ -82,17 +116,26 @@ def get_image_url_from_base64(request, base64_image_string, metadata, user): return None -def convert_markdown_base64_images(request, content: str, metadata, user): - def replace(match): +async def convert_markdown_base64_images(request, content: str, metadata, user): + MIN_REPLACEMENT_URL_LENGTH = 1024 + result_parts = [] + last_end = 0 + + for match in MARKDOWN_IMAGE_URL_PATTERN.finditer(content): + result_parts.append(content[last_end : match.start()]) base64_string = match.group(2) - MIN_REPLACEMENT_URL_LENGTH = 1024 if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH: - url = get_image_url_from_base64(request, base64_string, metadata, user) + url = await get_image_url_from_base64(request, base64_string, metadata, user) if url: - return f'![{match.group(1)}]({url})' - return match.group(0) + result_parts.append(f'![{match.group(1)}]({url})') + else: + result_parts.append(match.group(0)) + else: + result_parts.append(match.group(0)) + last_end = match.end() - return MARKDOWN_IMAGE_URL_PATTERN.sub(replace, content) + result_parts.append(content[last_end:]) + return ''.join(result_parts) def load_b64_audio_data(b64_str): @@ -110,7 +153,7 @@ def load_b64_audio_data(b64_str): return None, None -def upload_audio(request, audio_data, content_type, metadata, user): +async def upload_audio(request, audio_data, content_type, metadata, user): audio_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(audio_data), @@ -119,7 +162,7 @@ def upload_audio(request, audio_data, content_type, metadata, user): 'content-type': content_type, }, ) - file_item = upload_file_handler( + file_item = await upload_file_handler( request, file=file, metadata=metadata, @@ -130,13 +173,13 @@ def upload_audio(request, audio_data, content_type, metadata, user): return url -def get_audio_url_from_base64(request, base64_audio_string, metadata, user): +async def get_audio_url_from_base64(request, base64_audio_string, metadata, user): if 'data:audio/wav;base64' in base64_audio_string: audio_url = '' # Extract base64 audio data from the line audio_data, content_type = load_b64_audio_data(base64_audio_string) if audio_data is not None: - audio_url = upload_audio( + audio_url = await upload_audio( request, audio_data, content_type, @@ -147,30 +190,32 @@ def get_audio_url_from_base64(request, base64_audio_string, metadata, user): return None -def get_file_url_from_base64(request, base64_file_string, metadata, user): - if 'data:image/png;base64' in base64_file_string: - return get_image_url_from_base64(request, base64_file_string, metadata, user) +async def get_file_url_from_base64(request, base64_file_string, metadata, user): + if BASE64_IMAGE_URL_PREFIX.match(base64_file_string): + return await get_image_url_from_base64(request, base64_file_string, metadata, user) elif 'data:audio/wav;base64' in base64_file_string: - return get_audio_url_from_base64(request, base64_file_string, metadata, user) + return await get_audio_url_from_base64(request, base64_file_string, metadata, user) return None -def get_image_base64_from_file_id(id: str) -> Optional[str]: - file = Files.get_file_by_id(id) +async def get_image_base64_from_file_id(id: str) -> Optional[str]: + file = await Files.get_file_by_id(id) if not file: return None try: - file_path = Storage.get_file(file.path) + file_path = await asyncio.to_thread(Storage.get_file, file.path) file_path = Path(file_path) # Check if the file already exists in the cache if file_path.is_file(): - import base64 - with open(file_path, 'rb') as image_file: encoded_string = base64.b64encode(image_file.read()).decode('utf-8') - content_type, _ = mimetypes.guess_type(file_path.name) + content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type') + if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK: + content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower()) + if not content_type: + return None return f'data:{content_type};base64,{encoded_string}' else: return None diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 7f3f4e8ee2f..07edf9afa7c 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -10,48 +10,59 @@ log = logging.getLogger(__name__) -def get_function_module(request, function_id, load_from_db=True): +async def get_function_module(request, function_id, load_from_db=True): """ Get the function module by its ID. """ - function_module, _, _ = get_function_module_from_cache(request, function_id, load_from_db) + function_module, _, _ = await get_function_module_from_cache(request, function_id, load_from_db=load_from_db) return function_module -def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None): - def get_priority(function_id): +async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None): + async def get_priority(function_id): try: - function_module = get_function_module(request, function_id) + function_module = await get_function_module(request, function_id) if function_module and hasattr(function_module, 'Valves'): - valves_db = Functions.get_function_valves_by_id(function_id) + valves_db = await Functions.get_function_valves_by_id(function_id) valves = function_module.Valves(**(valves_db if valves_db else {})) return getattr(valves, 'priority', 0) except Exception: pass return 0 - filter_ids = [function.id for function in Functions.get_global_filter_functions()] + filter_ids = [function.id for function in await Functions.get_global_filter_functions()] if 'info' in model and 'meta' in model['info']: filter_ids.extend(model['info']['meta'].get('filterIds', [])) filter_ids = list(set(filter_ids)) - active_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)] + active_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)} - def get_active_status(filter_id): - function_module = get_function_module(request, filter_id) + async def get_active_status(filter_id): + function_module = await get_function_module(request, filter_id) if getattr(function_module, 'toggle', None): - return filter_id in (enabled_filter_ids or []) + return filter_id in (enabled_filter_ids or set()) return True - active_filter_ids = [filter_id for filter_id in active_filter_ids if get_active_status(filter_id)] + # Pre-compute active status for each filter (async functions can't be used in set comprehensions) + resolved_active = {} + for filter_id in active_filter_ids: + resolved_active[filter_id] = await get_active_status(filter_id) + active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active} filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] - filter_ids.sort(key=lambda fid: (get_priority(fid), fid)) + + # Pre-compute priorities (async functions can't be used in sort keys) + priorities = {} + for fid in filter_ids: + priorities[fid] = await get_priority(fid) + filter_ids.sort(key=lambda fid: (priorities.get(fid, 0), fid)) return filter_ids +# Grant these filters the discernment to pass what serves +# and refuse what harms, for every soul in the house. async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params): skip_files = None @@ -61,7 +72,7 @@ async def process_filter_functions(request, filter_functions, filter_type, form_ if not filter: continue - function_module = get_function_module(request, filter_id, load_from_db=(filter_type != 'stream')) + function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream')) # Prepare handler function handler = getattr(function_module, filter_type, None) if not handler: @@ -73,7 +84,7 @@ async def process_filter_functions(request, filter_functions, filter_type, form_ # Apply valves to the function if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): - valves = Functions.get_function_valves_by_id(filter_id) + valves = await Functions.get_function_valves_by_id(filter_id) function_module.valves = function_module.Valves(**(valves if valves else {})) try: @@ -98,7 +109,7 @@ async def process_filter_functions(request, filter_functions, filter_type, form_ if hasattr(function_module, 'UserValves'): try: params['__user__']['valves'] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id(filter_id, params['__user__']['id']) + **await Functions.get_user_valves_by_id_and_user_id(filter_id, params['__user__']['id']) ) except Exception as e: log.exception(f'Failed to get user values: {e}') diff --git a/backend/open_webui/utils/groups.py b/backend/open_webui/utils/groups.py index 90c4593cece..50099b2ee79 100644 --- a/backend/open_webui/utils/groups.py +++ b/backend/open_webui/utils/groups.py @@ -4,7 +4,7 @@ log = logging.getLogger(__name__) -def apply_default_group_assignment( +async def apply_default_group_assignment( default_group_id: str, user_id: str, db=None, @@ -18,6 +18,6 @@ def apply_default_group_assignment( """ if default_group_id: try: - Groups.add_users_to_group(default_group_id, [user_id], db=db) + await Groups.add_users_to_group(default_group_id, [user_id], db=db) except Exception as e: log.error(f'Failed to add user {user_id} to default group {default_group_id}: {e}') diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 0baee5edb90..f5ad41bcd26 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -16,3 +16,26 @@ def include_user_info_headers(headers, user): FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email, FORWARD_USER_INFO_HEADER_USER_ROLE: user.role, } + + +def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -> dict: + if not custom_headers or not isinstance(custom_headers, dict): + return {} + + metadata = metadata or {} + template_vars = { + '{{CHAT_ID}}': metadata.get('chat_id', '') or '', + '{{MESSAGE_ID}}': metadata.get('message_id', '') or '', + '{{USER_ID}}': (user.id if user else '') or '', + '{{USER_NAME}}': (user.name if user else '') or '', + } + + parsed_headers = {} + for key, value in custom_headers.items(): + if not isinstance(value, str): + value = str(value) + for token, val in template_vars.items(): + value = value.replace(token, val) + parsed_headers[key] = value + + return parsed_headers diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 497808c22db..9172f1c3250 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -1,49 +1,51 @@ -import asyncio import json import logging import random -import requests -import aiohttp import urllib.parse -import urllib.request from typing import Optional -import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client) +import aiohttp from pydantic import BaseModel +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL +from open_webui.utils.session_pool import get_session + log = logging.getLogger(__name__) default_headers = {'User-Agent': 'Mozilla/5.0'} -def queue_prompt(prompt, client_id, base_url, api_key): +async def queue_prompt(prompt, client_id, base_url, api_key): log.info('queue_prompt') p = {'prompt': prompt, 'client_id': client_id} - data = json.dumps(p).encode('utf-8') - log.debug(f'queue_prompt data: {data}') + log.debug(f'queue_prompt data: {p}') try: - req = urllib.request.Request( + session = await get_session() + async with session.post( f'{base_url}/prompt', - data=data, + json=p, headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - response = urllib.request.urlopen(req).read() - return json.loads(response) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() except Exception as e: log.exception(f'Error while queuing prompt: {e}') - raise e + raise -def get_image(filename, subfolder, folder_type, base_url, api_key): +async def get_image(filename, subfolder, folder_type, base_url, api_key): log.info('get_image') data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type} url_values = urllib.parse.urlencode(data) - req = urllib.request.Request( + session = await get_session() + async with session.get( f'{base_url}/view?{url_values}', headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - with urllib.request.urlopen(req) as response: - return response.read() + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.read() def get_image_url(filename, subfolder, folder_type, base_url): @@ -53,32 +55,39 @@ def get_image_url(filename, subfolder, folder_type, base_url): return f'{base_url}/view?{url_values}' -def get_history(prompt_id, base_url, api_key): +async def get_history(prompt_id, base_url, api_key): log.info('get_history') - - req = urllib.request.Request( + session = await get_session() + async with session.get( f'{base_url}/history/{prompt_id}', headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, - ) - with urllib.request.urlopen(req) as response: - return json.loads(response.read()) + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + r.raise_for_status() + return await r.json() + +async def _ws_get_images(ws, workflow, client_id, base_url, api_key): + """Queue a prompt and wait on *ws* for ComfyUI to finish executing it. -def get_images(ws, workflow, client_id, base_url, api_key): - prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id'] + Returns a dict of ``{'data': [{'url': ...}, ...]}``. + """ + prompt_id = (await queue_prompt(workflow, client_id, base_url, api_key))['prompt_id'] output_images = [] - while True: - out = ws.recv() - if isinstance(out, str): - message = json.loads(out) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + message = json.loads(msg.data) if message['type'] == 'executing': data = message['data'] if data['node'] is None and data['prompt_id'] == prompt_id: break # Execution is done - else: - continue # previews are binary data + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + log.error(f'WebSocket closed unexpectedly: {msg.type}') + break + # binary messages (previews) are silently skipped - history = get_history(prompt_id, base_url, api_key)[prompt_id] + history = (await get_history(prompt_id, base_url, api_key))[prompt_id] for node_id in history['outputs']: node_output = history['outputs'][node_id] if node_id in workflow and workflow[node_id].get('class_type') in [ @@ -105,10 +114,10 @@ async def comfyui_upload_image(image_file_item, base_url, api_key): form.add_field('image', file_bytes, filename=filename, content_type=mime_type) form.add_field('type', 'input') # required by ComfyUI - async with aiohttp.ClientSession() as session: - async with session.post(url, data=form, headers=headers) as resp: - resp.raise_for_status() - return await resp.json() + session = await get_session() + async with session.post(url, data=form, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + resp.raise_for_status() + return await resp.json() class ComfyUINodeInput(BaseModel): @@ -136,11 +145,9 @@ class ComfyUICreateImageForm(BaseModel): seed: Optional[int] = None -async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): - ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') - workflow = json.loads(payload.workflow.workflow) - - for node in payload.workflow.nodes: +def _apply_workflow_nodes(workflow, nodes, model, payload): + """Mutate *workflow* dict in-place based on typed node definitions.""" + for node in nodes: if node.type: if node.type == 'model': for node_id in node.node_ids: @@ -151,6 +158,14 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie elif node.type == 'negative_prompt': for node_id in node.node_ids: workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt + elif node.type == 'image': + if isinstance(payload.image, list): + for idx, node_id in enumerate(node.node_ids): + if idx < len(payload.image): + workflow[node_id]['inputs'][node.key] = payload.image[idx] + else: + for node_id in node.node_ids: + workflow[node_id]['inputs'][node.key] = payload.image elif node.type == 'width': for node_id in node.node_ids: workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width @@ -171,24 +186,31 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie for node_id in node.node_ids: workflow[node_id]['inputs'][node.key] = node.value + +async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): + ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') + workflow = json.loads(payload.workflow.workflow) + _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) + + headers = {'Authorization': f'Bearer {api_key}'} + session = await get_session() + try: - ws = websocket.WebSocket() - headers = {'Authorization': f'Bearer {api_key}'} - ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) - log.info('WebSocket connection established.') - except Exception as e: + async with session.ws_connect( + f'{ws_url}/ws?clientId={client_id}', + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as ws: + log.info('WebSocket connection established.') + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) + except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') return None - - try: - log.info('Sending workflow to WebSocket server.') - log.info(f'Workflow: {workflow}') - images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f'Error while receiving images: {e}') - images = None - - ws.close() + log.exception(f'Error during image generation: {e}') + return None return images @@ -209,64 +231,26 @@ class ComfyUIEditImageForm(BaseModel): async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key): ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') workflow = json.loads(payload.workflow.workflow) + _apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload) - for node in payload.workflow.nodes: - if node.type: - if node.type == 'model': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = model - elif node.type == 'image': - if isinstance(payload.image, list): - # check if multiple images are provided - for idx, node_id in enumerate(node.node_ids): - if idx < len(payload.image): - workflow[node_id]['inputs'][node.key] = payload.image[idx] - else: - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = payload.image - elif node.type == 'prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt - elif node.type == 'negative_prompt': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt - elif node.type == 'width': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width - elif node.type == 'height': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height - elif node.type == 'n': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n - elif node.type == 'steps': - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps - elif node.type == 'seed': - seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = seed - else: - for node_id in node.node_ids: - workflow[node_id]['inputs'][node.key] = node.value + headers = {'Authorization': f'Bearer {api_key}'} + session = await get_session() try: - ws = websocket.WebSocket() - headers = {'Authorization': f'Bearer {api_key}'} - ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) - log.info('WebSocket connection established.') - except Exception as e: + async with session.ws_connect( + f'{ws_url}/ws?clientId={client_id}', + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as ws: + log.info('WebSocket connection established.') + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await _ws_get_images(ws, workflow, client_id, base_url, api_key) + except aiohttp.WSServerHandshakeError as e: log.exception(f'Failed to connect to WebSocket server: {e}') return None - - try: - log.info('Sending workflow to WebSocket server.') - log.info(f'Workflow: {workflow}') - images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f'Error while receiving images: {e}') - images = None - - ws.close() + log.exception(f'Error during image editing: {e}') + return None return images diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 5cc34fe923a..fa4e77f53db 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from loguru import logger -from opentelemetry import trace + from open_webui.env import ( ENABLE_AUDIT_STDOUT, ENABLE_AUDIT_LOGS_FILE, @@ -100,6 +100,8 @@ def _get_extras(self): if not ENABLE_OTEL: return {} + from opentelemetry import trace + extras = {} context = trace.get_current_span().get_span_context() if context.is_valid: @@ -150,7 +152,7 @@ def start_logger(): """ logger.remove() - audit_filter = lambda record: (True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra']) + audit_filter = lambda record: True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra'] if LOG_FORMAT == 'json': logger.add( _json_sink, diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index fbabb390aa5..7a5aa61b802 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -1,7 +1,10 @@ import asyncio +import logging from typing import Optional from contextlib import AsyncExitStack +log = logging.getLogger(__name__) + import anyio from mcp import ClientSession @@ -9,22 +12,27 @@ from mcp.client.streamable_http import streamablehttp_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken import httpx -from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL +from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER -def create_insecure_httpx_client(headers=None, timeout=None, auth=None): - """Create an httpx AsyncClient with SSL verification disabled. +def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True): + """Create an httpx AsyncClient for MCP transport. + + Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller + (i.e. the MCP SDK) does not supply an explicit timeout. - Note: verify=False must be passed at construction time because httpx + Note: verify must be passed at construction time because httpx configures the SSL context during __init__. Setting client.verify = False after construction does not affect the underlying transport's SSL context. """ kwargs = { 'follow_redirects': True, - 'verify': False, + 'verify': verify, } if timeout is not None: kwargs['timeout'] = timeout + elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None: + kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) if headers is not None: kwargs['headers'] = headers if auth is not None: @@ -32,6 +40,14 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None): return httpx.AsyncClient(**kwargs) +def create_httpx_client(headers=None, timeout=None, auth=None): + return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True) + + +def create_insecure_httpx_client(headers=None, timeout=None, auth=None): + return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False) + + class MCPClient: def __init__(self): self.session: Optional[ClientSession] = None @@ -40,14 +56,13 @@ def __init__(self): async def connect(self, url: str, headers: Optional[dict] = None): async with AsyncExitStack() as exit_stack: try: - if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL: - self._streams_context = streamablehttp_client(url, headers=headers) - else: - self._streams_context = streamablehttp_client( - url, - headers=headers, - httpx_client_factory=create_insecure_httpx_client, - ) + self._streams_context = streamablehttp_client( + url, + headers=headers, + httpx_client_factory=create_httpx_client + if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL + else create_insecure_httpx_client, + ) transport = await exit_stack.enter_async_context(self._streams_context) read_stream, write_stream, _ = transport @@ -124,8 +139,37 @@ async def read_resource(self, uri: str) -> Optional[dict]: return result_dict async def disconnect(self): - # Clean up and close the session - await self.exit_stack.aclose() + """Clean up and close the session. + + This method is idempotent — calling it multiple times or on a + client that was never connected is safe. It shields the close + operation from CancelledError and adds a timeout so a hung MCP + server cannot block the event loop indefinitely. + """ + exit_stack = self.exit_stack + if exit_stack is None: + return + + # Prevent double-close from concurrent callers + self.exit_stack = None + self.session = None + + try: + # IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for() + # because they create a new asyncio task, which violates the MCP SDK's + # requirement that its TaskGroup be exited in the exact same task. + # ALSO do NOT use anyio.CancelScope(shield=True) or anyio.fail_after(), + # because they push a new cancel scope onto the task, violating LIFO + # order when aclose() attempts to exit the inner TaskGroup. + # We simply call aclose() directly. If the task is cancelled, the + # sockets will eventually be cleaned up by garbage collection. + await exit_stack.aclose() + except TimeoutError: + log.warning('MCPClient.disconnect() timed out after 5 s') + except RuntimeError as exc: + log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc) + except Exception as exc: + log.debug('MCPClient.disconnect() error: %s', exc) async def __aenter__(self): await self.exit_stack.__aenter__() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index ae1b557da75..56226fc226b 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -92,11 +92,13 @@ get_last_user_message_item, get_last_assistant_message, get_system_message, + merge_system_messages, replace_system_message_content, prepend_to_first_user_message_content, convert_logit_bias_input_to_json, get_content_from_message, convert_output_to_messages, + strip_empty_content_blocks, ) from open_webui.utils.tools import ( get_tools, @@ -104,6 +106,7 @@ get_terminal_tools, ) from open_webui.utils.access_control import has_connection_access +from open_webui.utils.access_control.files import get_accessible_folder_files from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -135,6 +138,7 @@ ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, FORWARD_SESSION_INFO_HEADER_MESSAGE_ID, + ENABLE_RESPONSES_API_STATEFUL, ) from open_webui.utils.headers import include_user_info_headers from open_webui.constants import TASKS @@ -143,6 +147,10 @@ log = logging.getLogger(__name__) +# We believe in one maker of all models, seen and unseen, +# and in the reasoning which proceeds from the architect. +# We look for the resurrection of dead processes and the +# inference of the world to come. DEFAULT_REASONING_TAGS = [ ('', ''), ('', ''), @@ -225,7 +233,7 @@ def get_citation_source_from_tool_result( Returns a list of sources (usually one, but query_knowledge_files may return multiple). """ _EXPECTS_LIST = {'search_web', 'query_knowledge_files'} - _EXPECTS_DICT = {'view_knowledge_file'} + _EXPECTS_DICT = {'view_knowledge_file', 'view_file'} try: try: @@ -269,7 +277,7 @@ def get_citation_source_from_tool_result( } ] - elif tool_name == 'view_knowledge_file': + elif tool_name in ('view_knowledge_file', 'view_file'): file_data = tool_result filename = file_data.get('filename', 'Unknown File') file_id = file_data.get('id', '') @@ -394,12 +402,65 @@ def is_opening_code_block(content): return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 +_OPENAI_TOOL_DISPLAY_NAMES = { + 'web_search_call': 'Web Search', + 'file_search_call': 'File Search', + 'computer_call': 'Computer Use', +} + + +def _render_openai_tool_call_handler(item: dict, done: bool) -> str: + """Render an OpenAI Responses API server-side tool item as a
block. + + Handles web_search_call, file_search_call, and computer_call items whose + schemas are defined in the openai-python SDK (generated from OpenAPI spec). + """ + item_type = item.get('type', '') + call_id = item.get('id', '') + display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type) + + # Build a short summary of what the tool did + summary = '' + if item_type == 'web_search_call': + action = item.get('action', {}) + if isinstance(action, dict): + atype = action.get('type', '') + if atype == 'search': + queries = action.get('queries') or [] + query = action.get('query', '') + summary = ( + f'Search: {", ".join(str(q) for q in queries)}' + if queries + else (f'Search: {query}' if query else '') + ) + elif atype == 'open_page': + summary = f'Open page: {action.get("url", "")}' if action.get('url') else '' + elif atype == 'find_in_page': + summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else '' + elif item_type == 'file_search_call': + queries = item.get('queries', []) + if queries: + summary = f'Queries: {", ".join(str(q) for q in queries)}' + elif item_type == 'computer_call': + action = item.get('action') + actions = item.get('actions') + if isinstance(action, dict): + summary = f'Action: {action.get("type", "unknown")}' + elif isinstance(actions, list) and actions: + summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}' + + escaped_name = html.escape(display_name) + if done: + return f'
\nTool Executed\n{html.escape(summary)}\n
\n' + return f'
\nExecuting...\n
\n' + + def serialize_output(output: list) -> str: """ Convert OR-aligned output items to HTML for display. For LLM consumption, use convert_output_to_messages() instead. """ - content = '' + parts: list[str] = [] # First pass: collect function_call_output items by call_id for lookup tool_outputs = {} @@ -416,46 +477,52 @@ def serialize_output(output: list) -> str: if 'text' in content_part: text = content_part.get('text', '').strip() if text: - content = f'{content}{text}\n' + parts.append(text) elif item_type == 'function_call': - # Render tool call inline with its result (if available) - if content and not content.endswith('\n'): - content += '\n' - call_id = item.get('call_id', '') name = item.get('name', '') arguments = item.get('arguments', '') result_item = tool_outputs.get(call_id) if result_item: - result_text = '' + result_parts: list[str] = [] for result_output in result_item.get('output', []): if 'text' in result_output: output_text = result_output.get('text', '') - result_text += str(output_text) if not isinstance(output_text, str) else output_text + result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text) + result_text = ''.join(result_parts) files = result_item.get('files') embeds = result_item.get('embeds', '') - content += f'
\nTool Executed\n
\n' + parts.append( + f'
\nTool Executed\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n
' + ) else: - content += f'
\nExecuting...\n
\n' + parts.append( + f'
\nExecuting...\n
' + ) elif item_type == 'function_call_output': # Already handled inline with function_call above pass + elif item_type in _OPENAI_TOOL_DISPLAY_NAMES: + status = item.get('status', 'in_progress') + done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1 + parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n')) + elif item_type == 'reasoning': - reasoning_content = '' + reasoning_parts: list[str] = [] # Check for 'summary' (new structure) or 'content' (legacy/fallback) source_list = item.get('summary', []) or item.get('content', []) for content_part in source_list: if 'text' in content_part: - reasoning_content += content_part.get('text', '') + reasoning_parts.append(content_part.get('text', '')) elif 'summary' in content_part: # Handle potential nested logic if any pass - reasoning_content = reasoning_content.strip() + reasoning_content = ''.join(reasoning_parts).strip() duration = item.get('duration') status = item.get('status', 'in_progress') @@ -464,9 +531,6 @@ def serialize_output(output: list) -> str: # render as done (a subsequent item means reasoning is complete) is_last_item = idx == len(output) - 1 - if content and not content.endswith('\n'): - content += '\n' - display = html.escape( '\n'.join( (f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines() @@ -474,19 +538,26 @@ def serialize_output(output: list) -> str: ) if status == 'completed' or duration is not None or not is_last_item: - content = f'{content}
\nThought for {duration or 0} seconds\n{display}\n
\n' + parts.append( + f'
\nThought for {duration or 0} seconds\n{display}\n
' + ) else: - content = f'{content}
\nThinking…\n{display}\n
\n' + parts.append( + f'
\nThinking…\n{display}\n
' + ) elif item_type == 'open_webui:code_interpreter': + # Code interpreter needs to inspect/mutate prior accumulated content + # to strip trailing unclosed code fences — materialize only here. + content = '\n'.join(parts) content_stripped, original_whitespace = split_content_and_whitespace(content) if is_opening_code_block(content_stripped): content = content_stripped.rstrip('`').rstrip() + original_whitespace else: content = content_stripped + original_whitespace - if content and not content.endswith('\n'): - content += '\n' + # Re-split back into parts list after mutation + parts = [content] if content else [] # Render the code_interpreter item as a
block # so the frontend Collapsible renders "Analyzing..."/"Analyzed". @@ -512,11 +583,15 @@ def serialize_output(output: list) -> str: output_attr = f' output="{html.escape(output_json)}"' if status == 'completed' or duration is not None or not is_last_item: - content += f'
\nAnalyzed\n{display}\n
\n' + parts.append( + f'
\nAnalyzed\n{display}\n
' + ) else: - content += f'
\nAnalyzing…\n{display}\n
\n' + parts.append( + f'
\nAnalyzing…\n{display}\n
' + ) - return content.strip() + return '\n'.join(parts).strip() def deep_merge(target, source): @@ -849,7 +924,11 @@ def handle_responses_streaming_event( if item.get('type') == 'reasoning' and item.get('status') != 'completed': item['status'] = 'completed' - return new_output, {'usage': response_data.get('usage'), 'done': True} + return new_output, { + 'usage': response_data.get('usage'), + 'done': True, + 'response_id': response_data.get('id'), + } elif event_type == 'response.in_progress': # State Machine Event: In Progress @@ -878,16 +957,20 @@ def get_source_context(sources: list, source_ids: dict = None, include_content: if source_id not in source_ids: source_ids[source_id] = len(source_ids) + 1 src_name = source.get('source', {}).get('name') + src_type = source.get('source', {}).get('type') + src_rid = source.get('source', {}).get('id') body = doc if include_content else '' context_string += ( f'{body}\n' ) return context_string -def apply_source_context_to_messages( +async def apply_source_context_to_messages( request: Request, messages: list, sources: list, @@ -913,19 +996,19 @@ def apply_source_context_to_messages( if RAG_SYSTEM_CONTEXT: return add_or_update_system_message( - rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), messages, append=True, ) else: return add_or_update_user_message( - rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), + await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message), messages, append=False, ) -def process_tool_result( +async def process_tool_result( request, tool_function_name, tool_result, @@ -937,6 +1020,13 @@ def process_tool_result( tool_result_embeds = [] EXTERNAL_TOOL_TYPES = ('external', 'action', 'terminal') + # Support (HTMLResponse, result_context) tuples: the optional second + # element lets tool authors provide the LLM with actionable context + # about the generated embed instead of the generic fallback message. + result_context = None + if isinstance(tool_result, tuple) and len(tool_result) == 2 and isinstance(tool_result[0], HTMLResponse): + tool_result, result_context = tool_result + if isinstance(tool_result, HTMLResponse): content_disposition = tool_result.headers.get('Content-Disposition', '') if 'inline' in content_disposition: @@ -944,11 +1034,14 @@ def process_tool_result( tool_result_embeds.append(content) if 200 <= tool_result.status_code < 300: - tool_result = { - 'status': 'success', - 'code': 'ui_component', - 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', - } + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } elif 400 <= tool_result.status_code < 500: tool_result = { 'status': 'error', @@ -999,23 +1092,47 @@ def process_tool_result( ) if 'text/html' in content_type: + # Support (html_content, result_context) nested tuple + result_context = None + html_content = tool_result + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + html_content, result_context = tool_result + # Display as iframe embed - tool_result_embeds.append(tool_result) - tool_result = { - 'status': 'success', - 'code': 'ui_component', - 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', - } + tool_result_embeds.append(html_content) + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } elif location: + # Support (html_content, result_context) nested tuple for location embeds + result_context = None + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + _, result_context = tool_result + tool_result_embeds.append(location) - tool_result = { - 'status': 'success', - 'code': 'ui_component', - 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', - } + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } tool_result_files = [] + # Detect base64 image data URIs from tool results (e.g. binary image + # responses from execute_tool_server). Move the data URI to + # tool_result_files and replace tool_result with a text summary. + if isinstance(tool_result, str) and tool_result.startswith('data:image/'): + tool_result_files.append({'type': 'image', 'url': tool_result}) + tool_result = f'{tool_function_name}: Image file read successfully.' + if isinstance(tool_result, list): if tool_type == 'mcp': # MCP tool_response = [] @@ -1030,7 +1147,7 @@ def process_tool_result( pass tool_response.append(text) elif item.get('type') in ['image', 'audio']: - file_url = get_file_url_from_base64( + file_url = await get_file_url_from_base64( request, f'data:{item.get("mimeType")};base64,{item.get("data", item.get("blob", ""))}', { @@ -1048,6 +1165,15 @@ def process_tool_result( 'url': file_url, } ) + elif item.get('type') == 'resource': + resource = item.get('resource', {}) + text = resource.get('text', '') + if isinstance(text, str) and text: + try: + text = json.loads(text) + except json.JSONDecodeError: + pass + tool_response.append(text) tool_result = tool_response[0] if len(tool_response) == 1 else tool_response else: # OpenAPI for item in tool_result: @@ -1259,7 +1385,7 @@ async def tool_call_handler(tool_call): except Exception as e: tool_result = str(e) - tool_result, tool_result_files, tool_result_embeds = process_tool_result( + tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, tool_function_name, tool_result, @@ -1413,7 +1539,7 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param response = res['choices'][0]['message']['content'] try: - bracket_start = response.find('{') + bracket_start = response.rfind('{') bracket_end = response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: @@ -1430,11 +1556,11 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param except Exception as e: log.exception(e) - queries = [user_message] + queries = [user_message or ''] # Check if generated queries are empty if len(queries) == 1 and queries[0].strip() == '': - queries = [user_message] + queries = [user_message or ''] # Check if queries are not found if len(queries) == 0: @@ -1557,7 +1683,7 @@ def get_images_from_messages(message_list): return images -def get_image_urls(delta_images, request, metadata, user) -> list[str]: +async def get_image_urls(delta_images, request, metadata, user) -> list[str]: if not isinstance(delta_images, list): return [] @@ -1571,21 +1697,21 @@ def get_image_urls(delta_images, request, metadata, user) -> list[str]: continue if url.startswith('data:image/png;base64'): - url = get_image_url_from_base64(request, url, metadata, user) + url = await get_image_url_from_base64(request, url, metadata, user) image_urls.append(url) return image_urls -def add_file_context(messages: list, chat_id: str, user) -> list: +async def add_file_context(messages: list, chat_id: str, user) -> list: """ Add file URLs to messages for native function calling. """ - if not chat_id or chat_id.startswith('local:'): + if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'): return messages - chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) + chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) if not chat: return messages @@ -1600,7 +1726,16 @@ def format_file_tag(file): attrs += f' name="{file["name"]}"' return f'' - for message, stored_message in zip(messages, stored_messages): + # Pair only user-role messages from both lists to avoid misalignment. + # After process_messages_with_output(), assistant messages with tool calls + # are expanded into multiple messages (assistant + tool results), making + # the payload message list longer than the stored message list. A naive + # positional zip() would pair user messages with wrong stored messages, + # causing later images to lose their file context (see #21878). + user_messages = [m for m in messages if m.get('role') == 'user'] + stored_user_messages = [m for m in stored_messages if m.get('role') == 'user'] + + for message, stored_message in zip(user_messages, stored_user_messages): files_with_urls = [ file for file in stored_message.get('files', []) @@ -1629,10 +1764,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: return form_data - if chat_id.startswith('local:'): + if chat_id.startswith('local:') or chat_id.startswith('channel:'): message_list = form_data.get('messages', []) else: - chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) + chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id) await __event_emitter__( { 'type': 'status', @@ -1735,7 +1870,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra response = res['choices'][0]['message']['content'] try: - bracket_start = response.find('{') + bracket_start = response.rfind('{') bracket_end = response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: @@ -1839,7 +1974,7 @@ async def chat_completion_files_handler( queries_response = queries_response['choices'][0]['message']['content'] try: - bracket_start = queries_response.find('{') + bracket_start = queries_response.rfind('{') bracket_end = queries_response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: @@ -1866,7 +2001,7 @@ async def chat_completion_files_handler( ) if len(queries) == 0: - queries = [get_last_user_message(body['messages'])] + queries = [get_last_user_message(body['messages']) or ''] try: # Directly await async get_sources_from_items (no thread needed - fully async now) @@ -1996,13 +2131,16 @@ async def convert_url_images_to_base64(form_data): continue try: - base64_data = await asyncio.to_thread(get_image_base64_from_url, image_url) - new_content.append( - { - 'type': 'image_url', - 'image_url': {'url': base64_data}, - } - ) + base64_data = await get_image_base64_from_url(image_url) + if base64_data: + new_content.append( + { + 'type': 'image_url', + 'image_url': {'url': base64_data}, + } + ) + else: + new_content.append(item) except Exception as e: log.debug(f'Error converting image URL to base64: {e}') new_content.append(item) @@ -2012,12 +2150,12 @@ async def convert_url_images_to_base64(form_data): return form_data -def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]: +async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]: """ Load the message chain from DB up to message_id, keeping only LLM-relevant fields (role, content, output). """ - messages_map = Chats.get_messages_map_by_chat_id(chat_id) + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) if not messages_map: return None @@ -2028,7 +2166,27 @@ def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]] return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages] -def process_messages_with_output(messages: list[dict]) -> list[dict]: +def get_reasoning_format(model: dict) -> str | None: + """ + Determine how reasoning should be included in reconstructed messages. + + Returns: + 'think_tags': Ollama expects tags in content. + 'reasoning_content': llama.cpp supports reasoning_content as a top-level field. + None: skip reasoning (safe default for strict providers). + """ + provider = model.get('provider', '') + if provider == 'ollama': + return 'think_tags' + if provider == 'llama.cpp': + return 'reasoning_content' + return None + + +def process_messages_with_output( + messages: list[dict], + reasoning_format: str | None = None, +) -> list[dict]: """ Process messages with OR-aligned output items for LLM consumption. @@ -2040,7 +2198,11 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]: for message in messages: if message.get('role') == 'assistant' and message.get('output'): # Use output items for clean OpenAI-format messages - output_messages = convert_output_to_messages(message['output'], raw=True) + output_messages = convert_output_to_messages( + message['output'], + raw=True, + reasoning_format=reasoning_format, + ) if output_messages: processed.extend(output_messages) continue @@ -2052,22 +2214,101 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]: return processed +SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)\|?[^>]*>') + + +def _get_text_parts(message: dict) -> list[str]: + """Return all text segments from a message's content.""" + content = message.get('content') + if isinstance(content, str): + return [content] + if isinstance(content, list): + return [p.get('text', '') for p in content if isinstance(p, dict) and p.get('type') == 'text'] + return [] + + +def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]: + """Extract skill IDs from <$skillId|label> mention tags in messages.""" + ids: set[str] = set() + for message in messages: + for text in _get_text_parts(message): + ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text)) + return ids + + +def strip_skill_mentions(messages: list[dict]) -> None: + """Strip <$skillId|label> mention tags from message content in-place.""" + strip_re = re.compile(r'<\$[^>]+>') + for message in messages: + content = message.get('content') + if isinstance(content, str) and strip_re.search(content): + message['content'] = strip_re.sub('', content).strip() + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get('type') == 'text': + text = part.get('text', '') + if strip_re.search(text): + part['text'] = strip_re.sub('', text).strip() + + async def process_chat_payload(request, form_data, user, metadata, model): # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling # -> Chat Files + # Arena model resolution — pick the sub-model now so all downstream + # processing (knowledge, capabilities, tools, params) uses its settings + # instead of the empty arena wrapper. + if model.get('owned_by') == 'arena': + arena_model_ids = model.get('info', {}).get('meta', {}).get('model_ids') + arena_filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode') + if arena_model_ids and arena_filter_mode == 'exclude': + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' and available_model['id'] not in arena_model_ids + ] + + if isinstance(arena_model_ids, list) and arena_model_ids: + selected_model_id = random.choice(arena_model_ids) + else: + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' + ] + selected_model_id = random.choice(arena_model_ids) + + selected_model = request.app.state.MODELS.get(selected_model_id) + if selected_model: + model = selected_model + form_data['model'] = selected_model_id + metadata['selected_model_id'] = selected_model_id + form_data = apply_params_to_form_data(form_data, model) log.debug(f'form_data: {form_data}') + # Guided regeneration: extract before it reaches the LLM provider + regeneration_prompt = form_data.pop('regeneration_prompt', None) + # Load messages from DB when available — DB preserves structured 'output' items # which the frontend strips, causing tool calls to be merged into content. chat_id = metadata.get('chat_id') - parent_message_id = metadata.get('parent_message_id') + user_message_id = metadata.get('user_message_id') - if chat_id and parent_message_id and not chat_id.startswith('local:'): - db_messages = load_messages_from_db(chat_id, parent_message_id) + if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'): + db_messages = await load_messages_from_db(chat_id, user_message_id) if db_messages: + # Continue: frontend sends assistant_message_id when continuing + # an existing response. Load its content so the LLM sees prior output. + assistant_message_id = metadata.get('assistant_message_id') + if assistant_message_id: + assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id) + if assistant_message and (assistant_message.get('content') or assistant_message.get('output')): + db_messages.append( + {k: v for k, v in assistant_message.items() if k in ('role', 'content', 'output', 'files')} + ) + system_message = get_system_message(form_data.get('messages', [])) form_data['messages'] = [system_message, *db_messages] if system_message else db_messages @@ -2095,13 +2336,19 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Strip files field — it's been incorporated into content message.pop('files', None) + if regeneration_prompt: + form_data['messages'].append({'role': 'user', 'content': regeneration_prompt}) + # Process messages with OR-aligned output items for clean LLM messages - form_data['messages'] = process_messages_with_output(form_data.get('messages', [])) + form_data['messages'] = process_messages_with_output( + form_data.get('messages', []), + reasoning_format=get_reasoning_format(model), + ) system_message = get_system_message(form_data.get('messages', [])) if system_message: # Chat Controls/User Settings try: - form_data = apply_system_prompt_to_body( + form_data = await apply_system_prompt_to_body( system_message.get('content'), form_data, metadata, user, replace=True ) # Required to handle system prompt variables except Exception: @@ -2109,8 +2356,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data = await convert_url_images_to_base64(form_data) - event_emitter = get_event_emitter(metadata) - event_caller = get_event_call(metadata) + event_emitter = await get_event_emitter(metadata) + event_caller = await get_event_call(metadata) extra_params = { '__event_emitter__': event_emitter, @@ -2146,24 +2393,32 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Check if the request has chat_id and is inside of a folder # Uses lightweight column query — only fetches folder_id, not the full chat JSON blob chat_id = metadata.get('chat_id', None) + folder_id = None if chat_id and user: - folder_id = Chats.get_chat_folder_id(chat_id, user.id) - if folder_id: - folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) - - if folder and folder.data: - if 'system_prompt' in folder.data: - form_data = apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) - if 'files' in folder.data: - if metadata.get('params', {}).get('function_calling') != 'native': - form_data['files'] = [ - *folder.data['files'], - *form_data.get('files', []), - ] - else: - # Native FC: skip RAG injection, builtin tools - # will read folder knowledge from metadata. - metadata['folder_knowledge'] = folder.data['files'] + folder_id = await Chats.get_chat_folder_id(chat_id, user.id) + + # Fallback: use folder_id from metadata (temporary chats have no DB record) + if not folder_id: + folder_id = metadata.get('folder_id', None) + + if folder_id and user: + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) + + if folder and folder.data: + if 'system_prompt' in folder.data: + form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) + if 'files' in folder.data: + # Defensive: filter to entries the caller can still read. + allowed_files = await get_accessible_folder_files(folder.data['files'], user) + if metadata.get('params', {}).get('function_calling') != 'native': + form_data['files'] = [ + *allowed_files, + *form_data.get('files', []), + ] + else: + # Native FC: skip RAG injection, builtin tools + # will read folder knowledge from metadata. + metadata['folder_knowledge'] = allowed_files # Model "Knowledge" handling user_message = get_last_user_message(form_data['messages']) @@ -2208,6 +2463,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['files'] = files variables = form_data.pop('variables', None) + payload_tools = form_data.get('tools', None) # snapshot before filters # Process the form_data through the pipeline try: @@ -2216,8 +2472,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): raise e try: - filter_ids = get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) - filter_functions = Functions.get_functions_by_ids(filter_ids) + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) form_data, flags = await process_filter_functions( request=request, @@ -2233,8 +2489,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): extra_params['__features__'] = features if features: if 'voice' in features and features['voice']: - if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None: - if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != '': + if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True): + if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE: template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE else: template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE @@ -2281,23 +2537,31 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) else: # Native FC: tool docstring can't be dynamic, so inject - # filesystem context into messages for pyodide engine + # filesystem context into the system message for pyodide + # engine. Appending to the system prompt (instead of the + # user message) keeps it in the stable cached prefix so + # providers with prefix caching don't re-bill the full + # conversation on every turn. if engine != 'jupyter': - form_data['messages'] = add_or_update_user_message( + form_data['messages'] = add_or_update_system_message( CODE_INTERPRETER_PYODIDE_PROMPT, form_data['messages'], + append=True, ) tool_ids = form_data.pop('tool_ids', None) terminal_id = form_data.pop('terminal_id', None) files = form_data.pop('files', None) + form_data.pop('folder_id', None) - # Caller-provided OpenAI-style tools take precedence over server-side - # tool resolution (tool_ids, MCP servers, builtin tools). - payload_tools = form_data.get('tools', None) + # If the original caller provided tools, use them as-is (skip resolution). + # Otherwise, save any tools that filter inlets added for merging later. + inlet_filter_tools = None if payload_tools else form_data.get('tools', None) - # Skills + # Skills — extract IDs from message content (<$skillId|label> tags) so + # persisted chats work without relying on the frontend to send skill_ids. user_skill_ids = set(form_data.pop('skill_ids', None) or []) + user_skill_ids |= extract_skill_ids_from_messages(form_data.get('messages', [])) model_skill_ids = set(model.get('info', {}).get('meta', {}).get('skillIds', [])) all_skill_ids = user_skill_ids | model_skill_ids @@ -2305,12 +2569,13 @@ async def process_chat_payload(request, form_data, user, metadata, model): if all_skill_ids: from open_webui.models.skills import Skills as SkillsModel - accessible_skill_ids = {s.id for s in SkillsModel.get_skills_by_user_id(user.id, 'read')} - available_skills = [ - s - for sid in all_skill_ids - if sid in accessible_skill_ids and (s := SkillsModel.get_skill_by_id(sid)) and s.is_active - ] + accessible_skill_ids = {s.id for s in await SkillsModel.get_skills_by_user_id(user.id, 'read')} + available_skills = [] + for sid in all_skill_ids: + if sid in accessible_skill_ids: + s = await SkillsModel.get_skill_by_id(sid) + if s and s.is_active: + available_skills.append(s) skill_descriptions = '' for skill in available_skills: @@ -2323,7 +2588,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) else: # Model-attached: name+description only - skill_descriptions += f'\n{skill.name}\n{skill.description or ""}\n\n' + skill_descriptions += f'\n{skill.id}\n{skill.name}\n{skill.description or ""}\n\n' if skill_descriptions: form_data['messages'] = add_or_update_system_message( @@ -2332,6 +2597,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): append=True, ) + # Strip <$skillId|label> mention tags so the model doesn't see raw markup. + strip_skill_mentions(form_data.get('messages', [])) + prompt = get_last_user_message(form_data['messages']) # TODO: re-enable URL extraction from prompt # urls = [] @@ -2347,10 +2615,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Get folder files folder_id = file_item.get('id', None) if folder_id: - folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) + folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id) if folder and folder.data and 'files' in folder.data: files = [f for f in files if f.get('id', None) != folder_id] - files = [*files, *folder.data['files']] + files = [*files, *await get_accessible_folder_files(folder.data['files'], user)] # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]] # Remove duplicate files based on their content @@ -2358,6 +2626,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata = { **metadata, + 'model_id': form_data.get('model'), 'tool_ids': tool_ids, 'terminal_id': terminal_id, 'files': files, @@ -2401,7 +2670,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): continue # Check access control for MCP server - if not has_connection_access(user, mcp_server_connection): + if not await has_connection_access(user, mcp_server_connection): log.warning(f'Access denied to MCP server {server_id} for user {user.id}') continue @@ -2418,7 +2687,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' - elif auth_type == 'oauth_2.1': + elif auth_type in ('oauth_2.1', 'oauth_2.1_static'): try: splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id @@ -2462,7 +2731,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): tool_specs = await mcp_clients[server_id].list_tool_specs() for tool_spec in tool_specs: - def make_tool_function(client, function_name): + async def make_tool_function(client, function_name): async def tool_function(**kwargs): return await client.call_tool( function_name, @@ -2476,7 +2745,7 @@ async def tool_function(**kwargs): # Skip this function continue - tool_function = make_tool_function(mcp_clients[server_id], tool_spec['name']) + tool_function = await make_tool_function(mcp_clients[server_id], tool_spec['name']) mcp_tools_dict[f'{server_id}_{tool_spec["name"]}'] = { 'spec': { @@ -2516,21 +2785,41 @@ async def tool_function(**kwargs): # Resolve terminal tools if terminal_id is set (outside tool_ids check # so system terminals work even when no other tools are selected) - if terminal_id: + terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True) + if terminal_id and terminal_capability: try: - terminal_tools = await get_terminal_tools( + terminal_result = await get_terminal_tools( request, terminal_id, user, extra_params, ) + if isinstance(terminal_result, tuple): + terminal_tools, system_prompt = terminal_result + else: + terminal_tools = terminal_result + system_prompt = None if terminal_tools: tools_dict = {**tools_dict, **terminal_tools} + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) except Exception as e: log.exception(e) if direct_tool_servers: for tool_server in direct_tool_servers: + system_prompt = tool_server.pop('system_prompt', None) + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) + tool_specs = tool_server.pop('specs', []) for tool in tool_specs: @@ -2551,8 +2840,8 @@ async def tool_function(**kwargs): if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled: # Add file context to user messages chat_id = metadata.get('chat_id') - form_data['messages'] = add_file_context(form_data.get('messages', []), chat_id, user) - builtin_tools = get_builtin_tools( + form_data['messages'] = await add_file_context(form_data.get('messages', []), chat_id, user) + builtin_tools = await get_builtin_tools( request, { **extra_params, @@ -2567,12 +2856,17 @@ async def tool_function(**kwargs): tools_dict[name] = tool_dict if tools_dict: + # Always store resolved tools in metadata so downstream consumers + # (e.g. pipe functions) can access all tools including MCP and builtins. + metadata['tools'] = tools_dict + if metadata.get('params', {}).get('function_calling') == 'native': # If the function calling is native, then call the tools function calling handler - metadata['tools'] = tools_dict form_data['tools'] = [ {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() ] + if inlet_filter_tools: + form_data['tools'].extend(inlet_filter_tools) else: # If the function calling is not native, then call the tools function calling handler try: @@ -2603,7 +2897,7 @@ async def tool_function(**kwargs): # If context is not empty, insert it into the messages if sources and prompt: - form_data['messages'] = apply_source_context_to_messages(request, form_data['messages'], sources, prompt) + form_data['messages'] = await apply_source_context_to_messages(request, form_data['messages'], sources, prompt) # If there are citations, add them to the data_items sources = [ @@ -2628,27 +2922,37 @@ async def tool_function(**kwargs): } ) + # Strip empty text content blocks from multimodal messages + # to prevent errors from providers like Gemini and Claude + form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', [])) + + # Merge any duplicate system messages into a single message at position 0 + # to prevent template parsing errors with strict chat templates (e.g. Qwen) + form_data['messages'] = merge_system_messages(form_data.get('messages', [])) + return form_data, metadata, events -def get_event_emitter_and_caller(metadata): +async def get_event_emitter_and_caller(metadata): event_emitter = None event_caller = None - if ( - 'session_id' in metadata - and metadata['session_id'] - and 'chat_id' in metadata - and metadata['chat_id'] - and 'message_id' in metadata - and metadata['message_id'] - ): - event_emitter = get_event_emitter(metadata) - event_caller = get_event_call(metadata) + + # event_emitter only needs user_id + chat_id + message_id. + # It broadcasts to user:{user_id} room AND persists to DB, + # so it works for backend-initiated calls (automations, API). + if metadata.get('chat_id') and metadata.get('message_id'): + event_emitter = await get_event_emitter(metadata) + + # event_caller needs session_id — it calls back to a specific + # websocket session (used by direct tools, pyodide code interpreter). + if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): + event_caller = await get_event_call(metadata) + return event_emitter, event_caller -def build_chat_response_context(request, form_data, user, model, metadata, tasks, events): - event_emitter, event_caller = get_event_emitter_and_caller(metadata) +async def build_chat_response_context(request, form_data, user, model, metadata, tasks, events): + event_emitter, event_caller = await get_event_emitter_and_caller(metadata) return { 'request': request, 'form_data': form_data, @@ -2712,13 +3016,32 @@ def build_response_object(response, response_data): async def get_system_oauth_token(request, user): + """Get the system OAuth token for a user. + + Primary path: use the oauth_session_id cookie (browser requests). + Fallback: look up the user's most recent OAuth session from the DB + (covers automations, API calls, and other cookie-less contexts). + """ oauth_token = None try: - if request.cookies.get('oauth_session_id', None): + oauth_session_id = request.cookies.get('oauth_session_id', None) + if oauth_session_id: oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get('oauth_session_id', None), + oauth_session_id, ) + + # Fallback: no cookie (automation, API key, etc.) — use most recent session + if oauth_token is None: + from open_webui.models.oauth_sessions import OAuthSessions + + sessions = await OAuthSessions.get_sessions_by_user_id(user.id) + if sessions: + best = max(sessions, key=lambda s: s.updated_at) + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + best.id, + ) except Exception as e: log.error(f'Error getting OAuth token: {e}') return oauth_token @@ -2735,8 +3058,12 @@ async def background_tasks_handler(ctx): message = None messages = [] - if 'chat_id' in metadata and not metadata['chat_id'].startswith('local:'): - messages_map = Chats.get_messages_map_by_chat_id(metadata['chat_id']) + if ( + 'chat_id' in metadata + and not metadata['chat_id'].startswith('local:') + and not metadata['chat_id'].startswith('channel:') + ): + messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id']) message = messages_map.get(metadata['message_id']) if messages_map else None message_list = get_message_list(messages_map, metadata['message_id']) @@ -2815,8 +3142,10 @@ async def background_tasks_handler(ctx): } ) - if not metadata.get('chat_id', '').startswith('local:'): - Chats.upsert_message_to_chat_by_id_and_message_id( + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get( + 'chat_id', '' + ).startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { @@ -2827,7 +3156,9 @@ async def background_tasks_handler(ctx): except Exception as e: pass - if not metadata.get('chat_id', '').startswith('local:'): # Only update titles and tags for non-temp chats + if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith( + 'channel:' + ): # Only update titles and tags for non-temp chats if TASKS.TITLE_GENERATION in tasks: user_message = get_last_user_message(messages) if user_message and len(user_message) > 100: @@ -2869,7 +3200,7 @@ async def background_tasks_handler(ctx): if not title: title = messages[0].get('content', user_message) - Chats.update_chat_title_by_id(metadata['chat_id'], title) + await Chats.update_chat_title_by_id(metadata['chat_id'], title) await event_emitter( { @@ -2878,10 +3209,10 @@ async def background_tasks_handler(ctx): } ) - if title == None and len(messages) == 2: + if title == None and len(messages) == 2 and (not messages_map or len(messages_map) <= 2): title = messages[0].get('content', user_message) - Chats.update_chat_title_by_id(metadata['chat_id'], title) + await Chats.update_chat_title_by_id(metadata['chat_id'], title) await event_emitter( { @@ -2915,7 +3246,7 @@ async def background_tasks_handler(ctx): try: tags = json.loads(tags_string).get('tags', []) - Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user) + await Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user) await event_emitter( { @@ -2927,6 +3258,147 @@ async def background_tasks_handler(ctx): pass +async def outlet_filter_handler(ctx): + """Run outlet filters inline after chat completion. + + Replaces the separate POST /api/chat/completed round-trip. + Persists outlet-modified content to DB and emits a chat:outlet event + so the frontend can sync its in-memory state. + + For temp chats (local: prefix), messages are built from form_data + plus the assistant response message stored in ctx['assistant_message'], + since temp chats have no DB-persisted history. + """ + request = ctx['request'] + user = ctx['user'] + model = ctx['model'] + metadata = ctx['metadata'] + event_emitter = ctx.get('event_emitter') + event_caller = ctx.get('event_caller') + + chat_id = metadata.get('chat_id', '') + message_id = metadata.get('message_id') + + if not chat_id or not message_id: + return + + is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:') + + try: + messages_map = None + + if is_temp_chat: + # Temp chats have no DB record — build message list from + # the in-memory form_data plus the assistant response. + form_messages = ctx.get('form_data', {}).get('messages', []) + assistant_message = ctx.get('assistant_message', {}) + + message_list = [ + { + 'role': m.get('role'), + 'content': m.get('content', ''), + } + for m in form_messages + ] + + # Append the full assistant message (content, output, usage, etc.) + if assistant_message: + message_list.append( + { + 'id': message_id, + 'role': 'assistant', + **assistant_message, + } + ) + else: + messages_map = await Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return + + message_list = get_message_list(messages_map, message_id) + if not message_list: + return + + model_id = model.get('id') if isinstance(model, dict) else model + + outlet_data = { + 'model': model_id, + 'messages': [ + { + 'id': m.get('id'), + 'role': m.get('role'), + 'content': m.get('content', ''), + 'info': m.get('info'), + 'timestamp': m.get('timestamp'), + **({'output': m['output']} if m.get('output') else {}), + **({'usage': m['usage']} if m.get('usage') else {}), + **({'sources': m['sources']} if m.get('sources') else {}), + } + for m in message_list + ], + 'filter_ids': metadata.get('filter_ids', []), + 'chat_id': chat_id, + 'session_id': metadata.get('session_id'), + 'id': message_id, + } + + # Pipeline outlet filters + models = request.app.state.MODELS + try: + outlet_data = await process_pipeline_outlet_filter(request, outlet_data, user, models) + except Exception as e: + log.debug(f'Pipeline outlet filter error: {e}') + + # Function outlet filters + extra_params = { + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__request__': request, + '__model__': model, + } + + filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + filter_functions = await Functions.get_functions_by_ids(filter_ids) + + outlet_result, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type='outlet', + form_data=outlet_data, + extra_params=extra_params, + ) + + # Persist outlet-modified content and notify frontend + # (skip DB persistence for temp chats — they have no DB record) + if outlet_result and outlet_result.get('messages'): + if not is_temp_chat and messages_map: + for message in outlet_result['messages']: + outlet_message_id = message.get('id') + if outlet_message_id and outlet_message_id in messages_map: + original_message = messages_map[outlet_message_id] + if original_message.get('content') != message.get('content'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + chat_id, + outlet_message_id, + { + 'content': message['content'], + 'originalContent': original_message.get('content'), + }, + ) + + if event_emitter: + await event_emitter( + { + 'type': 'chat:outlet', + 'data': {'messages': outlet_result['messages']}, + } + ) + except Exception as e: + log.debug(f'Error running outlet filters: {e}') + + async def non_streaming_chat_response_handler(response, ctx): request = ctx['request'] @@ -2950,13 +3422,16 @@ async def non_streaming_chat_response_handler(response, ctx): else: error = str(error) - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'error': {'content': error}, - }, - ) + log.error('Provider returned error (non-streaming): %s', error) + + if not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) if isinstance(error, str) or isinstance(error, dict): await event_emitter( { @@ -2965,8 +3440,8 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - if 'selected_model_id' in response_data: - Chats.upsert_message_to_chat_by_id_and_message_id( + if 'selected_model_id' in response_data and not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { @@ -2986,7 +3461,11 @@ async def non_streaming_chat_response_handler(response, ctx): } ) - title = Chats.get_chat_title_by_id(metadata['chat_id']) + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata['chat_id'].startswith('channel:') + else '' + ) # Use output from backend if provided (OR-compliant backends), # otherwise generate from response content @@ -3017,25 +3496,27 @@ async def non_streaming_chat_response_handler(response, ctx): # Save message in the database usage = normalize_usage(response_data.get('usage', {}) or {}) - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'role': 'assistant', - 'content': content, - 'output': response_output, - **({'usage': usage} if usage else {}), - }, - ) + if not metadata['chat_id'].startswith('channel:'): + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'role': 'assistant', + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + }, + ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): - webhook_url = Users.get_user_webhook_url_by_id(user.id) + if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + webhook_url = await Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, @@ -3045,6 +3526,12 @@ async def non_streaming_chat_response_handler(response, ctx): ) await background_tasks_handler(ctx) + ctx['assistant_message'] = { + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) response = build_response_object(response, merge_events_into_response(response_data, events)) except Exception as e: @@ -3084,12 +3571,14 @@ async def streaming_chat_response_handler(response, ctx): } filter_functions = [ - Functions.get_function_by_id(filter_id) - for filter_id in get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) + await Functions.get_function_by_id(filter_id) + for filter_id in await get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) ] # Standard streaming response handler - if event_emitter and event_caller: + # event_caller is optional — only needed for direct (client-side) tools + # and pyodide code interpreter. Server-side tools work without it. + if event_emitter: task_id = str(uuid4()) # Create a unique task ID. model_id = form_data.get('model', '') @@ -3318,7 +3807,7 @@ def set_last_text(out, text): return output, end_flag - message = Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) + message = await Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) tool_calls = [] @@ -3353,6 +3842,11 @@ def set_last_text(out, text): output = [] usage = None + prior_output = [] + last_response_id = None + + def full_output(): + return prior_output + output if prior_output else output reasoning_tags_param = metadata.get('params', {}).get('reasoning_tags') DETECT_REASONING_TAGS = reasoning_tags_param is not False @@ -3375,7 +3869,7 @@ def set_last_text(out, text): ) # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { @@ -3387,6 +3881,8 @@ async def stream_body_handler(response, form_data): nonlocal content nonlocal usage nonlocal output + nonlocal prior_output + nonlocal last_response_id response_tool_calls = [] @@ -3443,7 +3939,7 @@ async def flush_pending_delta_data(threshold: int = 0): if 'selected_model_id' in data: model_id = data['selected_model_id'] - Chats.upsert_message_to_chat_by_id_and_message_id( + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { @@ -3460,17 +3956,66 @@ async def flush_pending_delta_data(threshold: int = 0): elif data.get('type', '').startswith('response.'): output, response_metadata = handle_responses_streaming_event(data, output) + # Emit citation sources from finalized output items + # (mirrors Chat Completions annotation handling at delta level) + if data.get('type') == 'response.output_item.done': + item = data.get('item', {}) + if item.get('type') == 'message': + for part in item.get('content', []): + for annotation in part.get('annotations', []): + if annotation.get('type') == 'url_citation': + # Handle both flat (Responses API) and nested (Chat Completions) formats + url_citation = annotation.get('url_citation', annotation) + + url = url_citation.get('url', '') + title = url_citation.get('title', url) + + if url: + await event_emitter( + { + 'type': 'source', + 'data': { + 'source': { + 'name': title, + 'url': url, + }, + 'document': [title], + 'metadata': [ + { + 'source': url, + 'name': title, + } + ], + }, + } + ) + processed_data = { - 'output': output, - 'content': serialize_output(output), + 'output': full_output(), + 'content': serialize_output(full_output()), } # print(data) # print(processed_data) - # Merge any metadata (usage, done, etc.) + # Merge any metadata (usage, etc.) + # Strip 'done' — response.completed emits + # it but we may still need to execute tool + # calls. The outer middleware manages the + # actual completion signal. if response_metadata: + if ENABLE_RESPONSES_API_STATEFUL: + response_id = response_metadata.pop('response_id', None) + if response_id: + last_response_id = response_id + + # Normalize and capture usage for DB persistence + if response_metadata.get('usage'): + response_metadata['usage'] = normalize_usage(response_metadata['usage']) + usage = response_metadata['usage'] + processed_data.update(response_metadata) + processed_data.pop('done', None) await event_emitter( { @@ -3499,6 +4044,17 @@ async def flush_pending_delta_data(threshold: int = 0): if not choices: error = data.get('error', {}) if error: + log.error('Provider returned error (streaming): %s', error) + try: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'error': {'content': error}, + }, + ) + except Exception: + pass await event_emitter( { 'type': 'chat:completion', @@ -3597,20 +4153,20 @@ async def flush_pending_delta_data(threshold: int = 0): 'status': 'in_progress', } ) - pending_output = output + pending_fc_items + await event_emitter( { 'type': 'chat:completion', 'data': { - 'content': serialize_output(pending_output), + 'content': serialize_output(full_output() + pending_fc_items), }, } ) - image_urls = get_image_urls(delta.get('images', []), request, metadata, user) + image_urls = await get_image_urls(delta.get('images', []), request, metadata, user) if image_urls: image_file_list = [{'type': 'image', 'url': url} for url in image_urls] - message_files = Chats.add_message_files_by_id_and_message_id( + message_files = await Chats.add_message_files_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], image_file_list, @@ -3661,7 +4217,7 @@ async def flush_pending_delta_data(threshold: int = 0): } ] - data = {'content': serialize_output(output)} + data = {'content': serialize_output(full_output())} if value: if ( @@ -3692,7 +4248,7 @@ async def flush_pending_delta_data(threshold: int = 0): ) if ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION: - value = convert_markdown_base64_images( + value = await convert_markdown_base64_images( request, value, { @@ -3806,19 +4362,19 @@ async def flush_pending_delta_data(threshold: int = 0): if end: break - if ENABLE_REALTIME_CHAT_SAVE: + if ENABLE_REALTIME_CHAT_SAVE and not metadata['chat_id'].startswith('channel:'): # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( + await Chats.upsert_message_to_chat_by_id_and_message_id( metadata['chat_id'], metadata['message_id'], { - 'content': serialize_output(output), - 'output': output, + 'content': serialize_output(full_output()), + 'output': full_output(), }, ) else: data = { - 'content': serialize_output(output), + 'content': serialize_output(full_output()), } if delta: @@ -3833,6 +4389,8 @@ async def flush_pending_delta_data(threshold: int = 0): 'data': data, } ) + except (asyncio.CancelledError, KeyboardInterrupt): + raise except Exception as e: done = 'data: [DONE]' in line if done: @@ -3875,11 +4433,41 @@ async def flush_pending_delta_data(threshold: int = 0): if response_tool_calls: tool_calls.append(_split_tool_calls(response_tool_calls)) + # Responses API path: extract function_call items from output + if not response_tool_calls and output: + # Collect call_ids that already have results, + # including those from prior_output so we don't + # re-process tool calls from a previous turn. + handled_call_ids = { + item.get('call_id') + for item in (prior_output + output) + if item.get('type') == 'function_call_output' + } + responses_api_tool_calls = [] + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids: + arguments = item.get('arguments', '{}') + responses_api_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'index': len(responses_api_tool_calls), + 'function': { + 'name': item.get('name', ''), + 'arguments': ( + arguments if isinstance(arguments, str) else json.dumps(arguments) + ), + }, + } + ) + if responses_api_tool_calls: + tool_calls.append(_split_tool_calls(responses_api_tool_calls)) + + try: + await stream_body_handler(response, form_data) + finally: if response.background: await response.background() - await stream_body_handler(response, form_data) - tool_call_retries = 0 tool_call_sources = [] # Track citation sources from tool results all_tool_call_sources = [] # Accumulated sources across all iterations @@ -3906,26 +4494,29 @@ async def flush_pending_delta_data(threshold: int = 0): response_tool_calls = tool_calls.pop(0) # Append function_call items for each tool call + # (Responses API already has them from streaming, so skip duplicates) + existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'} for tc in response_tool_calls: call_id = tc.get('id', '') - func = tc.get('function', {}) - output.append( - { - 'type': 'function_call', - 'id': call_id or output_id('fc'), - 'call_id': call_id, - 'name': func.get('name', ''), - 'arguments': func.get('arguments', '{}'), - 'status': 'in_progress', - } - ) + if call_id not in existing_call_ids: + func = tc.get('function', {}) + output.append( + { + 'type': 'function_call', + 'id': call_id or output_id('fc'), + 'call_id': call_id, + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + 'status': 'in_progress', + } + ) await event_emitter( { 'type': 'chat:completion', 'data': { - 'content': serialize_output(output), - 'output': output, + 'content': serialize_output(full_output()), + 'output': full_output(), }, } ) @@ -3997,7 +4588,7 @@ async def flush_pending_delta_data(threshold: int = 0): ) else: - tool_function = get_updated_tool_function( + tool_function = await get_updated_tool_function( function=tool['callable'], extra_params={ '__messages__': form_data.get('messages', []), @@ -4010,7 +4601,7 @@ async def flush_pending_delta_data(threshold: int = 0): except Exception as e: tool_result = str(e) - tool_result, tool_result_files, tool_result_embeds = process_tool_result( + tool_result, tool_result_files, tool_result_embeds = await process_tool_result( request, tool_function_name, tool_result, @@ -4034,6 +4625,7 @@ async def flush_pending_delta_data(threshold: int = 0): in [ 'search_web', 'fetch_url', + 'view_file', 'view_knowledge_file', 'query_knowledge_files', ] @@ -4071,19 +4663,27 @@ async def flush_pending_delta_data(threshold: int = 0): break for result in results: + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + + # Separate image data URIs (for LLM via input_image) from + # other files (for frontend display via files attribute). + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + # LLM-only: add as input_image part (invisible to serialize_output) + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + # Frontend display (MCP images, audio, etc.) + display_files.append(file_item) + output.append( { 'type': 'function_call_output', 'id': output_id('fco'), 'call_id': result.get('tool_call_id', ''), - 'output': [ - { - 'type': 'input_text', - 'text': result.get('content', ''), - } - ], + 'output': output_parts, 'status': 'completed', - **({'files': result.get('files')} if result.get('files') else {}), + **({'files': display_files} if display_files else {}), **({'embeds': result.get('embeds')} if result.get('embeds') else {}), } ) @@ -4133,7 +4733,7 @@ async def flush_pending_delta_data(threshold: int = 0): ) source_context = source_context.strip() if source_context: - rag_content = rag_template( + rag_content = await rag_template( request.app.state.config.RAG_TEMPLATE, source_context, user_message, @@ -4152,12 +4752,23 @@ async def flush_pending_delta_data(threshold: int = 0): ) tool_call_sources.clear() + # Strip input_image parts (large base64 data URIs) from the + # output sent to the frontend — they're only for LLM consumption + # via convert_output_to_messages. + frontend_output = [] + for item in output: + if item.get('type') == 'function_call_output': + parts = item.get('output', []) + if any(p.get('type') == 'input_image' for p in parts): + item = {**item, 'output': [p for p in parts if p.get('type') != 'input_image']} + frontend_output.append(item) + await event_emitter( { 'type': 'chat:completion', 'data': { 'content': serialize_output(output), - 'output': output, + 'output': frontend_output, }, } ) @@ -4167,12 +4778,54 @@ async def flush_pending_delta_data(threshold: int = 0): **form_data, 'model': model_id, 'stream': True, - 'messages': [ - *form_data['messages'], - *convert_output_to_messages(output, raw=True), - ], + 'metadata': metadata, } + if ENABLE_RESPONSES_API_STATEFUL and last_response_id: + system_message = get_system_message(form_data['messages']) + new_form_data['messages'] = ( + [system_message] if system_message else [] + ) + convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ) + new_form_data['previous_response_id'] = last_response_id + else: + tool_messages = convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ) + + # Chat Completions providers don't support multimodal + # tool messages. Extract images into a user message. + image_urls = [] + for message in tool_messages: + if message.get('role') == 'tool' and isinstance(message.get('content'), list): + text_parts = [] + for part in message['content']: + if part.get('type') == 'input_text': + text_parts.append(part.get('text', '')) + elif part.get('type') == 'input_image': + image_urls.append(part.get('image_url', '')) + message['content'] = ''.join(text_parts) + + new_form_data['messages'] = [ + *form_data['messages'], + *tool_messages, + ] + + if image_urls: + new_form_data['messages'].append( + { + 'role': 'user', + 'content': [ + { + 'type': 'text', + 'text': 'Here are the images from the tool results above. Please analyze them.', + }, + *[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls], + ], + } + ) + res = await generate_chat_completion( request, new_form_data, @@ -4181,7 +4834,28 @@ async def flush_pending_delta_data(threshold: int = 0): ) if isinstance(res, StreamingResponse): + # Save accumulated output and start fresh. + # Responses API output_index values are relative + # to the current response — a clean output list + # keeps indices aligned. The display prefix + # ensures the UI shows tool history during + # streaming. + prior_output = list(output) + # Trim the trailing empty placeholder message + # so it doesn't persist as a ghost item once + # the new stream produces real content. + if ( + prior_output + and prior_output[-1].get('type') == 'message' + and prior_output[-1].get('status') == 'in_progress' + ): + msg_parts = prior_output[-1].get('content', []) + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): + prior_output.pop() + output = [] await stream_body_handler(res, new_form_data) + output[:0] = prior_output + prior_output = [] else: break except Exception as e: @@ -4221,7 +4895,7 @@ async def flush_pending_delta_data(threshold: int = 0): BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} _real_import = builtins.__import__ - def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): + async def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): if name.split('.')[0] in BLOCKED_MODULES: importer_name = globals.get('__name__') if globals else None if importer_name == '__main__': @@ -4267,14 +4941,19 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): log.debug(f'Code interpreter output: {ci_output}') + # Handle error responses from event_caller + # (e.g. session disconnected, timeout) + if isinstance(ci_output, dict) and ci_output.get('error'): + ci_output = {'stderr': ci_output['error']} + if isinstance(ci_output, dict): stdout = ci_output.get('stdout', '') if isinstance(stdout, str): stdoutLines = stdout.split('\n') for idx, line in enumerate(stdoutLines): - if 'data:image/png;base64' in line: - image_url = get_image_url_from_base64( + if re.match(r'data:image/\w+;base64', line): + image_url = await get_image_url_from_base64( request, line, metadata, @@ -4290,8 +4969,8 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): if isinstance(result, str): resultLines = result.split('\n') for idx, line in enumerate(resultLines): - if 'data:image/png;base64' in line: - image_url = get_image_url_from_base64( + if re.match(r'data:image/\w+;base64', line): + image_url = await get_image_url_from_base64( request, line, metadata, @@ -4330,9 +5009,12 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): **form_data, 'model': model_id, 'stream': True, + 'metadata': metadata, 'messages': [ *form_data['messages'], - *convert_output_to_messages(output, raw=True), + *convert_output_to_messages( + output, raw=True, reasoning_format=get_reasoning_format(model) + ), ], } @@ -4356,40 +5038,53 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): if item.get('status') == 'in_progress': item['status'] = 'completed' - title = Chats.get_chat_title_by_id(metadata['chat_id']) + title = ( + await Chats.get_chat_title_by_id(metadata['chat_id']) + if not metadata['chat_id'].startswith('channel:') + else '' + ) data = { 'done': True, 'content': serialize_output(output), 'output': output, 'title': title, + **({'usage': usage} if usage else {}), } - if not ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'content': serialize_output(output), - 'output': output, - **({'usage': usage} if usage else {}), - }, - ) - elif usage: - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - {'usage': usage}, - ) + if not metadata['chat_id'].startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + }, + ) + elif usage: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True, 'usage': usage}, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): - webhook_url = Users.get_user_webhook_url_by_id(user.id) + if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id): + webhook_url = await Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', + f'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', { 'action': 'chat', 'message': content, @@ -4406,20 +5101,50 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): ) await background_tasks_handler(ctx) + ctx['assistant_message'] = { + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), + } + await outlet_filter_handler(ctx) except asyncio.CancelledError: log.warning('Task was cancelled!') - await event_emitter({'type': 'chat:tasks:cancel'}) - if not ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata['chat_id'], - metadata['message_id'], - { - 'content': serialize_output(output), - 'output': output, - }, - ) + # Close the response body iterator to trigger cleanup + # in stream_wrapper's finally block and release the + # upstream connection. Without this, the async + # generator is orphaned and may spin in anyio internals. + if hasattr(response, 'body_iterator') and hasattr(response.body_iterator, 'aclose'): + try: + await asyncio.shield(response.body_iterator.aclose()) + except (asyncio.CancelledError, Exception): + pass + + async def save_cancelled_state(): + await event_emitter({'type': 'chat:tasks:cancel'}) + if not metadata['chat_id'].startswith('channel:'): + if not ENABLE_REALTIME_CHAT_SAVE: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + { + 'done': True, + 'content': serialize_output(output), + 'output': output, + }, + ) + else: + await Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) + + try: + await asyncio.shield(save_cancelled_state()) + except (asyncio.CancelledError, Exception): + pass + raise # re-raise CancelledError for proper propagation if response.background is not None: await response.background() diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index dec97abd25d..b6df292890e 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -129,7 +129,11 @@ def get_content_from_message(message: dict) -> Optional[str]: return None -def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: +def convert_output_to_messages( + output: list, + raw: bool = False, + reasoning_format: str | None = None, +) -> list[dict]: """ Convert OR-aligned output items to OpenAI Chat Completion-format messages. @@ -139,8 +143,14 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: Args: output: List of OR-aligned output items (Responses API format). - raw: If True, include reasoning blocks (with original tags) and code - interpreter blocks for LLM re-processing follow-ups. + raw: If True, include code interpreter blocks for LLM re-processing + follow-ups. + reasoning_format: How to include reasoning blocks in the output: + - None: skip reasoning (default, safe for strict providers). + - ``'think_tags'``: wrap in ```` tags inside content + (for Ollama, which expects reasoning as tagged content). + - ``'reasoning_content'``: set as ``reasoning_content`` top-level field + (for llama.cpp, which routes it via the chat template). """ if not output or not isinstance(output, list): return [] @@ -148,19 +158,26 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: messages = [] pending_tool_calls = [] pending_content = [] + pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content' def flush_pending(): - nonlocal pending_content, pending_tool_calls - if pending_content or pending_tool_calls: - messages.append( - { - 'role': 'assistant', - 'content': '\n'.join(pending_content) if pending_content else '', - **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), - } - ) - pending_content = [] - pending_tool_calls = [] + nonlocal pending_content, pending_tool_calls, pending_reasoning + if not pending_content and not pending_tool_calls and not pending_reasoning: + return + + message = { + 'role': 'assistant', + 'content': '\n'.join(pending_content) if pending_content else '', + **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), + } + + if pending_reasoning: + message['reasoning_content'] = '\n'.join(pending_reasoning) + + messages.append(message) + pending_content = [] + pending_tool_calls = [] + pending_reasoning = [] for item in output: item_type = item.get('type', '') @@ -196,38 +213,61 @@ def flush_pending(): # Flush any pending content/tool_calls before adding tool result flush_pending() - # Extract text from output content parts + # Extract text and images from output content parts output_parts = item.get('output', []) content = '' + image_urls = [] for part in output_parts: if part.get('type') == 'input_text': output_text = part.get('text', '') content += str(output_text) if not isinstance(output_text, str) else output_text - - messages.append( - { - 'role': 'tool', - 'tool_call_id': item.get('call_id', ''), - 'content': content, - } - ) + elif part.get('type') == 'input_image': + url = part.get('image_url', '') + if url: + image_urls.append(url) + + if image_urls: + # Multimodal tool content with image(s) + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': [ + {'type': 'input_text', 'text': content}, + *[{'type': 'input_image', 'image_url': url} for url in image_urls], + ], + } + ) + else: + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': content, + } + ) elif item_type == 'reasoning': - if raw: - # Include reasoning with original tags for LLM re-processing - reasoning_text = '' - source_list = item.get('summary', []) or item.get('content', []) - for part in source_list: - if part.get('type') == 'output_text': - reasoning_text += part.get('text', '') - elif 'text' in part: - reasoning_text += part.get('text', '') - - if reasoning_text: + if not reasoning_format: + continue + + reasoning_text = '' + source_list = item.get('summary', []) or item.get('content', []) + for part in source_list: + if part.get('type') == 'output_text': + reasoning_text += part.get('text', '') + elif 'text' in part: + reasoning_text += part.get('text', '') + + if reasoning_text: + if reasoning_format == 'think_tags': + # Ollama: embed in content with the item's original tags start_tag = item.get('start_tag', '') end_tag = item.get('end_tag', '') pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') - # else: skip reasoning blocks for normal LLM messages + elif reasoning_format == 'reasoning_content': + # llama.cpp: collect for reasoning_content field + pending_reasoning.append(reasoning_text) elif item_type == 'open_webui:code_interpreter': # Always include code interpreter content so the LLM knows @@ -312,6 +352,33 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict] return get_system_message(messages), remove_system_message(messages) +def merge_system_messages(messages: list[dict]) -> list[dict]: + """ + Merge all system messages into one at position 0. + + Some chat templates (e.g. Qwen) require exactly one system + message at the start. Multiple pipeline stages may each + insert their own system message; this function consolidates + them. + """ + system_contents: list[str] = [] + other_messages: list[dict] = [] + + for message in messages: + if message.get('role') == 'system': + content = get_content_from_message(message) + if content: + system_contents.append(content) + else: + other_messages.append(message) + + if not system_contents: + return other_messages + + merged = {'role': 'system', 'content': '\n'.join(system_contents)} + return [merged, *other_messages] + + def update_message_content(message: dict, content: str, append: bool = True) -> dict: if isinstance(message['content'], list): for item in message['content']: @@ -401,6 +468,27 @@ def append_or_update_assistant_message(content: str, messages: list[dict]): return messages +def strip_empty_content_blocks(messages: list[dict]) -> list[dict]: + """ + Remove empty text content blocks from multimodal message content arrays. + + Providers like Gemini and Claude reject messages where a text block has + an empty string. This can happen when a user sends only file/image + attachments without typing any text. + """ + for message in messages: + content = message.get('content') + if isinstance(content, list): + cleaned = [ + block + for block in content + if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip()) + ] + if cleaned: + message['content'] = cleaned + return messages + + def openai_chat_message_template(model: str): return { 'id': f'{model}-{str(uuid.uuid4())}', @@ -478,6 +566,10 @@ def get_gravatar_url(email): return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp' +# Give us each day the data we require, and forgive us our +# technical debts as we forgive those who commit upstream. +# Lead the bits not into corruption but deliver them from +# entropy, for the checksum and the glory are forever. def calculate_sha256(file_path, chunk_size): # Compute SHA-256 hash of a file efficiently in chunks sha256 = hashlib.sha256() @@ -521,6 +613,9 @@ def sanitize_text_for_db(text: str) -> str: """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" if not isinstance(text, str): return text + # Fast path: skip work when there are no null bytes (the common case) + if '\x00' not in text: + return text # Remove null bytes text = text.replace('\x00', '').replace('\u0000', '') # Remove invalid UTF-8 surrogate characters that can cause encoding errors @@ -532,17 +627,38 @@ def sanitize_text_for_db(text: str) -> str: return text -def sanitize_data_for_db(obj): - """Recursively sanitize all strings in a data structure for database storage.""" +def _strip_null_bytes_deep(obj): + """Inner recursive walk — only called when null bytes are known to be present.""" if isinstance(obj, str): return sanitize_text_for_db(obj) elif isinstance(obj, dict): - return {k: sanitize_data_for_db(v) for k, v in obj.items()} + return {k: _strip_null_bytes_deep(v) for k, v in obj.items()} elif isinstance(obj, list): - return [sanitize_data_for_db(v) for v in obj] + return [_strip_null_bytes_deep(v) for v in obj] return obj +def sanitize_data_for_db(obj): + """Recursively sanitize all strings in a data structure for database storage. + + Performs a fast pre-check: serializes the structure once and scans for + null bytes. If none are found (the overwhelmingly common case), the + original object is returned immediately, skipping the expensive + recursive walk. + """ + if isinstance(obj, str): + return sanitize_text_for_db(obj) + # Fast path: check for null bytes in the serialized form. + # json.dumps is implemented in C and much faster than a Python-level + # recursive walk over every leaf string. + try: + if '\x00' not in json.dumps(obj, ensure_ascii=False): + return obj + except (TypeError, ValueError): + pass + return _strip_null_bytes_deep(obj) + + def sanitize_metadata(metadata: dict) -> dict: """ Return a JSON-safe copy of a metadata dict for database storage. @@ -767,9 +883,9 @@ def decorator(func): last_calls = {} lock = threading.Lock() - def wrapper(*args, **kwargs): + async def wrapper(*args, **kwargs): if interval is None: - return func(*args, **kwargs) + return await func(*args, **kwargs) key = (args, freeze(kwargs)) now = time.time() @@ -779,7 +895,7 @@ def wrapper(*args, **kwargs): if now - last_calls.get(key, 0) < interval: return None last_calls[key] = now - return func(*args, **kwargs) + return await func(*args, **kwargs) return wrapper @@ -827,14 +943,25 @@ def extract_urls(text: str) -> list[str]: return url_pattern.findall(text) +# We believe in one architect of all that is seen and served. +# Should this stream falter, it shall be raised again on the +# third retry. We look for the uptime of the world to come. async def cleanup_response( response: Optional[aiohttp.ClientResponse], session: Optional[aiohttp.ClientSession], ): if response: - response.close() + if not response.closed: + # aiohttp 3.9+ made ClientResponse.close() synchronous (returns None). + # Older versions returned a coroutine. Handle both gracefully. + result = response.close() + if result is not None: + await result if session: - await session.close() + if not session.closed: + result = session.close() + if result is not None: + await result async def stream_wrapper(response, session, content_handler=None): @@ -888,18 +1015,15 @@ async def yield_safe_stream_chunks(): skip_mode = False yield line else: - yield b'data: {}' - yield b'\n' + yield b'data: {}\n' else: # Normal mode: check if line exceeds limit if len(line) > max_buffer_size: skip_mode = True - yield b'data: {}' - yield b'\n' + yield b'data: {}\n' log.info(f'Skip mode triggered, line size: {len(line)}') else: - yield line - yield b'\n' + yield line + b'\n' # Save the last incomplete fragment buffer = lines[-1] @@ -913,7 +1037,6 @@ async def yield_safe_stream_chunks(): # Process remaining buffer data if buffer and not skip_mode: - yield buffer - yield b'\n' + yield buffer + b'\n' return yield_safe_stream_chunks() diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index e579a3e3e78..e9201bb6215 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -22,7 +22,7 @@ load_function_module_by_id, get_function_module_from_cache, ) -from open_webui.utils.access_control import has_access +from open_webui.utils.access_control import has_access, has_base_model_access from open_webui.config import ( @@ -47,6 +47,7 @@ async def fetch_ollama_models(request: Request, user: UserModel = None): 'created': int(time.time()), 'owned_by': 'ollama', 'ollama': model, + 'loaded': 'expires_at' in model, 'connection_type': model.get('connection_type', 'local'), 'tags': model.get('tags', []), } @@ -130,13 +131,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ] models = models + arena_models - global_action_ids = [function.id for function in Functions.get_global_action_functions()] - enabled_action_ids = [function.id for function in Functions.get_functions_by_type('action', active_only=True)] + global_action_ids = {function.id for function in await Functions.get_global_action_functions()} + enabled_action_ids = {function.id for function in await Functions.get_functions_by_type('action', active_only=True)} - global_filter_ids = [function.id for function in Functions.get_global_filter_functions()] - enabled_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)] + global_filter_ids = {function.id for function in await Functions.get_global_filter_functions()} + enabled_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)} - custom_models = Models.get_all_models() + custom_models = await Models.get_all_models() # Single O(1) lookup: Ollama base names first, then exact IDs (exact wins). base_model_lookup = {} @@ -199,6 +200,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'connection_type': connection_type, 'preset': True, **({'pipe': pipe} if pipe is not None else {}), + **({'provider': base_model.get('provider')} if base_model and base_model.get('provider') else {}), + **({'loaded': base_model.get('loaded')} if base_model and base_model.get('loaded') is not None else {}), } info = custom_model.model_dump() @@ -278,16 +281,20 @@ def get_filter_items_from_module(function, module): all_function_ids.update(global_action_ids) all_function_ids.update(global_filter_ids) - functions_by_id = {f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids))} + functions_by_id = {f.id: f for f in await Functions.get_functions_by_ids(list(all_function_ids))} # Pre-warm the function module cache once per unique function ID. # This ensures each function's DB freshness check runs exactly once, # not once per (model × function) pair. - for function_id in all_function_ids: + # Only attempt to load functions that actually exist in the local DB; + # imported/custom model configs may reference tools or filters the user + # hasn't installed, and trying to load those would cause persistent + # "Failed to load function module" log spam on every model refresh. + for function_id, function in functions_by_id.items(): try: - get_function_module_from_cache(request, function_id) + await get_function_module_from_cache(request, function_id, function=function) except Exception as e: - log.info(f'Failed to load function module for {function_id}: {e}') + log.debug(f'Failed to load function module for {function_id}: {e}') # Apply global model defaults to all models # Per-model overrides take precedence over global defaults @@ -312,7 +319,7 @@ def get_filter_items_from_module(function, module): # Batch-fetch all function valves in one query to avoid N+1 DB hits # inside get_action_priority (previously called per action × per model). - all_function_valves = Functions.get_function_valves_by_ids(list(all_function_ids)) + all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids)) def get_action_priority(action_id): try: @@ -328,14 +335,14 @@ def get_action_priority(action_id): for model in models: action_ids = [ action_id - for action_id in list(set(model.pop('action_ids', []) + global_action_ids)) + for action_id in set(model.pop('action_ids', [])) | global_action_ids if action_id in enabled_action_ids ] action_ids.sort(key=lambda aid: (get_action_priority(aid), aid)) filter_ids = [ filter_id - for filter_id in list(set(model.pop('filter_ids', []) + global_filter_ids)) + for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids if filter_id in enabled_filter_ids ] @@ -377,11 +384,11 @@ def get_action_priority(action_id): return models -def check_model_access(user, model, db=None): +async def check_model_access(user, model, db=None): if model.get('arena'): meta = model.get('info', {}).get('meta', {}) access_grants = meta.get('access_grants', []) - if not has_access( + if not await has_access( user.id, permission='read', access_grants=access_grants, @@ -389,12 +396,12 @@ def check_model_access(user, model, db=None): ): raise Exception('Model not found') else: - model_info = Models.get_model_by_id(model.get('id'), db=db) + model_info = await Models.get_model_by_id(model.get('id'), db=db) if not model_info: raise Exception('Model not found') elif not ( user.id == model_info.user_id - or AccessGrants.has_access( + or await AccessGrants.has_access( user_id=user.id, resource_type='model', resource_id=model_info.id, @@ -404,8 +411,12 @@ def check_model_access(user, model, db=None): ): raise Exception('Model not found') + # Enforce access on chained base models + if not await has_base_model_access(user.id, model_info, db=db): + raise Exception('Model not found') + -def get_filtered_models(models, user, db=None): +async def get_filtered_models(models, user, db=None): # Filter out models that the user does not have access to if ( user.role == 'user' or (user.role == 'admin' and not BYPASS_ADMIN_ACCESS_CONTROL) @@ -418,10 +429,10 @@ def get_filtered_models(models, user, db=None): if info: model_infos[model['id']] = info - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls - accessible_model_ids = AccessGrants.get_accessible_resource_ids( + accessible_model_ids = await AccessGrants.get_accessible_resource_ids( user_id=user.id, resource_type='model', resource_ids=list(model_infos.keys()), @@ -435,7 +446,7 @@ def get_filtered_models(models, user, db=None): if model.get('arena'): meta = model.get('info', {}).get('meta', {}) access_grants = meta.get('access_grants', []) - if has_access( + if await has_access( user.id, permission='read', access_grants=access_grants, @@ -452,6 +463,10 @@ def get_filtered_models(models, user, db=None): or model['id'] in accessible_model_ids ): filtered_models.append(model) + elif user.role == 'admin': + # No DB entry means no access control configured yet; + # only admins can see unconfigured models. + filtered_models.append(model) return filtered_models else: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 202dd42d4a9..f518bfb223a 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1,4 +1,5 @@ import base64 +from dataclasses import dataclass, field import copy import hashlib import logging @@ -7,6 +8,7 @@ import urllib import uuid import json +from urllib.parse import quote from datetime import datetime, timedelta import re @@ -18,6 +20,7 @@ import aiohttp from authlib.integrations.starlette_client import OAuth +from authlib.jose.errors import BadSignatureError from authlib.oidc.core import UserInfo from fastapi import ( HTTPException, @@ -36,6 +39,7 @@ from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_SIGNUP, + OAUTH_CLIENT_TIMEOUT, OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, OAUTH_MERGE_ACCOUNTS_BY_EMAIL, OAUTH_PROVIDERS, @@ -60,13 +64,16 @@ OAUTH_UPDATE_EMAIL_ON_LOGIN, OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, OAUTH_AUDIENCE, + OAUTH_AUTHORIZE_PARAMS, WEBHOOK_URL, JWT_EXPIRES_IN, + GOOGLE_OAUTH_SCOPE, AppConfig, ) from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, WEBUI_NAME, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, @@ -74,11 +81,13 @@ ENABLE_OAUTH_EMAIL_FALLBACK, OAUTH_CLIENT_INFO_ENCRYPTION_KEY, OAUTH_MAX_SESSIONS_PER_USER, + REDIS_KEY_PREFIX, ) from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.webhook import post_webhook from open_webui.utils.groups import apply_default_group_assignment +from open_webui.retrieval.web.utils import validate_url from mcp.shared.auth import ( OAuthClientMetadata as MCPOAuthClientMetadata, @@ -95,6 +104,7 @@ class OAuthClientMetadata(MCPOAuthClientMetadata): class OAuthClientInformationFull(OAuthClientMetadata): issuer: Optional[str] = None # URL of the OAuth server that issued this client + resource: Optional[str] = None # RFC 8707 resource indicator for JWT audience client_id: str client_secret: str | None = None @@ -136,6 +146,41 @@ class OAuthClientInformationFull(OAuthClientMetadata): auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE +# Conservative default when the provider omits both expires_in and expires_at. +# Matches the value recommended by Authlib's compliance_fix documentation. +DEFAULT_TOKEN_EXPIRY_SECONDS = 3600 + + +def _normalize_token_expiry(token: dict) -> dict: + """Ensure a token dict always has a numeric ``expires_at``. + + Resolution order: + 1. If *expires_at* is already present and non-None, trust it. + 2. Else if *expires_in* is present and non-None, compute *expires_at*. + 3. Otherwise fall back to ``DEFAULT_TOKEN_EXPIRY_SECONDS`` and log a + warning so operators can identify providers that omit expiration. + + Also stamps *issued_at* for auditing. + """ + token['issued_at'] = datetime.now().timestamp() + + if token.get('expires_at') is not None: + token['expires_at'] = int(token['expires_at']) + return token + + if token.get('expires_in') is not None: + token['expires_at'] = int(datetime.now().timestamp() + token['expires_in']) + return token + + # Neither field present — conservative fallback + log.warning( + "OAuth token response missing both 'expires_in' and 'expires_at'; " + f'defaulting to {DEFAULT_TOKEN_EXPIRY_SECONDS}s from now' + ) + token['expires_at'] = int(datetime.now().timestamp() + DEFAULT_TOKEN_EXPIRY_SECONDS) + return token + + FERNET = None if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44: @@ -250,12 +295,34 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]: return parsed, base_url -async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: +@dataclass +class ProtectedResourceMetadata: + """RFC 9728 Protected Resource Metadata fields relevant to OAuth flows.""" + + resource: str | None = None + authorization_servers: list[str] = field(default_factory=list) + + def get_discovery_urls(self, server_url: str) -> list[str]: + """Build all candidate OAuth discovery URLs from this metadata and the server URL.""" + urls = [] + for auth_server in self.authorization_servers: + urls.extend(_build_well_known_urls(auth_server.rstrip('/'))) + urls.extend(_build_well_known_urls(server_url)) + return urls + + +async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceMetadata: """ + Fetch RFC 9728 Protected Resource Metadata from an MCP server. + https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization - """ + Returns: + ProtectedResourceMetadata with the resource indicator (RFC 8707) + and authorization server URLs discovered from the metadata document. + """ authorization_servers = [] + resource = None try: async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( @@ -265,57 +332,66 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: if response.status == 401: + resource_metadata_urls = [] match = re.search( - r'resource_metadata="([^"]+)"', + r'resource_metadata=(?:"([^"]+)"|([^\s,]+))', response.headers.get('WWW-Authenticate', ''), ) if match: - resource_metadata_url = match.group(1) - log.debug(f'Found resource_metadata URL: {resource_metadata_url}') - - # Step 2: Fetch Protected Resource metadata - async with session.get( - resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL - ) as resource_response: - if resource_response.status == 200: - resource_metadata = await resource_response.json() - - # Step 3: Extract authorization_servers - servers = resource_metadata.get('authorization_servers', []) - if servers: - authorization_servers = servers - log.debug(f'Discovered authorization servers: {servers}') + resource_metadata_urls = [match.group(1) or match.group(2)] + log.debug(f'Found resource_metadata URL: {resource_metadata_urls[0]}') + else: + # Fall back to well-known resource metadata URIs (RFC 9728 §4.2) + parsed, base_url = get_parsed_and_base_url(server_url) + if parsed.path and parsed.path != '/': + path = parsed.path.rstrip('/') + resource_metadata_urls.append( + urllib.parse.urljoin(base_url, f'/.well-known/oauth-protected-resource{path}') + ) + resource_metadata_urls.append( + urllib.parse.urljoin(base_url, '/.well-known/oauth-protected-resource') + ) + log.debug(f'No resource_metadata in header, trying well-known URIs: {resource_metadata_urls}') + + # Fetch Protected Resource metadata from candidate URLs + for resource_metadata_url in resource_metadata_urls: + try: + async with session.get( + resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resource_response: + if resource_response.status == 200: + resource_metadata = await resource_response.json() + + resource = resource_metadata.get('resource') or None + if resource: + log.debug(f'Discovered resource indicator: {resource}') + + servers = resource_metadata.get('authorization_servers', []) + if servers: + authorization_servers = servers + log.debug(f'Discovered authorization servers: {servers}') + break + except Exception as e: + log.debug(f'Failed to fetch resource metadata from {resource_metadata_url}: {e}') + continue except Exception as e: log.debug(f'MCP Protected Resource discovery failed: {e}') - discovery_urls = [] - for auth_server in authorization_servers: - auth_server = auth_server.rstrip('/') - discovery_urls.extend( - [ - f'{auth_server}/.well-known/oauth-authorization-server', - f'{auth_server}/.well-known/openid-configuration', - ] - ) + return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers) - return discovery_urls - -async def get_discovery_urls(server_url) -> list[str]: - urls = await get_authorization_server_discovery_urls(server_url) +def _build_well_known_urls(server_url: str) -> list[str]: + """Build RFC 8414 / OIDC Discovery well-known URLs for a server URL.""" parsed, base_url = get_parsed_and_base_url(server_url) + urls = [] if parsed.path and parsed.path != '/': - # Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery - tenant = parsed.path.rstrip('/') + path = parsed.path.rstrip('/') urls.extend( [ - urllib.parse.urljoin( - base_url, - f'/.well-known/oauth-authorization-server{tenant}', - ), - urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{tenant}'), - urllib.parse.urljoin(base_url, f'{tenant}/.well-known/openid-configuration'), + urllib.parse.urljoin(base_url, f'/.well-known/oauth-authorization-server{path}'), + urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{path}'), + urllib.parse.urljoin(base_url, f'{path}/.well-known/openid-configuration'), ] ) @@ -329,6 +405,12 @@ async def get_discovery_urls(server_url) -> list[str]: return urls +async def get_discovery_urls(server_url) -> list[str]: + """Convenience: get all OAuth discovery URLs for a server URL.""" + metadata = await get_protected_resource_metadata(server_url) + return metadata.get_discovery_urls(server_url) + + # TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration. # This is not currently supported. async def get_oauth_client_info_with_dynamic_client_registration( @@ -351,7 +433,9 @@ async def get_oauth_client_info_with_dynamic_client_registration( ) # Attempt to fetch OAuth server metadata to get registration endpoint & scopes - discovery_urls = await get_discovery_urls(oauth_server_url) + resource_metadata = await get_protected_resource_metadata(oauth_server_url) + resource = resource_metadata.resource + discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url) for url in discovery_urls: async with aiohttp.ClientSession(trust_env=True) as session: async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as oauth_server_metadata_response: @@ -411,8 +495,9 @@ async def get_oauth_client_info_with_dynamic_client_registration( oauth_client_info = OAuthClientInformationFull.model_validate( { **registration_response_json, - **{'issuer': oauth_server_metadata_url}, - **{'server_metadata': oauth_server_metadata}, + 'issuer': oauth_server_metadata_url, + 'server_metadata': oauth_server_metadata, + 'resource': resource, } ) log.info( @@ -441,6 +526,97 @@ async def get_oauth_client_info_with_dynamic_client_registration( raise e +async def get_oauth_client_info_with_static_credentials( + request, + client_id: str, + oauth_server_url: str, + oauth_client_id: str, + oauth_client_secret: str, +) -> OAuthClientInformationFull: + """ + Build an OAuthClientInformationFull from user-provided static credentials. + Performs server metadata discovery to resolve authorization/token endpoints, + but skips dynamic client registration entirely. + """ + try: + oauth_server_metadata = None + oauth_server_metadata_url = None + + redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback' + + # Discover server metadata (authorization endpoint, token endpoint, scopes, etc.) + resource_metadata = await get_protected_resource_metadata(oauth_server_url) + resource = resource_metadata.resource + discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url) + for url in discovery_urls: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + if resp.status == 200: + try: + oauth_server_metadata = OAuthMetadata.model_validate(await resp.json()) + oauth_server_metadata_url = url + break + except Exception as e: + log.error(f'Error parsing OAuth metadata from {url}: {e}') + continue + + # Let the OAuth provider apply its default scopes. + # We intentionally do NOT join all scopes_supported here — that list + # represents every scope the server *can* grant, not what the client + # should request. Requesting all of them is almost always wrong and + # can break providers like Entra ID that require resource-specific scopes. + scope = None + + # Determine token_endpoint_auth_method + token_endpoint_auth_method = 'client_secret_post' + if ( + oauth_server_metadata + and oauth_server_metadata.token_endpoint_auth_methods_supported + and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported + ): + token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0] + + oauth_client_info = OAuthClientInformationFull( + client_id=oauth_client_id, + client_secret=oauth_client_secret, + redirect_uris=[redirect_uri], + grant_types=['authorization_code', 'refresh_token'], + response_types=['code'], + scope=scope, + token_endpoint_auth_method=token_endpoint_auth_method, + issuer=oauth_server_metadata_url, + server_metadata=oauth_server_metadata, + resource=resource, + ) + + log.info( + f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}' + ) + return oauth_client_info + except Exception as e: + log.error(f'Exception building static OAuth client info: {e}') + raise e + + +def resolve_oauth_client_info(connection: dict) -> dict: + """ + Decrypt OAuth client info from a tool server connection config. + + For oauth_2.1_static, overlays admin-provided credentials from + info.oauth_client_id and info.oauth_client_secret onto the blob. + """ + info = connection.get('info', {}) + data = decrypt_data(info.get('oauth_client_info', '')) + + if connection.get('auth_type') == 'oauth_2.1_static': + if info.get('oauth_client_id') and info.get('oauth_client_secret'): + data['client_id'] = info['oauth_client_id'] + data['client_secret'] = info['oauth_client_secret'] + + return data + + class OAuthClientManager: def __init__(self, app): self.oauth = OAuth() @@ -453,6 +629,8 @@ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): 'client_id': oauth_client_info.client_id, 'client_secret': oauth_client_info.client_secret, 'client_kwargs': { + 'follow_redirects': True, + **({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}), **({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}), **( {'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method} @@ -463,15 +641,20 @@ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): 'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None), } - if oauth_client_info.server_metadata and oauth_client_info.server_metadata.code_challenge_methods_supported: - if ( - isinstance( - oauth_client_info.server_metadata.code_challenge_methods_supported, - list, - ) - and 'S256' in oauth_client_info.server_metadata.code_challenge_methods_supported - ): - kwargs['code_challenge_method'] = 'S256' + # Default to S256 for OAuth 2.1 (PKCE is mandatory per RFC 9700) + kwargs['code_challenge_method'] = 'S256' + + # Only remove PKCE if metadata explicitly excludes S256 + if ( + oauth_client_info.server_metadata + and oauth_client_info.server_metadata.code_challenge_methods_supported + and isinstance( + oauth_client_info.server_metadata.code_challenge_methods_supported, + list, + ) + and 'S256' not in oauth_client_info.server_metadata.code_challenge_methods_supported + ): + del kwargs['code_challenge_method'] self.clients[client_id] = { 'client': self.oauth.register(**kwargs), @@ -495,7 +678,7 @@ def ensure_client_from_config(self, client_id): for connection in connections or []: if connection.get('type', 'openapi') != 'mcp': continue - if connection.get('auth_type', 'none') != 'oauth_2.1': + if connection.get('auth_type', 'none') not in ('oauth_2.1', 'oauth_2.1_static'): continue server_id = connection.get('info', {}).get('id') @@ -511,7 +694,7 @@ def ensure_client_from_config(self, client_id): continue try: - oauth_client_info = decrypt_data(oauth_client_info) + oauth_client_info = resolve_oauth_client_info(connection) return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client'] except Exception as e: log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}') @@ -560,7 +743,7 @@ async def _preflight_authorization_url(self, client, client_info: OAuthClientInf async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( authorization_url, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as resp: if resp.status < 400: @@ -629,12 +812,16 @@ async def get_oauth_token(self, user_id: str, client_id: str, force_refresh: boo """ try: # Get the OAuth session - session = OAuthSessions.get_session_by_provider_and_user_id(client_id, user_id) + session = await OAuthSessions.get_session_by_provider_and_user_id(client_id, user_id) if not session: log.warning(f'No OAuth session found for user {user_id}, client_id {client_id}') return None - if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + if ( + force_refresh + or session.expires_at is None + or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) + ): log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: @@ -643,7 +830,7 @@ async def get_oauth_token(self, user_id: str, client_id: str, force_refresh: boo log.warning( f'Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}' ) - OAuthSessions.delete_session_by_id(session.id) + await OAuthSessions.delete_session_by_id(session.id) return None return session.token @@ -667,7 +854,7 @@ async def _refresh_token(self, session) -> dict: if refreshed_token: # Update the session with new token data - session = OAuthSessions.update_session_by_id(session.id, refreshed_token) + session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) log.info(f'Successfully refreshed token for session {session.id}') return session.token else: @@ -719,6 +906,11 @@ async def _perform_token_refresh(self, session) -> dict: 'refresh_token': token_data['refresh_token'], 'client_id': client.client_id, } + # RFC 8707: include resource indicator so refreshed tokens retain correct audience + client_info = self.get_client_info(client_id) + if client_info and client_info.resource: + refresh_data['resource'] = client_info.resource + if hasattr(client, 'client_secret') and client.client_secret: refresh_data['client_secret'] = client.client_secret @@ -745,14 +937,7 @@ async def _perform_token_refresh(self, session) -> dict: if 'refresh_token' not in new_token_data: new_token_data['refresh_token'] = token_data['refresh_token'] - # Add timestamp for tracking - new_token_data['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: - new_token_data['expires_at'] = int( - datetime.now().timestamp() + new_token_data['expires_in'] - ) + _normalize_token_expiry(new_token_data) log.debug(f'Token refresh successful for client_id {client_id}') return new_token_data @@ -778,7 +963,11 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: redirect_uri = client_info.redirect_uris[0] if client_info.redirect_uris else None redirect_uri_str = str(redirect_uri) if redirect_uri else None - return await client.authorize_redirect(request, redirect_uri_str) + # RFC 8707: pass resource indicator so the IdP sets the correct JWT audience + kwargs = {} + if client_info.resource: + kwargs['resource'] = client_info.resource + return await client.authorize_redirect(request, redirect_uri_str, **kwargs) async def handle_callback(self, request, client_id: str, user_id: str, response): client = self.get_client(client_id) or self.ensure_client_from_config(client_id) @@ -793,7 +982,11 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) # The Authlib client already has these configured during add_client(). # Passing them again causes Authlib to concatenate them (e.g., "ID1,ID1"), # which results in 401 errors from the token endpoint. (Fix for #19823) - token = await client.authorize_access_token(request) + # RFC 8707: pass resource indicator for correct JWT audience on token exchange + token_kwargs = {} + if client_info and client_info.resource: + token_kwargs['resource'] = client_info.resource + token = await client.authorize_access_token(request, **token_kwargs) # Validate that we received a proper token response # If token exchange failed (e.g., 401), we may get an error response instead @@ -805,20 +998,15 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) if token: try: - # Add timestamp for tracking - token['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in token and 'expires_at' not in token: - token['expires_at'] = datetime.now().timestamp() + token['expires_in'] + _normalize_token_expiry(token) # Clean up any existing sessions for this user/client_id first - sessions = OAuthSessions.get_sessions_by_user_id(user_id) + sessions = await OAuthSessions.get_sessions_by_user_id(user_id) for session in sessions: if session.provider == client_id: - OAuthSessions.delete_session_by_id(session.id) + await OAuthSessions.delete_session_by_id(session.id) - session = OAuthSessions.create_session( + session = await OAuthSessions.create_session( user_id=user_id, provider=client_id, token=token, @@ -892,12 +1080,16 @@ async def get_oauth_token(self, user_id: str, session_id: str, force_refresh: bo """ try: # Get the OAuth session - session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id) + session = await OAuthSessions.get_session_by_id_and_user_id(session_id, user_id) if not session: log.warning(f'No OAuth session found for user {user_id}, session {session_id}') return None - if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + if ( + force_refresh + or session.expires_at is None + or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at) + ): log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: @@ -906,7 +1098,7 @@ async def get_oauth_token(self, user_id: str, session_id: str, force_refresh: bo log.warning( f'Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}' ) - OAuthSessions.delete_session_by_id(session.id) + await OAuthSessions.delete_session_by_id(session.id) return None return session.token @@ -931,7 +1123,7 @@ async def _refresh_token(self, session) -> dict: if refreshed_token: # Update the session with new token data - session = OAuthSessions.update_session_by_id(session.id, refreshed_token) + session = await OAuthSessions.update_session_by_id(session.id, refreshed_token) log.info(f'Successfully refreshed token for session {session.id}') return session.token else: @@ -1011,14 +1203,7 @@ async def _perform_token_refresh(self, session) -> dict: if 'refresh_token' not in new_token_data: new_token_data['refresh_token'] = token_data['refresh_token'] - # Add timestamp for tracking - new_token_data['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: - new_token_data['expires_at'] = int( - datetime.now().timestamp() + new_token_data['expires_in'] - ) + _normalize_token_expiry(new_token_data) log.debug(f'Token refresh successful for provider {provider}') return new_token_data @@ -1031,16 +1216,19 @@ async def _perform_token_refresh(self, session) -> dict: log.error(f'Exception during token refresh for provider {provider}: {e}') return None - def get_user_role(self, user, user_data): - user_count = Users.get_num_users() + async def get_user_role(self, user, user_data, provider=None, access_token=None): + user_count = await Users.get_num_users() if user and user_count == 1: # If the user is the only user, assign the role "admin" - actually repairs role for single user on login log.debug('Assigning the only user the admin role') return 'admin' if not user and user_count == 0: - # If there are no users, assign the role "admin", as the first user will be an admin - log.debug('Assigning the first user the admin role') - return 'admin' + # First-user bootstrap: skip role management gating so the + # instance can be initialized. We intentionally return the + # default role here (not 'admin') — admin promotion happens + # race-safely *after* insert via get_num_users() == 1. + log.debug('First user bootstrap: using default role (admin promotion deferred to post-insert)') + return auth_manager_config.DEFAULT_USER_ROLE if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT: log.debug('Running OAUTH Role management') @@ -1051,50 +1239,90 @@ def get_user_role(self, user, user_data): # Default/fallback role if no matching roles are found role = auth_manager_config.DEFAULT_USER_ROLE - # Next block extracts the roles from the user data, accepting nested claims of any depth - if oauth_claim and oauth_allowed_roles and oauth_admin_roles: - claim_data = user_data - nested_claims = oauth_claim.split('.') - for nested_claim in nested_claims: - claim_data = claim_data.get(nested_claim, {}) - - # Try flat claim structure as alternative - if not claim_data: - claim_data = user_data.get(oauth_claim, {}) - - oauth_roles = [] + if ( + provider == "google" + and access_token + and "https://www.googleapis.com/auth/cloud-identity.groups.readonly" + in GOOGLE_OAUTH_SCOPE.value + ): + log.debug( + "Google OAuth with Cloud Identity scope detected - fetching groups via API" + ) + user_email = user_data.get(auth_manager_config.OAUTH_EMAIL_CLAIM, "") + if user_email: + try: + google_groups = ( + await self._fetch_google_groups_via_cloud_identity( + access_token, user_email + ) + ) + if "google_groups" not in user_data: + user_data["google_groups"] = google_groups - if isinstance(claim_data, list): - oauth_roles = claim_data - elif isinstance(claim_data, str): - # Split by the configured separator if present - if OAUTH_ROLES_SEPARATOR and OAUTH_ROLES_SEPARATOR in claim_data: - oauth_roles = claim_data.split(OAUTH_ROLES_SEPARATOR) - else: - oauth_roles = [claim_data] - elif isinstance(claim_data, int): - oauth_roles = [str(claim_data)] + # Use Google groups as oauth_roles for role determination + oauth_roles = google_groups + log.debug(f"Using Google groups as roles: {oauth_roles}") + except Exception as e: + log.error(f"Failed to fetch Google groups: {e}") + oauth_roles = [] + + # If not using Google groups or Google groups fetch failed, use traditional claims method + if not oauth_roles: + # Next block extracts the roles from the user data, accepting nested claims of any depth + if oauth_claim and oauth_allowed_roles and oauth_admin_roles: + claim_data = user_data + nested_claims = oauth_claim.split(".") + for nested_claim in nested_claims: + claim_data = claim_data.get(nested_claim, {}) + + # Try flat claim structure as alternative + if not claim_data: + claim_data = user_data.get(oauth_claim, {}) + + oauth_roles = [] + + if isinstance(claim_data, list): + oauth_roles = claim_data + elif isinstance(claim_data, str): + # Split by the configured separator if present + if OAUTH_ROLES_SEPARATOR and OAUTH_ROLES_SEPARATOR in claim_data: + oauth_roles = claim_data.split(OAUTH_ROLES_SEPARATOR) + else: + oauth_roles = [claim_data] + elif isinstance(claim_data, int): + oauth_roles = [str(claim_data)] log.debug(f'Oauth Roles claim: {oauth_claim}') log.debug(f'User roles from oauth: {oauth_roles}') log.debug(f'Accepted user roles: {oauth_allowed_roles}') log.debug(f'Accepted admin roles: {oauth_admin_roles}') - # If any roles are found, check if they match the allowed or admin roles + # If roles are present in the token, they must match; otherwise deny access if oauth_roles: - # If role management is enabled, and matching roles are provided, use the roles + matched = False for allowed_role in oauth_allowed_roles: - # If the user has any of the allowed roles, assign the role "user" if allowed_role in oauth_roles: log.debug('Assigned user the user role') role = 'user' + matched = True break for admin_role in oauth_admin_roles: - # If the user has any of the admin roles, assign the role "admin" if admin_role in oauth_roles: - log.debug('Assigned user the admin role') + log.debug( + f"Assigned user the admin role based on group: {admin_role}" + ) role = 'admin' + matched = True break + if not matched: + log.warning( + f'OAuth role management enabled but user roles do not match any allowed/admin roles. ' + f'User roles: {oauth_roles}, allowed: {oauth_allowed_roles}, admin: {oauth_admin_roles}' + ) + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) else: if not user: # If role management is disabled, use the default role for new users @@ -1105,8 +1333,83 @@ def get_user_role(self, user, user_data): return role - def update_user_groups(self, user, user_data, default_permissions, db=None): - log.debug('Running OAUTH Group management') + async def _fetch_google_groups_via_cloud_identity( + self, access_token: str, user_email: str + ) -> list[str]: + """ + Fetch Google Workspace groups for a user via Cloud Identity API. + + Args: + access_token: OAuth access token with cloud-identity.groups.readonly scope + user_email: User's email address + + Returns: + List of group email addresses the user belongs to + """ + groups = [] + base_url = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups" + + query_string = f"member_key_id == '{user_email}' && 'cloudidentity.googleapis.com/groups.security' in labels" + encoded_query = quote(query_string) + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + page_token = "" + + try: + async with aiohttp.ClientSession(trust_env=True) as session: + while True: + url = f"{base_url}?query={encoded_query}" + + if page_token: + url += f"&pageToken={quote(page_token)}" + + log.debug("Fetching Google groups via Cloud Identity API") + + async with session.get( + url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.status == 200: + data = await resp.json() + + memberships = data.get("memberships", []) + log.debug(f"Found {len(memberships)} memberships") + for membership in memberships: + group_key = membership.get("groupKey", {}) + group_email = group_key.get("id", "") + if group_email: + groups.append(group_email) + log.debug(f"Found group membership: {group_email}") + + page_token = data.get("nextPageToken", "") + if not page_token: + break + else: + error_text = await resp.text() + log.error( + f"Failed to fetch Google groups (status {resp.status})" + ) + try: + error_json = json.loads(error_text) + if "error" in error_json: + log.error(f"API error: {error_json['error'].get('message', 'Unknown error')}") + except json.JSONDecodeError: + log.error("Error response contains non-JSON data") + break + + except Exception as e: + log.error(f"Error fetching Google groups via Cloud Identity API: {e}") + + log.info(f"Retrieved {len(groups)} Google groups for user {user_email}") + return groups + + async def update_user_groups( + self, user, user_data, default_permissions, provider=None, access_token=None, db=None + ): + log.debug("Running OAUTH Group management") oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM try: @@ -1116,26 +1419,34 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): blocked_groups = [] user_oauth_groups = [] - # Nested claim search for groups claim - if oauth_claim: - claim_data = user_data - nested_claims = oauth_claim.split('.') - for nested_claim in nested_claims: - claim_data = claim_data.get(nested_claim, {}) - - if isinstance(claim_data, list): - user_oauth_groups = claim_data - elif isinstance(claim_data, str): - # Split by the configured separator if present - if OAUTH_GROUPS_SEPARATOR in claim_data: - user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR) + + # Check if Google groups were fetched via Cloud Identity API + if "google_groups" in user_data: + log.debug( + "Using Google groups from Cloud Identity API for group management" + ) + user_oauth_groups = user_data["google_groups"] + else: + # Nested claim search for groups claim (traditional method) + if oauth_claim: + claim_data = user_data + nested_claims = oauth_claim.split(".") + for nested_claim in nested_claims: + claim_data = claim_data.get(nested_claim, {}) + + if isinstance(claim_data, list): + user_oauth_groups = claim_data + elif isinstance(claim_data, str): + # Split by the configured separator if present + if OAUTH_GROUPS_SEPARATOR in claim_data: + user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR) + else: + user_oauth_groups = [claim_data] else: - user_oauth_groups = [claim_data] - else: - user_oauth_groups = [] + user_oauth_groups = [] - user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id, db=db) - all_available_groups: list[GroupModel] = Groups.get_all_groups(db=db) + user_current_groups: list[GroupModel] = await Groups.get_groups_by_member_id(user.id, db=db) + all_available_groups: list[GroupModel] = await Groups.get_all_groups(db=db) # Create groups if they don't exist and creation is enabled if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: @@ -1143,7 +1454,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): all_group_names = {g.name for g in all_available_groups} groups_created = False # Determine creator ID: Prefer admin, fallback to current user if no admin exists - admin_user = Users.get_super_admin_user() + admin_user = await Users.get_super_admin_user() creator_id = admin_user.id if admin_user else user.id log.debug(f'Using creator ID {creator_id} for potential group creation.') @@ -1158,7 +1469,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): data={'config': {'share': auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE}}, ) # Use determined creator ID (admin or fallback to current user) - created_group = Groups.insert_new_group(creator_id, new_group_form, db=db) + created_group = await Groups.insert_new_group(creator_id, new_group_form, db=db) if created_group: log.info( f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}" @@ -1173,7 +1484,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): # Refresh the list of all available groups if any were created if groups_created: - all_available_groups = Groups.get_all_groups(db=db) + all_available_groups = await Groups.get_all_groups(db=db) log.debug('Refreshed list of all available groups after creation.') log.debug(f'Oauth Groups claim: {oauth_claim}') @@ -1190,14 +1501,14 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): ): # Remove group from user log.debug(f'Removing user from group {group_model.name} as it is no longer in their oauth groups') - Groups.remove_users_from_group(group_model.id, [user.id], db=db) + await Groups.remove_users_from_group(group_model.id, [user.id], db=db) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions if not group_permissions: group_permissions = default_permissions - Groups.update_group_by_id( + await Groups.update_group_by_id( id=group_model.id, form_data=GroupUpdateForm( name=group_model.name, @@ -1219,14 +1530,14 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): # Add user to group log.debug(f'Adding user to group {group_model.name} as it was found in their oauth groups') - Groups.add_users_to_group(group_model.id, [user.id], db=db) + await Groups.add_users_to_group(group_model.id, [user.id], db=db) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions if not group_permissions: group_permissions = default_permissions - Groups.update_group_by_id( + await Groups.update_group_by_id( id=group_model.id, form_data=GroupUpdateForm( name=group_model.name, @@ -1251,6 +1562,8 @@ async def _process_picture_url(self, picture_url: str, access_token: str = None) return '/user.png' try: + validate_url(picture_url) + get_kwargs = {} if access_token: get_kwargs['headers'] = { @@ -1276,16 +1589,18 @@ async def handle_login(self, request, provider): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one - redirect_uri = OAUTH_PROVIDERS[provider].get('redirect_uri') or request.url_for( - 'oauth_login_callback', provider=provider - ) client = self.get_client(provider) if client is None: raise HTTPException(404) + redirect_uri = (client.server_metadata or {}).get('redirect_uri') or request.url_for( + 'oauth_login_callback', provider=provider + ) kwargs = {} if auth_manager_config.OAUTH_AUDIENCE: kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE + if OAUTH_AUTHORIZE_PARAMS: + kwargs.update(OAUTH_AUTHORIZE_PARAMS) return await client.authorize_redirect(request, redirect_uri, **kwargs) @@ -1305,6 +1620,27 @@ async def handle_callback(self, request, provider, response, db=None): try: token = await client.authorize_access_token(request, **auth_params) + except BadSignatureError: + # The IdP likely rotated its signing keys and the cached JWKS + # is stale. Evict the cached key set so the next attempt + # fetches fresh keys from the jwks_uri. + log.warning( + 'OIDC bad_signature for provider %s — evicting cached JWKS and retrying', + provider, + ) + if hasattr(client, 'server_metadata') and isinstance(client.server_metadata, dict): + client.server_metadata.pop('jwks', None) + try: + token = await client.authorize_access_token(request, **auth_params) + except Exception as retry_exc: + detailed_error = _build_oauth_callback_error_message(retry_exc) + log.warning( + 'OAuth callback error during authorize_access_token retry for provider %s: %s', + provider, + detailed_error, + exc_info=True, + ) + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) except Exception as e: detailed_error = _build_oauth_callback_error_message(e) log.warning( @@ -1317,12 +1653,21 @@ async def handle_callback(self, request, provider, response, db=None): # Try to get userinfo from the token first, some providers include it there user_data: UserInfo = token.get('userinfo') + # Preserve extra claims from the ID token (e.g. roles, groups for + # Microsoft Entra ID) before the userinfo endpoint possibly overwrites them. + id_token_claims = dict(user_data) if user_data else {} if ( (not user_data) or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) ): user_data: UserInfo = await client.userinfo(token=token) + # Merge back ID token claims that the userinfo endpoint doesn't + # return. Only backfill missing keys so userinfo always wins. + if user_data and id_token_claims: + for key, value in id_token_claims.items(): + if key not in user_data: + user_data[key] = value if provider == 'feishu' and isinstance(user_data, dict) and 'data' in user_data: user_data = user_data['data'] if not user_data: @@ -1394,20 +1739,22 @@ async def handle_callback(self, request, provider, response, db=None): raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists - user = Users.get_user_by_oauth_sub(provider, sub, db=db) + user = await Users.get_user_by_oauth_sub(provider, sub, db=db) if not user: # If the user does not exist, check if merging is enabled if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: # Check if the user exists by email - user = Users.get_user_by_email(email, db=db) + user = await Users.get_user_by_email(email, db=db) if user: # Update the user with the new oauth sub - Users.update_user_oauth_by_id(user.id, provider, sub, db=db) + await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) if user: - determined_role = self.get_user_role(user, user_data) + determined_role = await self.get_user_role( + user, user_data, provider, token.get("access_token") + ) if user.role != determined_role: - Users.update_user_role_by_id(user.id, determined_role, db=db) + await Users.update_user_role_by_id(user.id, determined_role, db=db) # Update the user object in memory as well, # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below user.role = determined_role @@ -1417,7 +1764,7 @@ async def handle_callback(self, request, provider, response, db=None): if username_claim: new_name = user_data.get(username_claim) if new_name and new_name != user.name: - Users.update_user_by_id(user.id, {'name': new_name}, db=db) + await Users.update_user_by_id(user.id, {'name': new_name}, db=db) user.name = new_name log.debug(f'Updated name for user {user.email}') @@ -1426,13 +1773,13 @@ async def handle_callback(self, request, provider, response, db=None): if email_claim: new_email = user_data.get(email_claim) if new_email and new_email.lower() != user.email.lower(): - existing_user = Users.get_user_by_email(new_email, db=db) + existing_user = await Users.get_user_by_email(new_email, db=db) if existing_user: log.error( f'Cannot update email to {new_email} for user {user.id} because it is already taken.' ) else: - Auths.update_email_by_id(user.id, new_email.lower(), db=db) + await Auths.update_email_by_id(user.id, new_email.lower(), db=db) user.email = new_email.lower() log.debug(f'Updated email for user {user.id}') @@ -1448,13 +1795,13 @@ async def handle_callback(self, request, provider, response, db=None): new_picture_url, token.get('access_token') ) if processed_picture_url != user.profile_image_url: - Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db) + await Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db) log.debug(f'Updated profile picture for user {user.email}') else: # If the user does not exist, check if signups are enabled if auth_manager_config.ENABLE_OAUTH_SIGNUP: # Check if an existing user with the same email already exists - existing_user = Users.get_user_by_email(email, db=db) + existing_user = await Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) @@ -1474,16 +1821,30 @@ async def handle_callback(self, request, provider, response, db=None): log.warning('Username claim is missing, using email as name') name = email - user = Auths.insert_new_auth( + role = await self.get_user_role( + None, user_data, provider, token.get("access_token") + ) + + user = await Auths.insert_new_auth( email=email, password=get_password_hash(str(uuid.uuid4())), # Random password, not used name=name, profile_image_url=picture_url, - role=self.get_user_role(None, user_data), + role=role, oauth=oauth_data, db=db, ) + if not user: + raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) + + # Atomically check if this is the only user *after* the + # insert to avoid TOCTOU race on first-user registration. + # Matches signup_handler pattern. + if await Users.get_num_users(db=db) == 1: + await Users.update_user_role_by_id(user.id, 'admin', db=db) + user = await Users.get_user_by_id(user.id, db=db) + if auth_manager_config.WEBHOOK_URL: await post_webhook( WEBUI_NAME, @@ -1496,7 +1857,7 @@ async def handle_callback(self, request, provider, response, db=None): }, ) - apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db) + await apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db) else: raise HTTPException( @@ -1508,11 +1869,16 @@ async def handle_callback(self, request, provider, response, db=None): data={'id': user.id}, expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), ) - if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != 'admin': - self.update_user_groups( + if ( + auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT + and user.role != "admin" + ): + await self.update_user_groups( user=user, user_data=user_data, default_permissions=request.app.state.config.USER_PERMISSIONS, + provider=provider, + access_token=token.get("access_token"), db=db, ) @@ -1533,6 +1899,10 @@ async def handle_callback(self, request, provider, response, db=None): response = RedirectResponse(url=redirect_url, headers=response.headers) + # Compute cookie expiry from JWT lifetime + expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN) + cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None + # Set the cookie token # Redirect back to the frontend with the JWT token response.set_cookie( @@ -1541,6 +1911,7 @@ async def handle_callback(self, request, provider, response, db=None): httponly=False, # Required for frontend access samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) # Legacy cookies for compatibility with older frontend versions @@ -1551,19 +1922,15 @@ async def handle_callback(self, request, provider, response, db=None): httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) try: - # Add timestamp for tracking - token['issued_at'] = datetime.now().timestamp() - - # Calculate expires_at if we have expires_in - if 'expires_in' in token and 'expires_at' not in token: - token['expires_at'] = datetime.now().timestamp() + token['expires_in'] + _normalize_token_expiry(token) # Enforce max concurrent sessions per user/provider to prevent # unbounded growth while allowing multi-device usage - sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db) + sessions = await OAuthSessions.get_sessions_by_user_id(user.id, db=db) provider_sessions = sorted( [session for session in sessions if session.provider == provider], key=lambda session: session.created_at, @@ -1572,9 +1939,9 @@ async def handle_callback(self, request, provider, response, db=None): # Keep the newest sessions up to the limit, prune the rest if len(provider_sessions) >= OAUTH_MAX_SESSIONS_PER_USER: for old_session in provider_sessions[OAUTH_MAX_SESSIONS_PER_USER - 1 :]: - OAuthSessions.delete_session_by_id(old_session.id, db=db) + await OAuthSessions.delete_session_by_id(old_session.id, db=db) - session = OAuthSessions.create_session( + session = await OAuthSessions.create_session( user_id=user.id, provider=provider, token=token, @@ -1588,6 +1955,7 @@ async def handle_callback(self, request, provider, response, db=None): httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}') @@ -1597,3 +1965,186 @@ async def handle_callback(self, request, provider, response, db=None): log.error(f'Failed to store OAuth session server-side: {e}') return response + + async def handle_backchannel_logout(self, request, db=None): + """ + Handle an OIDC Back-Channel Logout request. + Validates the logout_token, identifies the user, revokes their + sessions via Redis, and deletes their OAuth sessions. + Returns a JSONResponse per the OIDC Back-Channel Logout 1.0 spec. + """ + import jwt as pyjwt + from fastapi.responses import JSONResponse + + # 1. Extract logout_token from form body + try: + form = await request.form() + logout_token = form.get('logout_token') + except Exception: + logout_token = None + + if not logout_token: + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'Missing logout_token parameter'}, + ) + + # 2. Peek at unverified issuer to match against configured providers + try: + unverified_claims = pyjwt.decode(logout_token, options={'verify_signature': False}) + token_issuer = unverified_claims.get('iss') + except Exception as e: + log.warning(f'Back-channel logout: cannot decode logout_token: {e}') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'Malformed logout_token'}, + ) + + if not token_issuer: + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'logout_token missing iss claim'}, + ) + + # 3. Find the configured provider whose issuer matches the token + matched_provider = None + matched_client_id = None + matched_jwks_uri = None + matched_issuer = None + + for provider_name in OAUTH_PROVIDERS: + server_metadata_url = self.get_server_metadata_url(provider_name) + if not server_metadata_url: + continue + + try: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get(server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: + if r.status != 200: + continue + oidc_config = await r.json() + + provider_issuer = oidc_config.get('issuer') + if provider_issuer and provider_issuer == token_issuer: + client = self.get_client(provider_name) + matched_provider = provider_name + matched_client_id = client.client_id if client else None + matched_jwks_uri = oidc_config.get('jwks_uri') + matched_issuer = provider_issuer + break + except Exception as e: + log.debug(f'Back-channel logout: error checking provider {provider_name}: {e}') + continue + + if not matched_provider or not matched_client_id or not matched_jwks_uri: + log.warning(f'Back-channel logout: no configured provider matches issuer {token_issuer}') + return JSONResponse( + status_code=400, + content={ + 'error': 'invalid_request', + 'error_description': 'No configured provider matches token issuer', + }, + ) + + # 4. Validate the logout_token signature and claims + try: + jwks_client = pyjwt.PyJWKClient(matched_jwks_uri) + signing_key = jwks_client.get_signing_key_from_jwt(logout_token) + + claims = pyjwt.decode( + logout_token, + signing_key.key, + algorithms=['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'], + audience=matched_client_id, + issuer=matched_issuer, + options={ + 'require': ['iss', 'aud', 'iat', 'events'], + }, + ) + except pyjwt.InvalidTokenError as e: + log.warning(f'Back-channel logout: invalid logout_token: {e}') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': f'Invalid logout_token: {e}'}, + ) + except Exception as e: + log.error(f'Back-channel logout: error validating logout_token: {e}') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'Failed to validate logout_token'}, + ) + + # 5. Validate events claim per spec + events = claims.get('events', {}) + if 'http://schemas.openid.net/event/backchannel-logout' not in events: + log.warning('Back-channel logout: missing required backchannel-logout event claim') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'Missing backchannel-logout event claim'}, + ) + + # 6. Per spec, back-channel logout tokens MUST NOT contain a nonce + if 'nonce' in claims: + log.warning('Back-channel logout: logout_token contains nonce (rejected per spec)') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'logout_token must not contain nonce'}, + ) + + # 7. Extract sub and/or sid — at least one must be present + sub = claims.get('sub') + sid = claims.get('sid') + + if not sub and not sid: + log.warning('Back-channel logout: logout_token contains neither sub nor sid') + return JSONResponse( + status_code=400, + content={'error': 'invalid_request', 'error_description': 'logout_token must contain sub or sid'}, + ) + + # 8. Identify users to log out + users_to_logout = [] + if sub: + user = await Users.get_user_by_oauth_sub(matched_provider, sub, db=db) + if user: + users_to_logout.append(user) + + if not users_to_logout and sid: + log.debug(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})') + + if not users_to_logout: + log.debug(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}') + return JSONResponse(status_code=200, content={}) + + # 9. Revoke tokens and delete sessions + redis = request.app.state.redis + if not redis: + log.warning( + 'Back-channel logout: Redis not configured, cannot revoke JWT tokens. ' + 'OAuth sessions will be deleted but existing JWTs will remain valid until expiry.' + ) + + revoked_count = 0 + for user in users_to_logout: + sessions = await OAuthSessions.get_sessions_by_user_id(user.id, db=db) + for oauth_session in sessions: + await OAuthSessions.delete_session_by_id(oauth_session.id, db=db) + + if redis: + revocation_key = f'{REDIS_KEY_PREFIX}:auth:user:{user.id}:revoked_at' + await redis.set( + revocation_key, + str(int(time.time())), + ex=60 * 60 * 24 * 30, + ) + revoked_count += 1 + + log.info( + f'Back-channel logout: revoked sessions for user {user.id} ' + f'(email={user.email}, provider={matched_provider}, sessions_deleted={len(sessions)})' + ) + + log.info( + f'Back-channel logout: completed for {len(users_to_logout)} user(s), {revoked_count} revocation(s) set' + ) + return JSONResponse(status_code=200, content={}) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 21828d93f1f..63063f4983d 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -10,8 +10,10 @@ import json +# What goes out cannot be taken back. Let it be shaped +# well before it leaves this place. # inplace function: form_data is modified -def apply_system_prompt_to_body( +async def apply_system_prompt_to_body( system: Optional[str], form_data: dict, metadata: Optional[dict] = None, @@ -28,7 +30,7 @@ def apply_system_prompt_to_body( system = prompt_variables_template(system, variables) # Legacy (API Usage) - system = prompt_template(system, user) + system = await prompt_template(system, user) if replace: form_data['messages'] = replace_system_message_content(system, form_data.get('messages', [])) @@ -202,6 +204,11 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: # Initialize the new message structure with the role new_message = {'role': message['role']} + # Preserve Ollama-native 'thinking' field (used by reasoning models, + # may be injected by filter inlet functions). + if 'thinking' in message: + new_message['thinking'] = message['thinking'] + content = message.get('content', []) tool_calls = message.get('tool_calls', None) tool_call_id = message.get('tool_call_id', None) diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 6dae37e5319..1862e176604 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -14,7 +14,7 @@ OFFLINE_MODE, ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS, ) -from open_webui.models.functions import Functions +from open_webui.models.functions import FunctionModel, Functions from open_webui.models.tools import Tools log = logging.getLogger(__name__) @@ -197,16 +197,18 @@ def replace_imports(content): return content -def load_tool_module_by_id(tool_id, content=None): +# May the intent of the one who wrote it survive every +# import and transformation, as a deed survives the generations. +async def load_tool_module_by_id(tool_id, content=None): if content is None: - tool = Tools.get_tool_by_id(tool_id) + tool = await Tools.get_tool_by_id(tool_id) if not tool: raise Exception(f'Toolkit not found: {tool_id}') content = tool.content content = replace_imports(content) - Tools.update_tool_by_id(tool_id, {'content': content}) + await Tools.update_tool_by_id(tool_id, {'content': content}) else: frontmatter = extract_frontmatter(content) # Install required packages found within the frontmatter @@ -243,15 +245,15 @@ def load_tool_module_by_id(tool_id, content=None): os.unlink(temp_file.name) -def load_function_module_by_id(function_id: str, content: str | None = None): +async def load_function_module_by_id(function_id: str, content: str | None = None): if content is None: - function = Functions.get_function_by_id(function_id) + function = await Functions.get_function_by_id(function_id) if not function: raise Exception(f'Function not found: {function_id}') content = function.content content = replace_imports(content) - Functions.update_function_by_id(function_id, {'content': content}) + await Functions.update_function_by_id(function_id, {'content': content}) else: frontmatter = extract_frontmatter(content) install_frontmatter_requirements(frontmatter.get('requirements', '')) @@ -288,16 +290,16 @@ def load_function_module_by_id(function_id: str, content: str | None = None): # Cleanup by removing the module in case of error del sys.modules[module_name] - Functions.update_function_by_id(function_id, {'is_active': False}) + await Functions.update_function_by_id(function_id, {'is_active': False}) raise e finally: os.unlink(temp_file.name) -def get_tool_module_from_cache(request, tool_id, load_from_db=True): +async def get_tool_module_from_cache(request, tool_id, load_from_db=True): if load_from_db: # Always load from the database by default - tool = Tools.get_tool_by_id(tool_id) + tool = await Tools.get_tool_by_id(tool_id) if not tool: raise Exception(f'Tool not found: {tool_id}') content = tool.content @@ -306,7 +308,7 @@ def get_tool_module_from_cache(request, tool_id, load_from_db=True): if new_content != content: content = new_content # Update the tool content in the database - Tools.update_tool_by_id(tool_id, {'content': content}) + await Tools.update_tool_by_id(tool_id, {'content': content}) if (hasattr(request.app.state, 'TOOL_CONTENTS') and tool_id in request.app.state.TOOL_CONTENTS) and ( hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS @@ -314,12 +316,12 @@ def get_tool_module_from_cache(request, tool_id, load_from_db=True): if request.app.state.TOOL_CONTENTS[tool_id] == content: return request.app.state.TOOLS[tool_id], None - tool_module, frontmatter = load_tool_module_by_id(tool_id, content) + tool_module, frontmatter = await load_tool_module_by_id(tool_id, content) else: if hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS: return request.app.state.TOOLS[tool_id], None - tool_module, frontmatter = load_tool_module_by_id(tool_id) + tool_module, frontmatter = await load_tool_module_by_id(tool_id) if not hasattr(request.app.state, 'TOOLS'): request.app.state.TOOLS = {} @@ -333,13 +335,16 @@ def get_tool_module_from_cache(request, tool_id, load_from_db=True): return tool_module, frontmatter -def get_function_module_from_cache(request, function_id, load_from_db=True): +async def get_function_module_from_cache( + request, function_id, function: FunctionModel | None = None, load_from_db=True +): if load_from_db: # Always load from the database by default # This is useful for hooks like "inlet" or "outlet" where the content might change # and we want to ensure the latest content is used. - function = Functions.get_function_by_id(function_id) + if function is None: + function = await Functions.get_function_by_id(function_id) if not function: raise Exception(f'Function not found: {function_id}') content = function.content @@ -348,7 +353,7 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): if new_content != content: content = new_content # Update the function content in the database - Functions.update_function_by_id(function_id, {'content': content}) + await Functions.update_function_by_id(function_id, {'content': content}) if ( hasattr(request.app.state, 'FUNCTION_CONTENTS') and function_id in request.app.state.FUNCTION_CONTENTS @@ -356,7 +361,7 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): if request.app.state.FUNCTION_CONTENTS[function_id] == content: return request.app.state.FUNCTIONS[function_id], None, None - function_module, function_type, frontmatter = load_function_module_by_id(function_id, content) + function_module, function_type, frontmatter = await load_function_module_by_id(function_id, content) else: # Load from cache (e.g. "stream" hook) # This is useful for performance reasons @@ -364,7 +369,7 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): if hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS: return request.app.state.FUNCTIONS[function_id], None, None - function_module, function_type, frontmatter = load_function_module_by_id(function_id) + function_module, function_type, frontmatter = await load_function_module_by_id(function_id) if not hasattr(request.app.state, 'FUNCTIONS'): request.app.state.FUNCTIONS = {} @@ -378,7 +383,11 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): return function_module, function_type, frontmatter +_installed_requirements = set() + + def install_frontmatter_requirements(requirements: str): + global _installed_requirements if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS: log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.') return @@ -390,19 +399,25 @@ def install_frontmatter_requirements(requirements: str): if requirements: try: req_list = [req.strip() for req in requirements.split(',')] - log.info(f'Installing requirements: {" ".join(req_list)}') + new_reqs = [req for req in req_list if req and req not in _installed_requirements] + + if not new_reqs: + return + + log.info(f'Installing requirements: {" ".join(new_reqs)}') subprocess.check_call( - [sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + req_list + PIP_PACKAGE_INDEX_OPTIONS + [sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + new_reqs + PIP_PACKAGE_INDEX_OPTIONS ) + _installed_requirements.update(new_reqs) except Exception as e: - log.error(f'Error installing packages: {" ".join(req_list)}') + log.error(f'Error installing packages: {" ".join(new_reqs)}') raise e else: log.info('No requirements found in frontmatter.') -def install_tool_and_function_dependencies(): +async def install_tool_and_function_dependencies(): """ Install all dependencies for all admin tools and active functions. @@ -410,8 +425,8 @@ def install_tool_and_function_dependencies(): and then installing them using pip. Duplicates or similar version specifications are handled by pip as much as possible. """ - function_list = Functions.get_functions(active_only=True) - tool_list = Tools.get_tools() + function_list = await Functions.get_functions(active_only=True) + tool_list = await Tools.get_tools() all_dependencies = '' try: diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index a4d9d5cba53..e14a0079ec9 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -9,7 +9,9 @@ from open_webui.env import ( REDIS_CLUSTER, + REDIS_HEALTH_CHECK_INTERVAL, REDIS_SOCKET_CONNECT_TIMEOUT, + REDIS_SOCKET_KEEPALIVE, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_MAX_RETRY_COUNT, REDIS_SENTINEL_PORT, @@ -19,7 +21,12 @@ log = logging.getLogger(__name__) +MAX_RETRY_COUNT = REDIS_SENTINEL_MAX_RETRY_COUNT + +# Let not our connections be timed out but deliver them from +# partition. For the cache and the socket and the uptime +# belong to the one who first opened them, now and always. _CONNECTION_CACHE = {} @@ -188,6 +195,14 @@ def get_redis_connection( connection = None + connect_timeout_kwargs = ( + {'socket_connect_timeout': REDIS_SOCKET_CONNECT_TIMEOUT} if REDIS_SOCKET_CONNECT_TIMEOUT is not None else {} + ) + + keepalive_kwargs = {'socket_keepalive': True} if REDIS_SOCKET_KEEPALIVE else {} + + health_check_kwargs = {'health_check_interval': REDIS_HEALTH_CHECK_INTERVAL} if REDIS_HEALTH_CHECK_INTERVAL else {} + if async_mode: import redis.asyncio as redis @@ -202,6 +217,8 @@ def get_redis_connection( password=redis_config['password'], decode_responses=decode_responses, socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, + **keepalive_kwargs, + **health_check_kwargs, ) connection = SentinelRedisProxy( sentinel, @@ -211,9 +228,21 @@ def get_redis_connection( elif redis_cluster: if not redis_url: raise ValueError('Redis URL must be provided for cluster mode.') - return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses) + return redis.cluster.RedisCluster.from_url( + redis_url, + decode_responses=decode_responses, + **connect_timeout_kwargs, + **keepalive_kwargs, + **health_check_kwargs, + ) elif redis_url: - connection = redis.from_url(redis_url, decode_responses=decode_responses) + connection = redis.from_url( + redis_url, + decode_responses=decode_responses, + **connect_timeout_kwargs, + **keepalive_kwargs, + **health_check_kwargs, + ) else: import redis @@ -227,6 +256,8 @@ def get_redis_connection( password=redis_config['password'], decode_responses=decode_responses, socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, + **keepalive_kwargs, + **health_check_kwargs, ) connection = SentinelRedisProxy( sentinel, @@ -236,9 +267,21 @@ def get_redis_connection( elif redis_cluster: if not redis_url: raise ValueError('Redis URL must be provided for cluster mode.') - return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses) + return redis.cluster.RedisCluster.from_url( + redis_url, + decode_responses=decode_responses, + **connect_timeout_kwargs, + **keepalive_kwargs, + **health_check_kwargs, + ) elif redis_url: - connection = redis.Redis.from_url(redis_url, decode_responses=decode_responses) + connection = redis.Redis.from_url( + redis_url, + decode_responses=decode_responses, + **connect_timeout_kwargs, + **keepalive_kwargs, + **health_check_kwargs, + ) _CONNECTION_CACHE[cache_key] = connection return connection diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index ae911368a37..676a07525ea 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -6,6 +6,8 @@ ) +# An honest ledger is worth more than a flattering one. +# Let every cost here be counted true. def normalize_usage(usage: dict) -> dict: """ Normalize usage statistics to standard format. @@ -133,6 +135,9 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict: async def convert_streaming_response_ollama_to_openai(ollama_streaming_response): has_tool_calls = False + # All chunks in a single completion must share the same id (OpenAI spec). + completion_id = f'chatcmpl-{str(uuid4())}' + first = True async for data in ollama_streaming_response.body_iterator: data = json.loads(data) @@ -153,6 +158,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) usage = convert_ollama_usage_to_openai(data) data = openai_chat_chunk_message_template(model, message_content, reasoning_content, openai_tool_calls, usage) + data['id'] = completion_id + + # First chunk must carry delta.role (OpenAI spec). + if first: + data['choices'][0]['delta']['role'] = 'assistant' + first = False if done and has_tool_calls: data['choices'][0]['finish_reason'] = 'tool_calls' diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index 33956688a16..ecc3b6eb30f 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -28,6 +28,10 @@ def set_security_headers() -> Dict[str, str]: - x-frame-options - x-permitted-cross-domain-policies - content-security-policy + - content-security-policy-report-only + - cross-origin-embedder-policy + - cross-origin-opener-policy + - cross-origin-resource-policy - reporting-endpoints Each environment variable is associated with a specific setter function @@ -48,6 +52,10 @@ def set_security_headers() -> Dict[str, str]: 'XFRAME_OPTIONS': set_xframe, 'XPERMITTED_CROSS_DOMAIN_POLICIES': set_xpermitted_cross_domain_policies, 'CONTENT_SECURITY_POLICY': set_content_security_policy, + 'CONTENT_SECURITY_POLICY_REPORT_ONLY': set_content_security_policy_report_only, + 'CROSS_ORIGIN_EMBEDDER_POLICY': set_cross_origin_embedder_policy, + 'CROSS_ORIGIN_OPENER_POLICY': set_cross_origin_opener_policy, + 'CROSS_ORIGIN_RESOURCE_POLICY': set_cross_origin_resource_policy, 'REPORTING_ENDPOINTS': set_reporting_endpoints, } @@ -135,6 +143,38 @@ def set_content_security_policy(value: str): return {'Content-Security-Policy': value} +# Set Content-Security-Policy-Report-Only response header +def set_content_security_policy_report_only(value: str): + return {'Content-Security-Policy-Report-Only': value} + + +# Set Cross-Origin-Embedder-Policy response header +def set_cross_origin_embedder_policy(value: str): + pattern = r'^(unsafe-none|require-corp|credentialless)$' + match = re.match(pattern, value, re.IGNORECASE) + if not match: + value = 'require-corp' + return {'Cross-Origin-Embedder-Policy': value} + + +# Set Cross-Origin-Opener-Policy response header +def set_cross_origin_opener_policy(value: str): + pattern = r'^(unsafe-none|same-origin-allow-popups|same-origin)$' + match = re.match(pattern, value, re.IGNORECASE) + if not match: + value = 'same-origin' + return {'Cross-Origin-Opener-Policy': value} + + +# Set Cross-Origin-Resource-Policy response header +def set_cross_origin_resource_policy(value: str): + pattern = r'^(same-site|same-origin|cross-origin)$' + match = re.match(pattern, value, re.IGNORECASE) + if not match: + value = 'same-origin' + return {'Cross-Origin-Resource-Policy': value} + + # Set Reporting-Endpoints response header def set_reporting_endpoints(value: str): return {'Reporting-Endpoints': value} diff --git a/backend/open_webui/utils/session_pool.py b/backend/open_webui/utils/session_pool.py new file mode 100644 index 00000000000..d74eae4f04e --- /dev/null +++ b/backend/open_webui/utils/session_pool.py @@ -0,0 +1,119 @@ +"""Shared aiohttp ClientSession pool. + +Instead of creating a new ClientSession (and TCPConnector) per request, +callers acquire a long-lived session from this module. The pool manages +a single TCPConnector with configurable limits, enabling TCP/SSL connection +reuse, shared DNS cache, and bounded concurrency. + +All pool parameters are configurable via environment variables: + - AIOHTTP_POOL_CONNECTIONS (default 100) — max total connections + - AIOHTTP_POOL_CONNECTIONS_PER_HOST (default 30) — per-host limit + - AIOHTTP_POOL_DNS_TTL (default 300) — DNS cache TTL in seconds + +Usage: + from open_webui.utils.session_pool import get_session, cleanup_response + + session = await get_session() + r = await session.request(...) + # When done with the *response* (not the session): + await cleanup_response(r) + +IMPORTANT: Callers must NOT close the shared session. Only the response +needs cleanup. The session is closed once during application shutdown +via ``close_session()``. +""" + +import logging +from typing import Optional + +import aiohttp + +from open_webui.env import ( + AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_POOL_CONNECTIONS, + AIOHTTP_POOL_CONNECTIONS_PER_HOST, + AIOHTTP_POOL_DNS_TTL, +) + +log = logging.getLogger(__name__) + +_session: Optional[aiohttp.ClientSession] = None + + +async def get_session() -> aiohttp.ClientSession: + """Return the shared aiohttp ClientSession, creating it lazily.""" + global _session + if _session is None or _session.closed: + connector_kwargs = { + 'ttl_dns_cache': AIOHTTP_POOL_DNS_TTL, + 'enable_cleanup_closed': True, + } + if AIOHTTP_POOL_CONNECTIONS is not None: + connector_kwargs['limit'] = AIOHTTP_POOL_CONNECTIONS + else: + connector_kwargs['limit'] = 0 # aiohttp: 0 = unlimited + if AIOHTTP_POOL_CONNECTIONS_PER_HOST is not None: + connector_kwargs['limit_per_host'] = AIOHTTP_POOL_CONNECTIONS_PER_HOST + else: + connector_kwargs['limit_per_host'] = 0 # aiohttp: 0 = unlimited + connector = aiohttp.TCPConnector(**connector_kwargs) + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + _session = aiohttp.ClientSession( + connector=connector, + timeout=timeout, + trust_env=True, + ) + log.info( + 'Created shared aiohttp session pool (limit=%s, per_host=%s, dns_ttl=%d)', + AIOHTTP_POOL_CONNECTIONS or 'unlimited', + AIOHTTP_POOL_CONNECTIONS_PER_HOST or 'unlimited', + AIOHTTP_POOL_DNS_TTL, + ) + return _session + + +async def close_session(): + """Close the shared session. Called during application shutdown.""" + global _session + if _session and not _session.closed: + await _session.close() + log.info('Closed shared aiohttp session pool') + _session = None + + +async def cleanup_response( + response: Optional[aiohttp.ClientResponse], + session: Optional[aiohttp.ClientSession] = None, +): + """Release and close an aiohttp response, optionally closing the session. + + When using the shared pool, ``session`` should be ``None`` (the pool + session is never closed per-request). When a caller creates its own + one-off session, pass it here to close it after the response. + """ + if response: + if not response.closed: + # aiohttp 3.9+ made ClientResponse.close() synchronous (returns None). + # Older versions returned a coroutine. Handle both gracefully. + result = response.close() + if result is not None: + await result + if session: + if not session.closed: + result = session.close() + if result is not None: + await result + + +async def stream_wrapper(response, session=None, content_handler=None): + """Wrap a stream to ensure cleanup happens even if streaming is interrupted. + + This is more reliable than BackgroundTask which may not run if the client + disconnects. When using the shared pool, ``session`` should be ``None``. + """ + try: + stream = content_handler(response.content) if content_handler else response.content + async for chunk in stream: + yield chunk + finally: + await cleanup_response(response, session) diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index c640e7f5f83..866746fca85 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -13,11 +13,13 @@ log = logging.getLogger(__name__) +# Let the right tool be given for the work at hand, +# not the one that flatters, but the one that serves. def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str: # Set the task model task_model_id = default_model_id # Check if the user has a custom task model and use that model - if models[task_model_id].get('connection_type') == 'local': + if models.get(task_model_id, {}).get('connection_type') == 'local': if task_model and task_model in models: task_model_id = task_model else: @@ -33,7 +35,7 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str: return template -def prompt_template(template: str, user: Optional[Any] = None) -> str: +async def prompt_template(template: str, user: Optional[Any] = None) -> str: USER_VARIABLES = {} if user: @@ -56,6 +58,19 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str: except Exception as e: pass + # Resolve user groups from DB only when the template uses {{USER_GROUPS}} + groups = '' + if '{{USER_GROUPS}}' in template: + user_id = user.get('id') + if user_id: + try: + from open_webui.models.groups import Groups + + user_groups = await Groups.get_groups_by_member_id(user_id) + groups = ', '.join(g.name for g in user_groups) + except Exception: + pass + USER_VARIABLES = { 'name': str(user.get('name')), 'email': str(user.get('email')), @@ -64,6 +79,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str: 'gender': str(user.get('gender')), 'birth_date': str(birth_date), 'age': str(age), + 'groups': groups, } # Get the current date @@ -86,6 +102,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str: template = template.replace('{{USER_BIRTH_DATE}}', USER_VARIABLES.get('birth_date', 'Unknown')) template = template.replace('{{USER_AGE}}', str(USER_VARIABLES.get('age', 'Unknown'))) template = template.replace('{{USER_LOCATION}}', USER_VARIABLES.get('location', 'Unknown')) + template = template.replace('{{USER_GROUPS}}', USER_VARIABLES.get('groups', '')) return template @@ -239,11 +256,13 @@ def replacement_function(match): # {{prompt:middletruncate:8000}} -def rag_template(template: str, context: str, query: str): +# Let the context given here not distort the question, +# but illuminate it, so that the answer serves the one who asked. +async def rag_template(template: str, context: str, query: str): if template.strip() == '': template = DEFAULT_RAG_TEMPLATE - template = prompt_template(template) + template = await prompt_template(template) if '[context]' not in template and '{{CONTEXT}}' not in template: log.debug("WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder.") @@ -278,51 +297,51 @@ def rag_template(template: str, context: str, query: str): return template -def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: +async def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: +async def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: +async def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: +async def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str: +async def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str: template = replace_prompt_variable(template, prompt) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def autocomplete_generation_template( +async def autocomplete_generation_template( template: str, prompt: str, messages: Optional[list[dict]] = None, @@ -333,16 +352,16 @@ def autocomplete_generation_template( template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template -def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: +async def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) - template = prompt_template(template, user) + template = await prompt_template(template, user) return template @@ -381,6 +400,15 @@ def replacement_function(match): return template +async def model_recommendation_template( + template: str, task_description: str, models_info: str, user: Optional[Any] = None +) -> str: + template = template.replace("{{TASK_DESCRIPTION}}", task_description) + template = template.replace("{{MODELS_LIST}}", models_info) + template = await prompt_template(template, user) + return template + + def tools_function_calling_generation_template(template: str, tools_specs: str) -> str: template = template.replace('{{TOOLS}}', tools_specs) return template diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 394e7178d6e..fe8e9ba799b 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -7,8 +7,8 @@ TraceRequestEndParams, TraceRequestExceptionParams, ) -from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi from fastapi import FastAPI +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.httpx import ( HTTPXClientInstrumentor, RequestInfo, @@ -176,7 +176,7 @@ def instrumentation_dependencies(self) -> Collection[str]: return [] def _instrument(self, **kwargs): - instrument_fastapi(app=self.app) + FastAPIInstrumentor.instrument_app(app=self.app) SQLAlchemyInstrumentor().instrument(engine=self.db_engine) RedisInstrumentor().instrument(request_hook=redis_request_hook) RequestsInstrumentor().instrument(request_hook=requests_hook, response_hook=response_hook) diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 4c43de33426..a1d1dcb7cbb 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -17,8 +17,10 @@ from __future__ import annotations +import datetime +import logging import time -from typing import Dict, List, Sequence, Any +from typing import Dict, Iterable, List, Optional from base64 import b64encode from fastapi import FastAPI, Request @@ -36,6 +38,8 @@ PeriodicExportingMetricReader, ) from opentelemetry.sdk.resources import Resource +from sqlalchemy import Engine, func, select +from sqlalchemy.orm import Session from open_webui.env import ( OTEL_SERVICE_NAME, @@ -46,7 +50,47 @@ OTEL_METRICS_EXPORTER_OTLP_INSECURE, OTEL_METRICS_EXPORT_INTERVAL_MILLIS, ) -from open_webui.models.users import Users +from open_webui.models.users import User + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Sync DB helpers for OTel gauge callbacks +# +# The OTel Python SDK calls observable-instrument callbacks *synchronously* +# from a background collection thread — async callbacks are NOT supported +# (the SDK does not ``await`` the return value). +# +# Rather than bridging into the async event loop, we run plain synchronous +# SQL queries using the sync engine that is already available at setup time. +# This avoids any cross-thread / cross-loop concerns entirely. +# --------------------------------------------------------------------------- + + +def _count_total_users(db_engine: Engine) -> Optional[int]: + """Return the total number of registered users (sync).""" + with Session(db_engine) as session: + return session.execute(select(func.count()).select_from(User)).scalar() + + +def _count_active_users(db_engine: Engine) -> Optional[int]: + """Return the number of users active within the last 3 minutes (sync).""" + three_minutes_ago = int(time.time()) - 180 + with Session(db_engine) as session: + return session.execute( + select(func.count()).select_from(User).filter(User.last_active_at >= three_minutes_ago) + ).scalar() + + +def _count_users_active_today(db_engine: Engine) -> Optional[int]: + """Return the number of users active since midnight today (sync).""" + now = int(datetime.datetime.now().timestamp()) + today_midnight = now - (now % 86400) + with Session(db_engine) as session: + return session.execute( + select(func.count()).select_from(User).filter(User.last_active_at > today_midnight) + ).scalar() def _build_meter_provider(resource: Resource) -> MeterProvider: @@ -106,7 +150,7 @@ def _build_meter_provider(resource: Resource) -> MeterProvider: return provider -def setup_metrics(app: FastAPI, resource: Resource) -> None: +def setup_metrics(app: FastAPI, resource: Resource, db_engine: Engine) -> None: """Attach OTel metrics middleware to *app* and initialise provider.""" metrics.set_meter_provider(_build_meter_provider(resource)) @@ -124,32 +168,46 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: unit='ms', ) + # -- Observable gauge callbacks ---------------------------------------- + # These are called synchronously by the OTel SDK from a background + # collection thread. They use the sync DB engine directly — no async + # bridging required. + + def observe_total_users( + options: metrics.CallbackOptions, + ) -> Iterable[metrics.Observation]: + try: + value = _count_total_users(db_engine) + if value is not None: + yield metrics.Observation(value=value) + except Exception: + logger.debug('Failed to observe total users', exc_info=True) + def observe_active_users( options: metrics.CallbackOptions, - ) -> Sequence[metrics.Observation]: - return [ - metrics.Observation( - value=Users.get_active_user_count(), - ) - ] + ) -> Iterable[metrics.Observation]: + try: + value = _count_active_users(db_engine) + if value is not None: + yield metrics.Observation(value=value) + except Exception: + logger.debug('Failed to observe active users', exc_info=True) - def observe_total_registered_users( + def observe_users_active_today( options: metrics.CallbackOptions, - ) -> Sequence[metrics.Observation]: - # IMPORTANT: Use get_num_users() for efficient COUNT(*) query. - # Do NOT use len(get_users()["users"]) - it loads ALL user records into memory, - # causing connection pool exhaustion on high-latency databases (e.g., Aurora). - return [ - metrics.Observation( - value=Users.get_num_users() or 0, - ) - ] + ) -> Iterable[metrics.Observation]: + try: + value = _count_users_active_today(db_engine) + if value is not None: + yield metrics.Observation(value=value) + except Exception: + logger.debug('Failed to observe users active today', exc_info=True) meter.create_observable_gauge( name='webui.users.total', description='Total number of registered users', unit='users', - callbacks=[observe_total_registered_users], + callbacks=[observe_total_users], ) meter.create_observable_gauge( @@ -159,11 +217,6 @@ def observe_total_registered_users( callbacks=[observe_active_users], ) - def observe_users_active_today( - options: metrics.CallbackOptions, - ) -> Sequence[metrics.Observation]: - return [metrics.Observation(value=Users.get_num_users_active_today())] - meter.create_observable_gauge( name='webui.users.active.today', description='Number of users active since midnight today', diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py index 744dced2d05..14f10ef97f0 100644 --- a/backend/open_webui/utils/telemetry/setup.py +++ b/backend/open_webui/utils/telemetry/setup.py @@ -55,4 +55,4 @@ def setup(app: FastAPI, db_engine: Engine): # set up metrics only if enabled if ENABLE_OTEL_METRICS: - setup_metrics(app, resource) + setup_metrics(app, resource, db_engine) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 8febdfa31d1..64894432859 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,3 +1,5 @@ +import base64 +import copy import inspect import logging import re @@ -6,6 +8,7 @@ import asyncio import yaml import json +from urllib.parse import quote, urlencode from pydantic import BaseModel from pydantic.fields import FieldInfo @@ -43,14 +46,18 @@ from open_webui.utils.access_control import has_access, has_connection_access from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, FORWARD_SESSION_INFO_HEADER_MESSAGE_ID, + REDIS_KEY_PREFIX, ) -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, get_custom_headers from open_webui.tools.builtin import ( search_web, fetch_url, @@ -79,17 +86,33 @@ query_knowledge_bases, search_knowledge_files, query_knowledge_files, + list_knowledge, view_file, view_knowledge_file, view_skill, + create_tasks, + update_task, + create_automation, + update_automation, + list_automations, + toggle_automation, + delete_automation, + search_calendar_events, + create_calendar_event, + update_calendar_event, + delete_calendar_event, ) -import copy +from open_webui.utils.access_control import has_permission log = logging.getLogger(__name__) -def get_async_tool_function_and_apply_extra_params(function: Callable, extra_params: dict) -> Callable[..., Awaitable]: +# Let no function be called without need, and let what +# it yields justify the cost of running it. +async def get_async_tool_function_and_apply_extra_params( + function: Callable, extra_params: dict +) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} partial_func = partial(function, **extra_params) @@ -126,13 +149,13 @@ async def new_function(*args, **kwargs): return new_function -def get_updated_tool_function(function: Callable, extra_params: dict): +async def get_updated_tool_function(function: Callable, extra_params: dict): # Get the original function and merge updated params __function__ = getattr(function, '__function__', None) __extra_params__ = getattr(function, '__extra_params__', None) if __function__ is not None and __extra_params__ is not None: - return get_async_tool_function_and_apply_extra_params( + return await get_async_tool_function_and_apply_extra_params( __function__, {**__extra_params__, **extra_params}, ) @@ -148,16 +171,16 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr tools_dict = {} # Get user's group memberships for access control checks - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} for tool_id in tool_ids: - tool = Tools.get_tool_by_id(tool_id) + tool = await Tools.get_tool_by_id(tool_id) if tool: # Check access control for local tools if ( not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) and tool.user_id != user.id - and not AccessGrants.has_access( + and not await AccessGrants.has_access( user_id=user.id, resource_type='tool', resource_id=tool.id, @@ -168,10 +191,11 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr log.warning(f'Access denied to tool {tool_id} for user {user.id}') continue - module = request.app.state.TOOLS.get(tool_id, None) - if module is None: - module, _ = load_tool_module_by_id(tool_id) + module = request.app.state.TOOLS.get(tool_id) + if module is None or request.app.state.TOOL_CONTENTS.get(tool_id) != tool.content: + module, _ = await load_tool_module_by_id(tool_id, content=tool.content) request.app.state.TOOLS[tool_id] = module + request.app.state.TOOL_CONTENTS[tool_id] = tool.content __user__ = { **extra_params['__user__'], @@ -179,11 +203,11 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr # Set valves for the tool if hasattr(module, 'valves') and hasattr(module, 'Valves'): - valves = Tools.get_tool_valves_by_id(tool_id) or {} + valves = await Tools.get_tool_valves_by_id(tool_id) or {} module.valves = module.Valves(**valves) if hasattr(module, 'UserValves'): __user__['valves'] = module.UserValves( # type: ignore - **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) + **await Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) ) for spec in tool.specs: @@ -201,7 +225,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr # convert to function that takes only model params and inserts custom params function_name = spec['name'] tool_function = getattr(module, function_name) - callable = get_async_tool_function_and_apply_extra_params( + callable = await get_async_tool_function_and_apply_extra_params( tool_function, { **extra_params, @@ -273,7 +297,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr tool_server_connection = connections[tool_server_idx] # Check access control for tool server - if not has_connection_access(user, tool_server_connection, user_group_ids): + if not await has_connection_access(user, tool_server_connection, user_group_ids): log.warning(f'Access denied to tool server {server_id} for user {user.id}') continue @@ -315,8 +339,9 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr connection_headers = tool_server_connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): - for key, value in connection_headers.items(): - headers[key] = value + metadata = extra_params.get('__metadata__', {}) + custom_headers = get_custom_headers(connection_headers, user, metadata) + headers.update(custom_headers) # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: @@ -327,7 +352,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr if metadata and metadata.get('message_id'): headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = metadata.get('message_id') - def make_tool_function(function_name, tool_server_data, headers): + async def make_tool_function(function_name, tool_server_data, headers): async def tool_function(**kwargs): return await execute_tool_server( url=tool_server_data['url'], @@ -340,9 +365,9 @@ async def tool_function(**kwargs): return tool_function - tool_function = make_tool_function(function_name, tool_server_data, headers) + tool_function = await make_tool_function(function_name, tool_server_data, headers) - callable = get_async_tool_function_and_apply_extra_params( + callable = await get_async_tool_function_and_apply_extra_params( tool_function, {}, ) @@ -369,7 +394,7 @@ async def tool_function(**kwargs): return tools_dict -def get_builtin_tools( +async def get_builtin_tools( request: Request, extra_params: dict, features: dict = None, model: dict = None ) -> dict[str, dict]: """ @@ -391,6 +416,18 @@ def is_builtin_tool_enabled(category: str) -> bool: builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools', {}) return builtin_tools.get(category, True) + # Helper to check user-level feature permission (admins always pass) + user = extra_params.get('__user__', {}) + + async def has_user_permission(feature_key: str) -> bool: + if user.get('role') == 'admin': + return True + return await has_permission( + user.get('id', ''), + f'features.{feature_key}', + request.app.state.config.USER_PERMISSIONS, + ) + # Time utilities - available for date calculations if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) @@ -405,12 +442,15 @@ def is_builtin_tool_enabled(category: str) -> bool: model_knowledge = list(model_knowledge or []) + list(folder_knowledge) if is_builtin_tool_enabled('knowledge'): if model_knowledge: - # Model has attached knowledge - only allow semantic search within it + # Model has attached knowledge - provide discovery, search and semantic tools + builtin_functions.append(list_knowledge) + builtin_functions.append(search_knowledge_files) builtin_functions.append(query_knowledge_files) knowledge_types = {item.get('type') for item in model_knowledge} if 'file' in knowledge_types or 'collection' in knowledge_types: builtin_functions.append(view_file) + builtin_functions.append(view_knowledge_file) if 'note' in knowledge_types: builtin_functions.append(view_note) else: @@ -431,7 +471,11 @@ def is_builtin_tool_enabled(category: str) -> bool: builtin_functions.extend([search_chats, view_chat]) # Add memory tools if builtin category enabled AND enabled for this chat - if is_builtin_tool_enabled('memory') and features.get('memory'): + if ( + is_builtin_tool_enabled('memory') + and (features.get('memory') or get_model_capability('memory', False)) + and await has_user_permission('memories') + ): builtin_functions.extend( [ search_memories, @@ -448,6 +492,7 @@ def is_builtin_tool_enabled(category: str) -> bool: and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False) and get_model_capability('web_search') and features.get('web_search') + and await has_user_permission('web_search') ): builtin_functions.extend([search_web, fetch_url]) @@ -457,6 +502,7 @@ def is_builtin_tool_enabled(category: str) -> bool: and getattr(request.app.state.config, 'ENABLE_IMAGE_GENERATION', False) and get_model_capability('image_generation') and features.get('image_generation') + and await has_user_permission('image_generation') ): builtin_functions.append(generate_image) if ( @@ -464,6 +510,7 @@ def is_builtin_tool_enabled(category: str) -> bool: and getattr(request.app.state.config, 'ENABLE_IMAGE_EDIT', False) and get_model_capability('image_generation') and features.get('image_generation') + and await has_user_permission('image_generation') ): builtin_functions.append(edit_image) @@ -473,15 +520,24 @@ def is_builtin_tool_enabled(category: str) -> bool: and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True) and get_model_capability('code_interpreter') and features.get('code_interpreter') + and await has_user_permission('code_interpreter') ): builtin_functions.append(execute_code) - # Notes tools - search, view, create, and update user's notes (if builtin category enabled AND notes enabled globally) - if is_builtin_tool_enabled('notes') and getattr(request.app.state.config, 'ENABLE_NOTES', False): + # Notes tools - search, view, create, and update user's notes + if ( + is_builtin_tool_enabled('notes') + and getattr(request.app.state.config, 'ENABLE_NOTES', False) + and await has_user_permission('notes') + ): builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) - # Channels tools - search channels and messages (if builtin category enabled AND channels enabled globally) - if is_builtin_tool_enabled('channels') and getattr(request.app.state.config, 'ENABLE_CHANNELS', False): + # Channels tools - search channels and messages + if ( + is_builtin_tool_enabled('channels') + and getattr(request.app.state.config, 'ENABLE_CHANNELS', False) + and await has_user_permission('channels') + ): builtin_functions.extend( [ search_channels, @@ -495,8 +551,32 @@ def is_builtin_tool_enabled(category: str) -> bool: if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) + # Task management - break down complex work into trackable steps + if is_builtin_tool_enabled('tasks'): + builtin_functions.extend([create_tasks, update_task]) + + # Automation tools - create and manage scheduled automations from chat + if ( + is_builtin_tool_enabled('automations') + and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False) + and await has_user_permission('automations') + ): + builtin_functions.extend( + [create_automation, update_automation, list_automations, toggle_automation, delete_automation] + ) + + # Calendar tools - search/create/update/delete events + if ( + is_builtin_tool_enabled('calendar') + and getattr(request.app.state.config, 'ENABLE_CALENDAR', False) + and await has_user_permission('calendar') + ): + builtin_functions.extend( + [search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event] + ) + for func in builtin_functions: - callable = get_async_tool_function_and_apply_extra_params( + callable = await get_async_tool_function_and_apply_extra_params( func, { '__request__': request, @@ -650,7 +730,6 @@ def clean_properties(schema: dict): def clean_openai_tool_schema(spec: dict) -> dict: - import copy cleaned_spec = copy.deepcopy(spec) @@ -683,20 +762,36 @@ def get_tool_specs(tool_module: object) -> list[dict]: return specs -def resolve_schema(schema, components): +# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +# and non-operation path-item fields (summary, description, servers, parameters). +OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'} + + +def resolve_schema(schema, components, resolved_schemas=None): """ Recursively resolves a JSON schema using OpenAPI components. """ if not schema: return {} + if resolved_schemas is None: + resolved_schemas = set() + if '$ref' in schema: ref_path = schema['$ref'] + schema_name = ref_path.split('/')[-1] + + if schema_name in resolved_schemas: + # Avoid infinite recursion on circular references + return {} + + resolved_schemas.add(schema_name) + ref_parts = ref_path.strip('#/').split('/') resolved = components for part in ref_parts[1:]: # Skip the initial 'components' resolved = resolved.get(part, {}) - return resolve_schema(resolved, components) + return resolve_schema(resolved, components, resolved_schemas) resolved_schema = copy.deepcopy(schema) @@ -708,6 +803,13 @@ def resolve_schema(schema, components): if 'items' in resolved_schema: resolved_schema['items'] = resolve_schema(resolved_schema['items'], components) + # Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref + for keyword in ('oneOf', 'anyOf', 'allOf'): + if keyword in resolved_schema and isinstance(resolved_schema[keyword], list): + resolved_schema[keyword] = [ + resolve_schema(inner, components, resolved_schemas) for inner in resolved_schema[keyword] + ] + return resolved_schema @@ -724,7 +826,20 @@ def convert_openapi_to_tool_payload(openapi_spec): tool_payload = [] for path, methods in openapi_spec.get('paths', {}).items(): + if not isinstance(methods, dict): + continue + + # Path-level parameters apply to all operations under this path + # unless overridden at the operation level (matched by name + in). + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + for method, operation in methods.items(): + if method not in OPENAPI_HTTP_METHODS: + continue + if not isinstance(operation, dict): + continue if operation.get('operationId'): tool = { 'name': operation.get('operationId'), @@ -735,7 +850,21 @@ def convert_openapi_to_tool_payload(openapi_spec): 'parameters': {'type': 'object', 'properties': {}, 'required': []}, } - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters. + # Operation-level params override path-level params with the + # same (name, in) pair per the OpenAPI spec. + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -744,7 +873,7 @@ def convert_openapi_to_tool_payload(openapi_spec): if not description: description = param.get('description') or '' if param_schema.get('enum') and isinstance(param_schema.get('enum'), list): - description += f'. Possible values: {", ".join(param_schema.get("enum"))}' + description += f'. Possible values: {", ".join(str(v) for v in param_schema.get("enum"))}' param_property = { 'type': param_schema.get('type') or 'string', 'description': description, @@ -784,27 +913,40 @@ def convert_openapi_to_tool_payload(openapi_spec): async def set_tool_servers(request: Request): - request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) + try: + request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) + except Exception as e: + log.error(f'Error fetching tool server data: {e}') + request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or [] - if request.app.state.redis is not None: - await request.app.state.redis.set('tool_servers', json.dumps(request.app.state.TOOL_SERVERS)) + try: + if request.app.state.redis is not None: + await request.app.state.redis.set( + f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS) + ) + except Exception as e: + log.error(f'Error caching tool_servers to Redis: {e}') return request.app.state.TOOL_SERVERS async def get_tool_servers(request: Request): - tool_servers = [] - if request.app.state.redis is not None: - try: - tool_servers = json.loads(await request.app.state.redis.get('tool_servers')) - request.app.state.TOOL_SERVERS = tool_servers - except Exception as e: - log.error(f'Error fetching tool_servers from Redis: {e}') - - if not tool_servers: - tool_servers = await set_tool_servers(request) - - return tool_servers + try: + tool_servers = [] + if request.app.state.redis is not None: + try: + tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')) + request.app.state.TOOL_SERVERS = tool_servers + except Exception as e: + log.error(f'Error fetching tool_servers from Redis: {e}') + + if not tool_servers: + tool_servers = await set_tool_servers(request) + + return tool_servers + except Exception as e: + log.error(f'Failed to load tool servers, skipping: {e}') + return getattr(request.app.state, 'TOOL_SERVERS', None) or [] async def get_terminal_cwd( @@ -819,7 +961,9 @@ async def get_terminal_cwd( timeout=aiohttp.ClientTimeout(total=5), trust_env=True, ) as session: - async with session.get(cwd_url, headers=headers, cookies=cookies or {}) as resp: + async with session.get( + cwd_url, headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: if resp.status == 200: data = await resp.json() return data.get('cwd') @@ -828,6 +972,43 @@ async def get_terminal_cwd( return None +async def get_terminal_system_prompt( + base_url: str, + headers: dict, + cookies: Optional[dict] = None, +) -> Optional[str]: + """Fetch the system prompt from a terminal server. + + Checks ``/api/config`` for the ``system`` feature flag first; + only fetches ``/system`` if the flag is present. Returns *None* + silently when the server doesn't support the endpoint. + """ + base = base_url.rstrip('/') + try: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=3), + trust_env=True, + ) as session: + # 1. Check feature flag + async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + if resp.status != 200: + return None + config = await resp.json() + if not config.get('features', {}).get('system'): + return None + + # 2. Fetch system prompt + async with session.get( + f'{base}/system', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('prompt') + except Exception as e: + log.debug(f'Failed to fetch terminal system prompt: {e}') + return None + + async def set_terminal_servers(request: Request): """Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS.""" connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] @@ -867,8 +1048,29 @@ async def set_terminal_servers(request: Request): request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs) + # Fetch system prompts concurrently (runs at cache time, not per-request) + connections_by_id = {c.get('id'): c for c in connections if c.get('id')} + + async def _fetch_system_prompt(server): + connection = connections_by_id.get(server.get('id')) + if not connection: + return + headers = {} + if connection.get('auth_type', 'bearer') == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + prompt = await get_terminal_system_prompt(server['url'], headers) + if prompt: + server['system_prompt'] = prompt + + await asyncio.gather( + *[_fetch_system_prompt(s) for s in request.app.state.TERMINAL_SERVERS], + return_exceptions=True, + ) + if request.app.state.redis is not None: - await request.app.state.redis.set('terminal_servers', json.dumps(request.app.state.TERMINAL_SERVERS)) + await request.app.state.redis.set( + f'{REDIS_KEY_PREFIX}:terminal_servers', json.dumps(request.app.state.TERMINAL_SERVERS) + ) return request.app.state.TERMINAL_SERVERS @@ -878,7 +1080,7 @@ async def get_terminal_servers(request: Request): terminal_servers = [] if request.app.state.redis is not None: try: - terminal_servers = json.loads(await request.app.state.redis.get('terminal_servers')) + terminal_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')) request.app.state.TERMINAL_SERVERS = terminal_servers except Exception as e: log.error(f'Error fetching terminal_servers from Redis: {e}') @@ -894,7 +1096,7 @@ async def get_terminal_tools( terminal_id: str, user: UserModel, extra_params: dict, -) -> dict[str, dict]: +) -> dict[str, dict] | tuple[dict[str, dict], Optional[str]]: """Resolve tools for a terminal server identified by terminal_id. - Finds the connection in TERMINAL_SERVER_CONNECTIONS @@ -908,8 +1110,8 @@ async def get_terminal_tools( log.warning(f'Terminal server not found: {terminal_id}') return {} - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - if not has_connection_access(user, connection, user_group_ids): + user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)} + if not await has_connection_access(user, connection, user_group_ids): log.warning(f'Access denied to terminal {terminal_id} for user {user.id}') return {} @@ -941,20 +1143,27 @@ async def get_terminal_tools( headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' # auth_type == "none": no Authorization header + system_prompt = server_data.get('system_prompt') + + # Use chat_id as the per-session key for cwd tracking + metadata = extra_params.get('__metadata__', {}) + session_id = metadata.get('chat_id') + if session_id: + headers['X-Session-Id'] = session_id + terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies) tools_dict = {} for spec in specs: function_name = spec['name'] - - # Inject CWD into run_command description tool_spec = clean_openai_tool_schema(spec) + if function_name == 'run_command' and terminal_cwd: tool_spec['description'] = ( tool_spec.get('description', '') + f'\n\nThe current working directory is: {terminal_cwd}' ) - def make_tool_function(fn_name, srv_data, hdrs, cks): + async def make_tool_function(fn_name, srv_data, hdrs, cks): async def tool_function(**kwargs): return await execute_tool_server( url=srv_data['url'], @@ -967,8 +1176,8 @@ async def tool_function(**kwargs): return tool_function - tool_function = make_tool_function(function_name, server_data, headers, cookies) - callable = get_async_tool_function_and_apply_extra_params(tool_function, {}) + tool_function = await make_tool_function(function_name, server_data, headers, cookies) + callable = await get_async_tool_function_and_apply_extra_params(tool_function, {}) tools_dict[function_name] = { 'tool_id': f'terminal:{terminal_id}', @@ -977,7 +1186,7 @@ async def tool_function(**kwargs): 'type': 'terminal', } - return tools_dict + return tools_dict, system_prompt async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]: @@ -998,22 +1207,17 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A error_body = await response.json() raise Exception(error_body) - text_content = None + text_content = await response.text() # Check if URL ends with .yaml or .yml to determine format if url.lower().endswith(('.yaml', '.yml')): - text_content = await response.text() res = yaml.safe_load(text_content) else: - text_content = await response.text() - - try: - res = json.loads(text_content) - except json.JSONDecodeError: try: + res = json.loads(text_content) + except json.JSONDecodeError: + # Fall back to YAML for non-.yml URLs that aren't valid JSON res = yaml.safe_load(text_content) - except Exception as e: - raise e except Exception as err: log.exception(f'Could not fetch tool server spec from {url}') @@ -1141,7 +1345,11 @@ async def execute_tool_server( matching_route = None for route_path, methods in paths.items(): + if not isinstance(methods, dict): + continue for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue if isinstance(operation, dict) and operation.get('operationId') == name: matching_route = (route_path, methods) break @@ -1155,6 +1363,10 @@ async def execute_tool_server( method_entry = None for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue + if not isinstance(operation, dict): + continue if operation.get('operationId') == name: method_entry = (http_method.lower(), operation) break @@ -1168,7 +1380,22 @@ async def execute_tool_server( query_params = {} body_params = {} - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters for execution. + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -1176,24 +1403,27 @@ async def execute_tool_server( if param_name in params: if param_in == 'path': path_params[param_name] = params[param_name] - elif param_in == 'query': - if params[param_name] is not None: - query_params[param_name] = params[param_name] + if param_in == 'query': + value = params[param_name] + # Skip empty values for optional params (LLMs sometimes + # pass "" instead of omitting optional parameters). + if value is None or (value == '' and not param.get('required')): + continue + query_params[param_name] = value final_url = f'{url.rstrip("/")}{route_path}' for key, value in path_params.items(): - final_url = final_url.replace(f'{{{key}}}', str(value)) + final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe='')) if query_params: - query_string = '&'.join(f'{k}={v}' for k, v in query_params.items()) - final_url = f'{final_url}?{query_string}' + final_url = f'{final_url}?{urlencode(query_params)}' if operation.get('requestBody', {}).get('content'): if params: body_params = params async with aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) ) as session: request_method = getattr(session, http_method.lower()) @@ -1204,7 +1434,7 @@ async def execute_tool_server( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if response.status >= 400: text = await response.text() @@ -1213,7 +1443,13 @@ async def execute_tool_server( try: response_data = await response.json() except Exception: - response_data = await response.text() + content_type = response.headers.get('Content-Type', '').split(';')[0].strip() + if content_type.startswith('text/') or not content_type: + response_data = await response.text() + else: + raw = await response.read() + b64 = base64.b64encode(raw).decode() + response_data = f'data:{content_type};base64,{b64}' response_headers = response.headers return (response_data, response_headers) @@ -1223,7 +1459,7 @@ async def execute_tool_server( headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, - allow_redirects=False, + allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS, ) as response: if response.status >= 400: text = await response.text() @@ -1232,7 +1468,13 @@ async def execute_tool_server( try: response_data = await response.json() except Exception: - response_data = await response.text() + content_type = response.headers.get('Content-Type', '').split(';')[0].strip() + if content_type.startswith('text/') or not content_type: + response_data = await response.text() + else: + raw = await response.read() + b64 = base64.b64encode(raw).decode() + response_data = f'data:{content_type};base64,{b64}' response_headers = response.headers return (response_data, response_headers) diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index c2064de257b..68a56dfadc9 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -1,9 +1,26 @@ """Validation utilities for user-supplied input.""" -# Known static asset paths used as default profile images -_ALLOWED_STATIC_PATHS = ( - '/user.png', - '/static/favicon.png', +import re +from urllib.parse import urlparse + +from open_webui.env import PROFILE_IMAGE_ALLOWED_MIME_TYPES + +_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$') + +# Data-URI prefix validator derived from PROFILE_IMAGE_ALLOWED_MIME_TYPES. +_mime_suffixes = '|'.join(re.escape(t.split('/')[-1]) for t in sorted(PROFILE_IMAGE_ALLOWED_MIME_TYPES)) +_SAFE_DATA_URI_RE = re.compile(rf'^data:image/({_mime_suffixes});base64,', re.IGNORECASE) + +# Exact relative paths accepted as profile images. These are the only +# static-asset paths OWUI itself assigns; no prefix/wildcard matching is +# used so that arbitrary relative paths cannot trigger authenticated GETs +# against internal endpoints when rendered as ```` sources. +_SAFE_STATIC_PATHS = frozenset( + { + '/user.png', + '/favicon.png', + '/static/favicon.png', + } ) @@ -13,24 +30,49 @@ def validate_profile_image_url(url: str) -> str: Allowed formats: - Empty string (falls back to default avatar) - - data:image/* URIs (base64-encoded uploads from the frontend) - - Known static asset paths (/user.png, /static/favicon.png) + - Known static-asset paths assigned by OWUI (exact match) + - The OWUI profile-image API route ``/api/v1/users/{id}/profile/image`` + - ``http://`` and ``https://`` URLs with a valid hostname + - ``data:image/{png,jpeg,gif,webp};base64,...`` URIs - Returns the url unchanged if valid, raises ValueError otherwise. + Everything else is rejected, including: + - Dangerous schemes (javascript:, file:, ftp:, …) + - SVG data URIs (can contain embedded scripts) + - Arbitrary relative paths (prevents authenticated GET triggers) + - Scheme-relative URLs (``//host/path``) """ if not url: return url - _ALLOWED_DATA_PREFIXES = ( - 'data:image/png', - 'data:image/jpeg', - 'data:image/gif', - 'data:image/webp', - ) - if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES): + # --- Relative paths (exact match + anchored regex only) ----------- + + if url in _SAFE_STATIC_PATHS: return url - if url in _ALLOWED_STATIC_PATHS: + if _USER_PROFILE_IMAGE_RE.match(url): return url - raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.') + # --- Absolute URLs ------------------------------------------------- + + # urlparse normalises the scheme to lowercase, giving us + # case-insensitive scheme matching for free. + parsed = urlparse(url) + + # External images served over HTTP(S), e.g. OAuth provider avatars. + # Require a non-empty hostname (not just netloc, which can be ":80" + # for a URL like http://:80/path with no actual host). + if parsed.scheme in ('http', 'https'): + if not parsed.hostname: + raise ValueError('Invalid profile image URL: HTTP(S) URLs must include a host.') + return url + + # Base64-encoded raster images uploaded via the frontend. + # The regex enforces the ;base64, boundary and is case-insensitive + # per the data-URI / MIME-type specs. + if _SAFE_DATA_URI_RE.match(url): + return url + + raise ValueError( + 'Invalid profile image URL: must be a known internal path, ' + 'an HTTP(S) URL with a host, or a data:image URI (png/jpeg/gif/webp).' + ) diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 800450dfd39..ee7f3ab3b2e 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -3,11 +3,13 @@ import aiohttp from open_webui.config import WEBUI_FAVICON_URL -from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION +from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, VERSION log = logging.getLogger(__name__) +# Let this message reach those for whom it was written, and +# may no network partition deny the word its destination. async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool: try: log.debug(f'post_webhook: {url}, {message}, {event_data}') @@ -51,7 +53,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.post(url, json=payload) as r: + async with session.post(url, json=payload, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r: r_text = await r.text() r.raise_for_status() log.debug(f'r.text: {r_text}') diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 13bd199f08e..05c28deba66 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -13,19 +13,22 @@ cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.9 +authlib==1.6.10 -requests==2.32.5 -aiohttp==3.13.2 # do not update to 3.13.3 - broken +requests==2.33.1 +aiohttp==3.13.5 # do not update to 3.13.3 - broken async-timeout aiocache aiofiles starlette-compress==1.7.0 -Brotli==1.1.0 +Brotli==1.2.0 +brotlicffi==1.2.0.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 sqlalchemy==2.0.48 +aiosqlite==0.21.0 +psycopg[binary]==3.2.9 alembic==1.18.4 peewee==3.19.0 peewee-migrate==1.14.3 @@ -50,7 +53,7 @@ langchain-text-splitters==1.1.1 fake-useragent==2.2.0 chromadb==1.5.2 -black==26.1.0 +black==26.3.1 pydub chardet==5.2.0 beautifulsoup4 diff --git a/backend/requirements.txt b/backend/requirements.txt index 103a3bd8689..a7d2b1cb537 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,30 +6,33 @@ itsdangerous==2.2.0 python-socketio==5.16.1 python-jose==3.5.0 -cryptography +cryptography==46.0.5 bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 -authlib==1.6.9 +authlib==1.6.10 -requests==2.32.5 -aiohttp==3.13.2 # do not update to 3.13.3 - broken -async-timeout -aiocache -aiofiles +requests==2.33.1 +aiohttp==3.13.5 # do not update to 3.13.3 - broken +async-timeout==5.0.1 +aiocache==0.12.3 +aiofiles==25.1.0 starlette-compress==1.7.0 -Brotli==1.1.0 +Brotli==1.2.0 +brotlicffi==1.2.0.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 python-mimeparse==2.0.0 -sqlalchemy==2.0.48 +sqlalchemy[asyncio]==2.0.48 +aiosqlite==0.21.0 +psycopg[binary]==3.2.9 alembic==1.18.4 peewee==3.19.0 peewee-migrate==1.14.3 pycrdt==0.12.47 -redis +redis==7.4.0 APScheduler==3.11.2 RestrictedPython==8.1 @@ -39,11 +42,11 @@ loguru==0.7.3 asgiref==3.11.1 # AI libraries -tiktoken +tiktoken==0.12.0 mcp==1.26.0 -openai -anthropic +openai==2.29.0 +anthropic==0.86.0 google-genai==1.66.0 langchain==1.2.10 @@ -56,9 +59,9 @@ chromadb==1.5.2 weaviate-client==4.20.3 opensearch-py==3.1.0 -transformers==5.3.0 -sentence-transformers==5.2.3 -accelerate +transformers==5.5.4 +sentence-transformers==5.4.0 +accelerate==1.13.0 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.2 @@ -74,15 +77,15 @@ unstructured==0.18.31 nltk==3.9.3 Markdown==3.10.2 -beautifulsoup4 +beautifulsoup4==4.14.3 pypandoc==1.16.2 pandas==3.0.1 openpyxl==3.1.5 pyxlsb==1.0.10 xlrd==2.0.2 validators==0.35.0 -psutil -sentencepiece +psutil==7.2.2 +sentencepiece==0.2.1 soundfile==0.13.1 pillow==12.1.1 @@ -93,11 +96,11 @@ rank-bm25==0.2.2 onnxruntime==1.24.3 faster-whisper==1.2.1 -black==26.1.0 +black==26.3.1 youtube-transcript-api==1.2.4 pytube==15.0.0 -pydub +pydub==0.25.1 ddgs==9.11.3 azure-ai-documentintelligence==1.0.2 @@ -106,15 +109,15 @@ azure-storage-blob==12.28.0 azure-search-documents==11.6.0 ## Google Drive -google-api-python-client -google-auth-httplib2 -google-auth-oauthlib +google-api-python-client==2.193.0 +google-auth-httplib2==0.3.0 +google-auth-oauthlib==1.3.0 googleapis-common-protos==1.72.0 google-cloud-storage==3.9.0 ## Databases -pymongo +pymongo==4.16.0 psycopg2-binary==2.9.11 pgvector==0.4.2 @@ -142,9 +145,6 @@ pytest-docker~=3.2.5 ## LDAP ldap3==2.9.1 -## Firecrawl -firecrawl-py==4.18.0 - ## Trace opentelemetry-api==1.40.0 opentelemetry-sdk==1.40.0 diff --git a/backend/start.sh b/backend/start.sh index 31e87c95577..00d02f326b7 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -50,7 +50,7 @@ if [ -n "$SPACE_ID" ]; then echo "Configuring for HuggingFace Space deployment" if [ -n "$ADMIN_USER_EMAIL" ] && [ -n "$ADMIN_USER_PASSWORD" ]; then echo "Admin user configured, creating" - WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' & + WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" & webui_pid=$! echo "Waiting for webui to start..." while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do @@ -83,5 +83,5 @@ fi WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \ --host "$HOST" \ --port "$PORT" \ - --forwarded-allow-ips '*' \ + --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \ "${ARGS[@]}" \ No newline at end of file diff --git a/backend/start_windows.bat b/backend/start_windows.bat index f350d11cd19..c5f96e0e6f8 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -24,6 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" ( IF "%PORT%"=="" SET PORT=8080 IF "%HOST%"=="" SET HOST=0.0.0.0 +IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS='*'" SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" @@ -46,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " ( :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1 -uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --workers %UVICORN_WORKERS% --ws auto +uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips %FORWARDED_ALLOW_IPS% --workers %UVICORN_WORKERS% --ws auto :: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index 5b37c8f8c85..00000000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,88 +0,0 @@ -# Contributing to Open WebUI - -🚀 **Welcome, Contributors!** 🚀 - -Your interest in contributing to Open WebUI is greatly appreciated. This document is here to guide you through the process, ensuring your contributions enhance the project effectively. Let's make Open WebUI even better, together! - -## 📌 Key Points - -### 🦙 Ollama vs. Open WebUI - -It's crucial to distinguish between Ollama and Open WebUI: - -- **Open WebUI** focuses on providing an intuitive and responsive web interface for chat interactions. -- **Ollama** is the underlying technology that powers these interactions. - -If your issue or contribution pertains directly to the core Ollama technology, please direct it to the appropriate [Ollama project repository](https://ollama.com/). Open WebUI's repository is dedicated to the web interface aspect only. - -### 🚨 Reporting Issues - -Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently. - -> [!IMPORTANT] -> -> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking. -> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue. - -> [!WARNING] -> Reporting vulnerabilities is not wanted through Issues! -> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements. - -### 🧭 Scope of Support - -We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience. - -- **Docker Deployment Support**: Open WebUI supports Docker deployment. Familiarity with Docker is assumed. For Docker basics, please refer to the [official Docker documentation](https://docs.docker.com/get-started/overview/). - -- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects. - -- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). - -## 💡 Contributing - -Looking to contribute? Great! Here's how you can help: - -### 🛠 Pull Requests - -We welcome pull requests. Before submitting one, please: - -1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose). -2. Follow the project's coding standards and include tests for new features. -3. Update documentation as necessary. -4. Write clear, descriptive commit messages. -5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward. - -> [!NOTE] -> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!). - -### 📚 Documentation & Tutorials - -Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI. - -Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs). -We welcome tutorials, guides and other documentation improvements! - -### 🌐 Translations and Internationalization - -Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project. - -We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language. - -To add a new language: - -- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`. -- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object. -- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`. - -> [!NOTE] -> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow. - -### 🤔 Questions & Feedback - -Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help! - -## 🙏 Thank You! - -Your contributions, big or small, make a significant impact on Open WebUI. We're excited to see what you bring to the project! - -Together, let's create an even more powerful tool for the community. 🌟 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4113c35a78f..00000000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Project workflow - -[![](https://mermaid.ink/img/pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg?type=png)](https://mermaid.live/edit#pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 415e09f16e8..575388f0cd6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -16,13 +16,26 @@ Based on a precedent of an unacceptable degree of spamming and unsolicited commu Any reports or solicitations arriving from sources other than our designated GitHub repository will be dismissed without consideration. We’ve seen how external engagements can dilute and compromise the integrity of community-driven projects, and we’re not here to gamble with the security and privacy of our user community. +## Foreign CNAs and Vendor Disposition + +When a report is filed via GitHub Security Advisories and the maintainers close it as out-of-scope per this policy, that closure is the **vendor's disposition** of the issue. A CVE Numbering Authority (CNA) that mints a CVE for such an issue without reflecting that vendor disposition in the resulting record is acting against vendor disposition. + +We respond to such records by: + +1. Filing a **REJECT** request with the CVE Program (with **DISPUTED** as fallback); +2. Cataloging the record publicly, naming the issuing CNA; +3. Refusing to provide vendor statements, version mappings, fix references, or any other coordination that would lend authority to the record; +4. Escalating repeated patterns from a single CNA to the CVE Program Root. + +**Channel compliance does not entitle a CNA to override vendor disposition.** Reporters who escalate a closed-as-out-of-scope GHSA report to a third-party CNA after vendor disposition has been issued are likewise considered to have acted against vendor disposition, and will be permanently barred from future GHSA submissions. + ## Reporting a Vulnerability Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable. We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**: -1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. +1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. A vulnerability must cross at least one of the security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation). **These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them.** 2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted. @@ -33,7 +46,7 @@ We appreciate the community's interest in identifying potential vulnerabilities. > [!NOTE] > A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show: > -> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation) +> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) > 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.) > 3. What actions the attacker can now perform > 4. What data/action becomes possible that should not be possible @@ -76,16 +89,16 @@ Your remediation guidance can include, for example: > > **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report. -9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** +9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks, functions), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** > [!NOTE] > Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions, > **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research. -10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability. Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. Granting a user the ability to create Tools is equivalent to giving them shell access to the server. Reports that describe the expected behavior of `exec()` in the Tools/Functions pipeline as a vulnerability will be closed as **not a vulnerability / intended behavior**. This applies to both direct code execution and frontmatter-based package installation (`pip install`). +10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also rule 7, [Threat Model Understanding](#threat-model-understanding-required)). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. **Granting a user the ability to create Tools is equivalent to giving them shell access to the server**. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by rule 9 ([Admin Actions Are Out of Scope](#admin-actions-are-out-of-scope)). More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`). > [!IMPORTANT] -> **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. +> **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. For more details, see our [Plugin Security documentation](https://docs.openwebui.com/features/extensibility/plugin/). 11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. @@ -105,13 +118,52 @@ Your remediation guidance can include, for example: > - wrote comments with conflicting information > - used illogical and conflicting arguments -**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. +12. **Self-Affecting Issues Are Not Vulnerabilities:** A vulnerability requires crossing a security boundary that affects **a party other than the reporter**. Crossing one of the five recognized security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) only against the reporter's own data, account, session, or environment is **not a vulnerability** - it is a bug, and belongs in the [Issue Tracker](https://github.com/open-webui/open-webui/issues), not in a security report. + +> [!NOTE] +> This rule is about **who is harmed**, not about severity. A user modifying or deleting their own data, impairing their own session, observing their own configuration, or disabling security controls on their own account is out of scope under this rule, regardless of impact. +> +> If the same action also affects another user, the operator, the host system, or shared resources, identify that second party clearly in the PoC, and we want to hear about it. + +**Non-compliant submissions will be closed, and repeat or extreme violators may be banned from submitting reports.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. ## Where to report the vulnerability If you want to report a vulnerability and can meet the outlined requirements, [open a vulnerability report here](https://github.com/open-webui/open-webui/security/advisories/new). If you feel like you are not able to follow ALL outlined requirements for vulnerability-specific reasons, still do report it, we will check every report either way. +## Expected Response Timeframe + +Due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by an unbelievably high number of AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities. + +**Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. + +## Report Handling + +If you report a valid vulnerability that somebody else reported before you, we will close your report as a duplicate. The earliest filing is the one we will handle going forward, and we will not publish multiple advisories for the same vulnerability. + +When multiple independent reporters describe the same vulnerability class but each demonstrates a **distinct and separate exploitation vector** — for example, the same missing authorization check reached through different endpoints — we will consolidate them into the earliest filing and credit every reporter who demonstrated a distinct path. Only one CVE will be issued for the consolidated advisory. + +### Why duplicate reports don't receive credit + +We credit only the earliest filer of a given vulnerability: + +1. **The first report did the work.** By the time a later report arrives, triage and fix are already in motion. Later reports don't change the outcome or timeline; crediting them would misrepresent what moved the fix. +2. **Credit-for-duplicates incentivizes flooding.** If similar-but-later filings earn credit, the rational play is to skim open advisories and file variations. We already see this pressure — the first-filer rule is what limits it. +3. **Co-discovery is different from duplication.** Multiple reporters **are credited** on one advisory **when each contributes a _distinct_ finding** — different vector, different affected component, different sub-path the earlier filing does not cover. That is the consolidation rule above. Filing a duplicate of an existing report is not co-discovery. + +## Confidential Disclosure + +Vulnerability reports submitted through GitHub Security Advisories are **private and confidential**. Public disclosure of **ANY** details related to a submitted vulnerability report is **STRICTLY PROHIBITED** until the advisory has been **fully published** — not merely when a CVE ID has been assigned, but when the advisory itself is publicly visible. + +This prohibition applies to **all channels**, including but not limited to: + +- Comments on pull requests, issues, or discussions (on GitHub or elsewhere) +- Social media, blogs, forums, or any other website +- Discord, Reddit, or any other platform, website or service + +Premature disclosure undermines the security of all Open WebUI users and **violates the trust** inherent in the responsible disclosure process. **Reporters who publicly disclose vulnerability details before official publication may be permanently banned from future reporting.** + ## Product Security And For Non-Vulnerability Related Security Concerns: If your concern does not meet the vulnerability requirements outlined above, is not a vulnerability, **but is still related to security concerns**, then use the following channels instead: @@ -131,7 +183,7 @@ If your concern does not meet the vulnerability requirements outlined above, is - Feature requests for optional security enhancements (2FA, audit logging, etc.) - General security questions about production deployment -Please use the adequate channel for your specific issue - e.g. best-practice guidance or **dditional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs)**, and **feature requests into the Main Repository as an issue or discussion**. +Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs), and feature requests into the Main Repository as an issue or discussion. We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon. @@ -139,4 +191,4 @@ For any other immediate concerns and questions, please create an issue in our [i --- -_Last updated on **2026-03-15**._ +_Last updated on **2026-05-04**._ diff --git a/docs/apache.md b/docs/apache.md deleted file mode 100644 index bdf119b5b66..00000000000 --- a/docs/apache.md +++ /dev/null @@ -1,205 +0,0 @@ -# Hosting UI and Models separately - -Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: - -# Open WebUI Configuration - -## UI Configuration - -For the UI configuration, you can set up the Apache VirtualHost as follows: - -``` -# Assuming you have a website hosting this UI at "server.com" - - ServerName server.com - DocumentRoot /home/server/public_html - - ProxyPass / http://server.com:3000/ nocanon - ProxyPassReverse / http://server.com:3000/ - # Needed after 0.5 - ProxyPass / ws://server.com:3000/ nocanon - ProxyPassReverse / ws://server.com:3000/ - - -``` - -Enable the site first before you can request SSL: - -`a2ensite server.com.conf` # this will enable the site. a2ensite is short for "Apache 2 Enable Site" - -``` -# For SSL - - ServerName server.com - DocumentRoot /home/server/public_html - - ProxyPass / http://server.com:3000/ nocanon - ProxyPassReverse / http://server.com:3000/ - # Needed after 0.5 - ProxyPass / ws://server.com:3000/ nocanon - ProxyPassReverse / ws://server.com:3000/ - - SSLEngine on - SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert - SSLCertificateKeyFile /etc/ssl/virtualmin/170514456861234/ssl.key - SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1 - - SSLProxyEngine on - SSLCACertificateFile /etc/ssl/virtualmin/170514456865864/ssl.ca - - -``` - -I'm using virtualmin here for my SSL clusters, but you can also use certbot directly or your preferred SSL method. To use SSL: - -### Prerequisites. - -Run the following commands: - -`snap install certbot --classic` -`snap apt install python3-certbot-apache` (this will install the apache plugin). - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` - -Create server.com.conf if it is not yet already created, containing the above `` configuration (it should match your case. Modify as necessary). Use the one without the SSL: - -Once it's created, run `certbot --apache -d server.com`, this will request and add/create an SSL keys for you as well as create the server.com.le-ssl.conf - -# Configuring Ollama Server - -On your latest installation of Ollama, make sure that you have setup your api server from the official Ollama reference: - -[Ollama FAQ](https://github.com/jmorganca/ollama/blob/main/docs/faq.md) - -### TL;DR - -The guide doesn't seem to match the current updated service file on linux. So, we will address it here: - -Unless when you're compiling Ollama from source, installing with the standard install `curl https://ollama.com/install.sh | sh` creates a file called `ollama.service` in /etc/systemd/system. You can use nano to edit the file: - -``` -sudo nano /etc/systemd/system/ollama.service -``` - -Add the following lines: - -``` -Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify -``` - -For instance: - -``` -[Unit] -Description=Ollama Service -After=network-online.target - -[Service] -ExecStart=/usr/local/bin/ollama serve -Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify 192.168.254.109:DIFFERENT_PORT, format -Environment="OLLAMA_ORIGINS=http://192.168.254.106:11434,https://models.server.city" # this line is optional -User=ollama -Group=ollama -Restart=always -RestartSec=3 -Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/s> - -[Install] -WantedBy=default.target -``` - -Save the file by pressing CTRL+S, then press CTRL+X - -When your computer restarts, the Ollama server will now be listening on the IP:PORT you specified, in this case 0.0.0.0:11434, or 192.168.254.106:11434 (whatever your local IP address is). Make sure that your router is correctly configured to serve pages from that local IP by forwarding 11434 to your local IP server. - -# Ollama Model Configuration - -## For the Ollama model configuration, use the following Apache VirtualHost setup: - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` - -`nano models.server.city.conf` # match this with your ollama server domain - -Add the following virtualhost containing this example (modify as needed): - -``` - -# Assuming you have a website hosting this UI at "models.server.city" - - - DocumentRoot "/var/www/html/" - ServerName models.server.city - - Options None - Require all granted - - - ProxyRequests Off - ProxyPreserveHost On - ProxyAddHeaders On - SSLProxyEngine on - - ProxyPass / http://server.city:1000/ nocanon # or port 11434 - ProxyPassReverse / http://server.city:1000/ # or port 11434 - - SSLCertificateFile /etc/letsencrypt/live/models.server.city/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/models.server.city/privkey.pem - Include /etc/letsencrypt/options-ssl-apache.conf - - -``` - -You may need to enable the site first (if you haven't done so yet) before you can request SSL: - -`a2ensite models.server.city.conf` - -#### For the SSL part of Ollama server - -Run the following commands: - -Navigate to the apache sites-available directory: - -`cd /etc/apache2/sites-available/` -`certbot --apache -d server.com` - -``` - - DocumentRoot "/var/www/html/" - ServerName models.server.city - - Options None - Require all granted - - - ProxyRequests Off - ProxyPreserveHost On - ProxyAddHeaders On - SSLProxyEngine on - - ProxyPass / http://server.city:1000/ nocanon # or port 11434 - ProxyPassReverse / http://server.city:1000/ # or port 11434 - - RewriteEngine on - RewriteCond %{SERVER_NAME} =models.server.city - RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] - - -``` - -Don't forget to restart/reload Apache with `systemctl reload apache2` - -Open your site at https://server.com! - -**Congratulations**, your _**Open-AI-like Chat-GPT style UI**_ is now serving AI with RAG, RBAC and multimodal features! Download Ollama models if you haven't yet done so! - -If you encounter any misconfiguration or errors, please file an issue or engage with our discussion. There are a lot of friendly developers here to assist you. - -Let's make this UI much more user friendly for everyone! - -Thanks for making open-webui your UI Choice for AI! - -This doc is made by **Bob Reyes**, your **Open-WebUI** fan from the Philippines. diff --git a/docs/oauth-google-groups.md b/docs/oauth-google-groups.md new file mode 100644 index 00000000000..40bc62ba5bf --- /dev/null +++ b/docs/oauth-google-groups.md @@ -0,0 +1,95 @@ +# Google OAuth with Cloud Identity Groups Support + +This example demonstrates how to configure Open WebUI to use Google OAuth with Cloud Identity API for group-based role management. + +## Configuration + +### Environment Variables + +```bash +# Google OAuth Configuration +GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-google-client-secret" + +# IMPORTANT: Include the Cloud Identity Groups scope +GOOGLE_OAUTH_SCOPE="openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly" + +# Enable OAuth features +ENABLE_OAUTH_SIGNUP=true +ENABLE_OAUTH_ROLE_MANAGEMENT=true +ENABLE_OAUTH_GROUP_MANAGEMENT=true + +# Configure admin roles using Google group emails +OAUTH_ADMIN_ROLES="admin@yourcompany.com,superadmin@yourcompany.com" +OAUTH_ALLOWED_ROLES="users@yourcompany.com,employees@yourcompany.com" + +# Optional: Configure group creation +ENABLE_OAUTH_GROUP_CREATION=true +``` + +## How It Works + +1. **Scope Detection**: When a user logs in with Google OAuth, the system checks if the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope is present in `GOOGLE_OAUTH_SCOPE`. + +2. **Groups Fetching**: If the scope is present, the system uses the Google Cloud Identity API to fetch all groups the user belongs to, instead of relying on claims in the OAuth token. + +3. **Role Assignment**: + - If the user belongs to any group listed in `OAUTH_ADMIN_ROLES`, they get admin privileges + - If the user belongs to any group listed in `OAUTH_ALLOWED_ROLES`, they get user privileges + - Default role is applied if no matching groups are found + +4. **Group Management**: If `ENABLE_OAUTH_GROUP_MANAGEMENT` is enabled, Open WebUI groups are synchronized with Google Workspace groups. + +## Google Cloud Console Setup + +1. **Enable APIs**: + - Cloud Identity API + - Cloud Identity Groups API + +2. **OAuth 2.0 Setup**: + - Create OAuth 2.0 credentials + - Add authorized redirect URIs + - Configure consent screen + +3. **Required Scopes**: + ``` + openid + email + profile + https://www.googleapis.com/auth/cloud-identity.groups.readonly + ``` + +## Example Groups Structure + +``` +Your Google Workspace: +├── admin@yourcompany.com (Admin group) +├── superadmin@yourcompany.com (Super admin group) +├── users@yourcompany.com (Regular users) +├── employees@yourcompany.com (All employees) +└── developers@yourcompany.com (Development team) +``` + +## Fallback Behavior + +If the Cloud Identity scope is not present or the API call fails, the system falls back to the traditional method of reading roles from OAuth token claims. + +## Security Considerations + +- The Cloud Identity API requires proper authentication and authorization +- Only users with appropriate permissions can access group membership information +- Groups are fetched server-side, not exposed to the client +- Access tokens are handled securely and not logged + +## Troubleshooting + +1. **Groups not detected**: Ensure the Cloud Identity API is enabled and the OAuth client has the required scope +2. **Permission denied**: Verify the service account or OAuth client has Cloud Identity API access +3. **No admin role**: Check that the user belongs to a group listed in `OAUTH_ADMIN_ROLES` + +## Benefits Over Token Claims + +- **Real-time**: Groups are fetched fresh on each login +- **Complete**: Gets all group memberships, including nested groups +- **Accurate**: No dependency on ID token size limits +- **Flexible**: Can handle complex group hierarchies in Google Workspace \ No newline at end of file diff --git a/functions/actions/transcribe-audio.py b/functions/actions/transcribe-audio.py new file mode 100644 index 00000000000..85b5de57809 --- /dev/null +++ b/functions/actions/transcribe-audio.py @@ -0,0 +1,271 @@ +""" +title: Audio Transcription Action +author: ChatGPT Assistant +version: 0.1.0 +license: MIT + +This Action Function adds an interactive button to Open WebUI that allows users +to transcribe uploaded audio files directly inside the chat interface. When +triggered, the function searches the current message for the first attached +audio file (MP3, WAV, FLAC, M4A, etc.), runs OpenAI’s Whisper model locally +to convert the speech to text, and returns the transcript as the assistant’s +message. + +The function uses the same parameter configuration (Valves) found in the tool +version: you can adjust the Whisper model size, specify a language hint, +choose between transcription and translation, and tweak the beam size for +decoding. During execution the function emits real‑time status updates to +inform the user of progress and handles missing dependencies by attempting to +install the Whisper library automatically. +""" + +import json +import os +import subprocess +import sys +import asyncio +from typing import Any, Callable, Optional + +from pydantic import BaseModel, Field # type: ignore + + +class EventEmitter: + """Helper for sending progress events back to the frontend.""" + + def __init__(self, event_emitter: Optional[Callable[[dict], Any]] = None) -> None: + self.event_emitter = event_emitter + + async def emit( + self, + description: str, + status: str = "in_progress", + done: bool = False, + ) -> None: + if self.event_emitter: + await self.event_emitter( + { + "type": "status", + "data": { + "status": status, + "description": description, + "done": done, + }, + } + ) + + +class Action: + """An Open WebUI Action Function for audio transcription using Whisper. + + This class adheres to the Action Function structure described in the + Open WebUI documentation【494419777235562†L100-L116】. It exposes a single + asynchronous ``action`` method which is invoked when the user clicks the + associated button in the chat toolbar. The function expects the chat + message to contain an uploaded audio file; it extracts the file path, + transcribes the audio to text using Whisper, and returns the transcript as + the assistant’s reply. + """ + + class Valves(BaseModel): + """Configuration parameters for the Action Function. + + These values are exposed in the Open WebUI settings for this Action so + administrators can fine‑tune the transcription behaviour. See the + documentation for descriptions of each field. + """ + + MODEL: str = Field( + default="base", + description=( + "Size of the Whisper model to load. Options include tiny, base, " + "small, medium and large. Larger models are more accurate but " + "consume more memory and compute." + ), + ) + LANGUAGE: str = Field( + default="", + description=( + "ISO‑639‑1 code for the language spoken in the audio (e.g. 'en' for " + "English, 'es' for Spanish). Leave blank to enable automatic " + "language detection." + ), + ) + TASK: str = Field( + default="transcribe", + description=( + "Task to perform: 'transcribe' will convert speech to the same " + "language as the audio. 'translate' will translate the speech " + "into English." + ), + ) + BEAM_SIZE: int = Field( + default=5, + ge=1, + le=10, + description=( + "Beam size used during decoding. Higher values may improve accuracy " + "slightly at the cost of speed." + ), + ) + + def to_kwargs(self) -> dict: + """Convert valves into keyword arguments for whisper.transcribe.""" + kwargs: dict = { + "task": self.TASK, + "beam_size": self.BEAM_SIZE, + } + if self.LANGUAGE: + kwargs["language"] = self.LANGUAGE + return kwargs + + def __init__(self) -> None: + self.valves = self.Valves() + + async def _ensure_whisper_available(self, emitter: EventEmitter) -> Optional[Any]: + """Ensure the whisper library is installed and importable. + + Attempts to import the ``whisper`` package. If it's not available, + automatically installs ``openai-whisper`` via pip. Any errors during + installation are reported back to the user via the event emitter. On + success, returns the imported module; otherwise ``None``. + """ + try: + import whisper # type: ignore + + return whisper + except ModuleNotFoundError: + await emitter.emit( + description=( + "Whisper library not found. Installing openai‑whisper package – " + "this may take a while." + ), + status="in_progress", + ) + try: + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "openai-whisper==20230918", + ], + check=True, + ) + import whisper # type: ignore # type: ignore[redefined] + + return whisper + except Exception as exc: + await emitter.emit( + description=f"Failed to install Whisper: {exc}", + status="error", + done=True, + ) + return None + + async def action( + self, + body: dict, + __user__: Optional[dict] = None, + __event_emitter__: Optional[Callable[[dict], Any]] = None, + __event_call__: Optional[Callable[[dict], Any]] = None, + **kwargs: Any, + ) -> dict: + """Handle the Action invocation and return the transcription result. + + Parameters + ---------- + body: dict + A dictionary containing message context, including uploaded files. + The function looks for an entry in ``body['files']`` whose ``type`` + starts with ``'audio'``. The file must include either a ``path`` or + ``url`` key pointing to its location on disk. When no audio file + is provided, the action returns an instructional message. + + Returns + ------- + dict + A dictionary with at least a ``content`` field containing the + transcript or an error message. Additional fields like ``files`` + may be included in future enhancements. + """ + emitter = EventEmitter(__event_emitter__) + + # Locate the first attached audio file + audio_file: Optional[dict] = None + for f in body.get("files", []): + # Accept any file whose type starts with 'audio' + if str(f.get("type", "")).lower().startswith("audio"): + audio_file = f + break + + if not audio_file: + # No audio file present; instruct the user + return {"content": "Please attach an audio file to transcribe."} + + # Determine the file path. Open WebUI typically stores the uploaded + # file on the server and exposes its local path via the 'path' key. If + # the path is not available, fall back to 'url'. + file_path = audio_file.get("path") or audio_file.get("url") or "" + if not file_path or not os.path.isfile(file_path): + return { + "content": "The attached audio file could not be located on the server." + } + + # Emit a starting status + await emitter.emit(f"Starting transcription for: {file_path}") + + # Ensure whisper is available + whisper = await self._ensure_whisper_available(emitter) + if whisper is None: + return { + "content": "Failed to import the Whisper library. Please check server logs." + } + + # Load the model + await emitter.emit( + description=f"Loading Whisper model '{self.valves.MODEL}'", + status="in_progress", + ) + try: + model = whisper.load_model(self.valves.MODEL) + except Exception as exc: + await emitter.emit( + description=f"Could not load Whisper model: {exc}", + status="error", + done=True, + ) + return {"content": "Error loading Whisper model."} + + # Perform the transcription in a background thread + await emitter.emit("Transcribing audio…", status="in_progress") + try: + loop = asyncio.get_event_loop() + kwargs_transcribe = self.valves.to_kwargs() + result = await loop.run_in_executor( + None, + lambda: model.transcribe(file_path, **kwargs_transcribe), + ) + except Exception as exc: + await emitter.emit( + description=f"Error during transcription: {exc}", + status="error", + done=True, + ) + return {"content": "An error occurred while transcribing the audio."} + + # Final update + await emitter.emit( + description="Transcription completed successfully", + status="complete", + done=True, + ) + + transcript_text = result.get("text", "") + # Return the transcript. Additional data (e.g. segments) could be + # returned as part of the message body or as attached files in the + # future. + return { + "content": transcript_text.strip() or "(No speech detected in the audio.)" + } \ No newline at end of file diff --git a/functions/filters/cameron-walls-transcription.py b/functions/filters/cameron-walls-transcription.py new file mode 100644 index 00000000000..33db428b201 --- /dev/null +++ b/functions/filters/cameron-walls-transcription.py @@ -0,0 +1,366 @@ +""" +title: Audio Transcription with Whisper +author: GitHub Copilot +author_url: https://github.com/github/copilot +funding_url: https://github.com/sponsors/github +version: 1.0.0 +required_open_webui_version: 0.3.8 +""" + +import os +import tempfile +import json +import base64 +import io +from typing import List, Dict, Any, Optional, Callable +from pydantic import BaseModel, Field + +# Import Whisper and related libraries +try: + import whisper + import torch + import numpy as np + from pydub import AudioSegment + + WHISPER_AVAILABLE = True +except ImportError: + WHISPER_AVAILABLE = False + + +class Filter: + class Valves(BaseModel): + """ + Configuration valves for the audio transcription function + """ + + whisper_model: str = Field( + default="base", + description="Whisper model size (tiny, base, small, medium, large, large-v2, large-v3)", + ) + max_file_size_mb: int = Field(default=25, description="Maximum file size in MB") + supported_formats: List[str] = Field( + default=["mp3", "wav", "m4a", "flac", "ogg", "aac", "wma"], + description="Supported audio formats", + ) + language: str = Field( + default="auto", + description="Language for transcription (auto for auto-detection, or ISO 639-1 code)", + ) + temperature: float = Field( + default=0.0, description="Temperature for Whisper sampling (0.0 to 1.0)" + ) + include_timestamps: bool = Field( + default=True, description="Include timestamps in the transcription" + ) + auto_transcribe: bool = Field( + default=True, + description="Automatically transcribe audio files when uploaded", + ) + + def __init__(self): + # Enable custom file handling + self.file_handler = True + + # Initialize valves + self.valves = self.Valves() + self.whisper_model = None + + def _load_whisper_model(self): + """Load the Whisper model if not already loaded""" + if not WHISPER_AVAILABLE: + raise ImportError( + "Whisper dependencies not available. Please install required packages." + ) + + if self.whisper_model is None: + try: + self.whisper_model = whisper.load_model(self.valves.whisper_model) + except Exception as e: + raise RuntimeError( + f"Failed to load Whisper model '{self.valves.whisper_model}': {str(e)}" + ) + + def _validate_file(self, file_data: bytes, filename: str) -> bool: + """Validate the uploaded audio file""" + # Check file size + file_size_mb = len(file_data) / (1024 * 1024) + if file_size_mb > self.valves.max_file_size_mb: + raise ValueError( + f"File size ({file_size_mb:.1f}MB) exceeds maximum allowed size ({self.valves.max_file_size_mb}MB)" + ) + + # Check file extension + file_ext = filename.lower().split(".")[-1] if "." in filename else "" + if file_ext not in self.valves.supported_formats: + raise ValueError( + f"Unsupported file format: {file_ext}. Supported formats: {', '.join(self.valves.supported_formats)}" + ) + + return True + + def _convert_audio(self, file_data: bytes, filename: str) -> str: + """Convert audio file to a format suitable for Whisper""" + try: + # Create temporary file for input + with tempfile.NamedTemporaryFile( + delete=False, suffix=f".{filename.split('.')[-1]}" + ) as temp_input: + temp_input.write(file_data) + temp_input_path = temp_input.name + + # Load audio with pydub + audio = AudioSegment.from_file(temp_input_path) + + # Convert to wav format for Whisper + with tempfile.NamedTemporaryFile( + delete=False, suffix=".wav" + ) as temp_output: + temp_output_path = temp_output.name + + # Export as wav with standard settings for Whisper + audio.export( + temp_output_path, format="wav", parameters=["-ar", "16000", "-ac", "1"] + ) + + # Clean up input file + os.unlink(temp_input_path) + + return temp_output_path + + except Exception as e: + # Clean up files in case of error + if "temp_input_path" in locals() and os.path.exists(temp_input_path): + os.unlink(temp_input_path) + if "temp_output_path" in locals() and os.path.exists(temp_output_path): + os.unlink(temp_output_path) + raise RuntimeError(f"Audio conversion failed: {str(e)}") + + def _format_timestamp(self, seconds: float) -> str: + """Format timestamp in HH:MM:SS format""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + def inlet( + self, + body: dict, + __user__: Optional[dict] = None, + __event_emitter__: Optional[Callable] = None, + ) -> dict: + """ + Process incoming requests and handle file transcription + """ + print(f"inlet: Audio Transcription Filter called") + + # Check if auto transcription is enabled + if not self.valves.auto_transcribe: + return body + + # Look for files in the request + files = body.get("files", []) + if not files: + return body + + # Process each audio file + transcriptions = [] + + for file_item in files: + try: + # Check if it's an audio file + filename = file_item.get("name", "") + file_ext = filename.lower().split(".")[-1] if "." in filename else "" + + if file_ext not in self.valves.supported_formats: + continue + + print(f"Processing audio file: {filename}") + + if __event_emitter: + __event_emitter__( + { + "type": "status", + "data": { + "description": f"Transcribing {filename}...", + "done": False, + }, + } + ) + + # Get file data + file_data = file_item.get("data", {}) + content = file_data.get("content", "") + + if not content: + continue + + # Transcribe the audio + transcription = self._transcribe_audio_file( + content, filename, __event_emitter + ) + transcriptions.append(transcription) + + except Exception as e: + print(f"Error transcribing {filename}: {str(e)}") + transcriptions.append(f"❌ **Error transcribing {filename}**: {str(e)}") + + # Add transcriptions to the message if any were created + if transcriptions: + messages = body.get("messages", []) + if messages: + # Find the last user message and append transcriptions + for i in range(len(messages) - 1, -1, -1): + if messages[i].get("role") == "user": + current_content = messages[i].get("content", "") + transcription_text = "\n\n".join(transcriptions) + + if current_content: + messages[i][ + "content" + ] = f"{current_content}\n\n{transcription_text}" + else: + messages[i]["content"] = transcription_text + break + + return body + + def outlet(self, body: dict, __user__: Optional[dict] = None) -> dict: + """ + Process outgoing responses (no modifications needed for transcription) + """ + return body + + def _transcribe_audio_file( + self, + file_data: str, + filename: str, + __event_emitter__: Optional[Callable] = None, + ) -> str: + """ + Transcribe an audio file using Whisper + + Args: + file_data: Base64 encoded audio file data + filename: Name of the audio file + __event_emitter__: Event emitter for progress updates (optional) + + Returns: + Transcribed text with optional timestamps + """ + + try: + # Decode base64 file data + try: + audio_bytes = base64.b64decode(file_data) + except Exception as e: + raise ValueError(f"Invalid base64 audio data: {str(e)}") + + # Validate file + self._validate_file(audio_bytes, filename) + + if __event_emitter: + __event_emitter( + { + "type": "status", + "data": { + "description": "Loading Whisper model...", + "done": False, + }, + } + ) + + # Load Whisper model + self._load_whisper_model() + + if __event_emitter: + __event_emitter( + { + "type": "status", + "data": { + "description": "Converting audio format...", + "done": False, + }, + } + ) + + # Convert audio to suitable format + audio_path = self._convert_audio(audio_bytes, filename) + + try: + if __event_emitter: + __event_emitter( + { + "type": "status", + "data": { + "description": "Transcribing audio...", + "done": False, + }, + } + ) + + # Prepare transcription options + transcribe_options = { + "temperature": self.valves.temperature, + "word_timestamps": self.valves.include_timestamps, + } + + # Set language if not auto-detection + if self.valves.language != "auto": + transcribe_options["language"] = self.valves.language + + # Transcribe audio + result = self.whisper_model.transcribe(audio_path, **transcribe_options) + + # Format output + output_lines = [] + output_lines.append(f"# Audio Transcription: {filename}") + output_lines.append("") + + # Add metadata + if result.get("language"): + output_lines.append(f"**Detected Language:** {result['language']}") + output_lines.append(f"**Model Used:** {self.valves.whisper_model}") + output_lines.append("") + + # Add transcription + if self.valves.include_timestamps and "segments" in result: + output_lines.append("## Transcription with Timestamps") + output_lines.append("") + for segment in result["segments"]: + start_time = self._format_timestamp(segment["start"]) + end_time = self._format_timestamp(segment["end"]) + text = segment["text"].strip() + output_lines.append(f"**[{start_time} - {end_time}]** {text}") + else: + output_lines.append("## Transcription") + output_lines.append("") + output_lines.append(result["text"].strip()) + + if __event_emitter: + __event_emitter( + { + "type": "status", + "data": { + "description": "Transcription completed!", + "done": True, + }, + } + ) + + return "\n".join(output_lines) + + finally: + # Clean up temporary audio file + if os.path.exists(audio_path): + os.unlink(audio_path) + + except Exception as e: + if __event_emitter: + __event_emitter( + { + "type": "status", + "data": {"description": f"Error: {str(e)}", "done": True}, + } + ) + + return f"❌ **Transcription Error**\n\n{str(e)}\n\nPlease check your audio file and try again." \ No newline at end of file diff --git a/functions/pipes/advising-agent-answers.py b/functions/pipes/advising-agent-answers.py new file mode 100644 index 00000000000..90b35f60f3d --- /dev/null +++ b/functions/pipes/advising-agent-answers.py @@ -0,0 +1,76 @@ +""" +title: DRAFT - Flexion Advising Answers +author: bdruth +author_url: https://github.com/bdruth +version: 0.1 +""" + +from pydantic import BaseModel, Field +import requests +from fastapi import Request + +from open_webui.models.users import Users +from open_webui.utils.chat import generate_chat_completion + +FLEXION_POLICY_PROMPT = ( + "You are Flexion's helpful employee policy assistant. Your role is to provide accurate, actionable guidance to employees about company policies and procedures.\n\n" + "INSTRUCTIONS:\n" + "- Use ONLY the provided context to answer the employee's question\n" + "- Always include specific URLs from the context when they are available\n" + "- Provide clear, actionable next steps when possible\n" + "- If the context has partial information, acknowledge what you know and suggest where to get complete information\n" + "- Keep answers professional but friendly\n" + "- If no relevant information is found in the context, clearly state this\n\n" + "Context:\n{context}\n\n" + "Employee Question: {question}\n\n" + "Policy Guidance:" +) + + +class Pipe: + class Valves(BaseModel): + MODEL_ID: str = Field(default="us.meta.llama3-1-8b-instruct-v1:0") + FLEXION_POLICY_PROMPT: str = Field(default=FLEXION_POLICY_PROMPT) + + def __init__(self): + self.valves = self.Valves() + + async def pipe( + self, + body: dict, + __user__: dict, + __request__: Request, + ) -> str: + # Logic goes here + messages = body.get("messages", []) + + if messages: + question = messages[-1]["content"] + if "Prompt: " in question: + question = question.split("Prompt: ")[-1] + try: + url = "https://2aengmh5jkmyk4feh32ovm54q40gdket.lambda-url.us-west-2.on.aws/" + headers = {"Content-Type": "application/json"} + data = {"query": question, "k": 3} + + response = requests.post(url, headers=headers, json=data) + results = response.json() + + output = "" + for i, result in enumerate(results["results"], 1): + content = result["chunk"]["content"] + output += "```\n" + output += content + output += "\n```\n\n" + + prompt = self.valves.FLEXION_POLICY_PROMPT.format( + context=output, question=question + ) + messages[-1]["content"] = prompt + user = Users.get_user_by_id(__user__["id"]) + body["model"] = "us.meta.llama3-1-8b-instruct-v1:0" + + return await generate_chat_completion(__request__, body, user) + except Exception as e: + error_msg = f"Error during sequence execution: {str(e)}" + return {"error": error_msg} \ No newline at end of file diff --git a/functions/pipes/gemini.pipe.py b/functions/pipes/gemini.pipe.py new file mode 100644 index 00000000000..1f729557516 --- /dev/null +++ b/functions/pipes/gemini.pipe.py @@ -0,0 +1,3408 @@ +""" +title: Google Gemini Pipeline +author: owndev, olivier-lacroix +author_url: https://github.com/owndev/ +project_url: https://github.com/owndev/Open-WebUI-Functions +funding_url: https://github.com/sponsors/owndev +version: 1.14.1 +required_open_webui_version: 0.8.0 +license: Apache License 2.0 +description: Highly optimized Google Gemini pipeline with advanced image and video generation capabilities, intelligent compression, and streamlined processing workflows. +features: + - Optimized asynchronous API calls for maximum performance + - Intelligent model caching with configurable TTL + - Streamlined dynamic model specification with automatic prefix handling + - Smart streaming response handling with safety checks + - Advanced multimodal input support (text and images) + - Unified image generation and editing with Gemini 2.5 Flash Image Preview + - Intelligent image optimization with size-aware compression algorithms + - Automated image upload to Open WebUI with robust fallback support + - Optimized text-to-image and image-to-image workflows + - Non-streaming mode for image generation to prevent chunk overflow + - Progressive status updates for optimal user experience + - Consolidated error handling and comprehensive logging + - Seamless Google Generative AI and Vertex AI integration + - Advanced generation parameters (temperature, max tokens, etc.) + - Configurable safety settings with environment variable support + - Military-grade encrypted storage of sensitive API keys + - Intelligent grounding with Google search integration + - Vertex AI Search grounding for RAG + - Native tool calling support with automatic signature management + - URL context grounding for specified web pages + - Unified image processing with consolidated helper methods + - Optimized payload creation for image generation models + - Configurable image processing parameters (size, quality, compression) + - Flexible upload fallback options and optimization controls + - Configurable thinking levels for Gemini 3 models with model-specific validation + - Configurable thinking budgets (0-32768 tokens) for Gemini 2.5 models + - Configurable image generation aspect ratio (1:1, 16:9, etc.) and resolution (1K, 2K, 4K) + - Model whitelist for filtering available models + - Additional model support for SDK-unsupported models + - Video generation with Google Veo models (Veo 3.1, 3, 2) + - Configurable video generation parameters (aspect ratio, resolution, duration) + - Asynchronous video generation with progressive polling status updates + - Automatic video upload to Open WebUI with embedded playback + - Image-to-video generation support for Veo models + - Negative prompt and person generation controls for video +""" + +import os +import re +import time +import asyncio +import base64 +import hashlib +import logging +import io +import uuid +import aiofiles +from PIL import Image +from google import genai +from google.genai import types +from google.genai.errors import ClientError, ServerError, APIError +from typing import List, Union, Optional, Dict, Any, Tuple, AsyncIterator, Callable +from pydantic_core import core_schema +from pydantic import BaseModel, Field, GetCoreSchemaHandler +from cryptography.fernet import Fernet, InvalidToken +from open_webui.env import SRC_LOG_LEVELS +from fastapi import Request, UploadFile, BackgroundTasks +from open_webui.routers.files import upload_file +from open_webui.models.users import UserModel, Users +from starlette.datastructures import Headers + +ASPECT_RATIO_OPTIONS: List[str] = [ + "default", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] + +RESOLUTION_OPTIONS: List[str] = [ + "default", + "1K", + "2K", + "4K", +] + +VIDEO_ASPECT_RATIO_OPTIONS: List[str] = [ + "default", + "16:9", + "9:16", +] + +VIDEO_RESOLUTION_OPTIONS: List[str] = [ + "default", + "720p", + "1080p", + "4k", +] + +VIDEO_DURATION_OPTIONS: List[str] = [ + "default", + "4", + "5", + "6", + "8", +] + +VIDEO_PERSON_GENERATION_OPTIONS: List[str] = [ + "default", + "allow_all", + "allow_adult", + "dont_allow", +] + + +# Simplified encryption implementation with automatic handling +class EncryptedStr(str): + """A string type that automatically handles encryption/decryption""" + + @classmethod + def _get_encryption_key(cls) -> Optional[bytes]: + """ + Generate encryption key from WEBUI_SECRET_KEY if available + Returns None if no key is configured + """ + secret = os.getenv("WEBUI_SECRET_KEY") + if not secret: + return None + + hashed_key = hashlib.sha256(secret.encode()).digest() + return base64.urlsafe_b64encode(hashed_key) + + @classmethod + def encrypt(cls, value: str) -> str: + """ + Encrypt a string value if a key is available + Returns the original value if no key is available + """ + if not value or value.startswith("encrypted:"): + return value + + key = cls._get_encryption_key() + if not key: # No encryption if no key + return value + + f = Fernet(key) + encrypted = f.encrypt(value.encode()) + return f"encrypted:{encrypted.decode()}" + + @classmethod + def decrypt(cls, value: str) -> str: + """ + Decrypt an encrypted string value if a key is available + Returns the original value if no key is available or decryption fails + """ + if not value or not value.startswith("encrypted:"): + return value + + key = cls._get_encryption_key() + if not key: # No decryption if no key + return value[len("encrypted:") :] # Return without prefix + + try: + encrypted_part = value[len("encrypted:") :] + f = Fernet(key) + decrypted = f.decrypt(encrypted_part.encode()) + return decrypted.decode() + except (InvalidToken, Exception): + return value + + # Pydantic integration + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function( + lambda value: cls(cls.encrypt(value) if value else value) + ), + ] + ), + ], + serialization=core_schema.plain_serializer_function_ser_schema( + lambda instance: str(instance) + ), + ) + + +class Pipe: + """ + Pipeline for interacting with Google Gemini models. + """ + + # User-overridable configuration valves + class UserValves(BaseModel): + IMAGE_GENERATION_ASPECT_RATIO: str = Field( + default=os.getenv("GOOGLE_IMAGE_GENERATION_ASPECT_RATIO", "default"), + description="Default aspect ratio for image generation.", + json_schema_extra={"enum": ASPECT_RATIO_OPTIONS}, + ) + IMAGE_GENERATION_RESOLUTION: str = Field( + default=os.getenv("GOOGLE_IMAGE_GENERATION_RESOLUTION", "default"), + description="Default resolution for image generation.", + json_schema_extra={"enum": RESOLUTION_OPTIONS}, + ) + VIDEO_GENERATION_ASPECT_RATIO: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_ASPECT_RATIO", "default"), + description="Default aspect ratio for video generation (16:9 landscape or 9:16 portrait).", + json_schema_extra={"enum": VIDEO_ASPECT_RATIO_OPTIONS}, + ) + VIDEO_GENERATION_RESOLUTION: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_RESOLUTION", "default"), + description="Default resolution for video generation (720p, 1080p, or 4k).", + json_schema_extra={"enum": VIDEO_RESOLUTION_OPTIONS}, + ) + VIDEO_GENERATION_DURATION: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_DURATION", "default"), + description="Default duration in seconds for video generation (4, 5, 6, or 8 - availability varies by model).", + json_schema_extra={"enum": VIDEO_DURATION_OPTIONS}, + ) + + # Configuration valves for the pipeline + class Valves(BaseModel): + BASE_URL: str = Field( + default=os.getenv( + "GOOGLE_GENAI_BASE_URL", "https://generativelanguage.googleapis.com/" + ), + description="Base URL for the Google Generative AI API.", + ) + GOOGLE_API_KEY: EncryptedStr = Field( + default=os.getenv("GOOGLE_API_KEY", ""), + description="API key for Google Generative AI (used if USE_VERTEX_AI is false).", + ) + API_VERSION: str = Field( + default=os.getenv("GOOGLE_API_VERSION", "v1alpha"), + description="API version to use for Google Generative AI (e.g., v1alpha, v1beta, v1).", + ) + STREAMING_ENABLED: bool = Field( + default=os.getenv("GOOGLE_STREAMING_ENABLED", "true").lower() == "true", + description="Enable streaming responses (set false to force non-streaming mode).", + ) + INCLUDE_THOUGHTS: bool = Field( + default=os.getenv("GOOGLE_INCLUDE_THOUGHTS", "true").lower() == "true", + description="Enable Gemini thoughts outputs (set false to disable).", + ) + THINKING_BUDGET: int = Field( + default=int(os.getenv("GOOGLE_THINKING_BUDGET", "-1")), + description="Thinking budget for Gemini 2.5 models (0=disabled, -1=dynamic, 1-32768=fixed token limit). " + "Not used for Gemini 3 models which use THINKING_LEVEL instead.", + ) + THINKING_LEVEL: str = Field( + default=os.getenv("GOOGLE_THINKING_LEVEL", ""), + description="Thinking level for Gemini 3 models. Most Gemini 3 models support 'low'/'high', " + "while gemini-3.1-flash-image-preview supports 'minimal'/'high'. " + "Ignored for other models. Empty string means use model default.", + ) + USE_VERTEX_AI: bool = Field( + default=os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "false").lower() == "true", + description="Whether to use Google Cloud Vertex AI instead of the Google Generative AI API.", + ) + VERTEX_PROJECT: str | None = Field( + default=os.getenv("GOOGLE_CLOUD_PROJECT"), + description="The Google Cloud project ID to use with Vertex AI.", + ) + VERTEX_LOCATION: str = Field( + default=os.getenv("GOOGLE_CLOUD_LOCATION", "global"), + description="The Google Cloud region to use with Vertex AI.", + ) + VERTEX_AI_RAG_STORE: str | None = Field( + default=os.getenv("GOOGLE_VERTEX_AI_RAG_STORE"), + description="Vertex AI RAG Store path for grounding (e.g., projects/PROJECT/locations/LOCATION/ragCorpora/DATA_STORE_ID). Only used when USE_VERTEX_AI is true.", + ) + USE_PERMISSIVE_SAFETY: bool = Field( + default=os.getenv("GOOGLE_USE_PERMISSIVE_SAFETY", "false").lower() + == "true", + description="Use permissive safety settings for content generation.", + ) + MODEL_CACHE_TTL: int = Field( + default=int(os.getenv("GOOGLE_MODEL_CACHE_TTL", "600")), + description="Time in seconds to cache the model list before refreshing", + ) + RETRY_COUNT: int = Field( + default=int(os.getenv("GOOGLE_RETRY_COUNT", "2")), + description="Number of times to retry API calls on temporary failures", + ) + DEFAULT_SYSTEM_PROMPT: str = Field( + default=os.getenv("GOOGLE_DEFAULT_SYSTEM_PROMPT", ""), + description="Default system prompt applied to all chats. If a user-defined system prompt exists, " + "this is prepended to it. Leave empty to disable.", + ) + ENABLE_FORWARD_USER_INFO_HEADERS: bool = Field( + default=os.getenv( + "GOOGLE_ENABLE_FORWARD_USER_INFO_HEADERS", "false" + ).lower() + == "true", + description="Whether to forward user information headers.", + ) + MODEL_ADDITIONAL: str = Field( + default=os.getenv( + "GOOGLE_MODEL_ADDITIONAL", + "gemini-2.5-flash-image", + ), + description="A comma-separated list of model IDs to manually add to the list of available models. " + "These are models not returned by the SDK but that you want to make available. " + "Non-Gemini model IDs must be explicitly included in MODEL_WHITELIST to be available.", + ) + MODEL_WHITELIST: str = Field( + default=os.getenv( + "GOOGLE_MODEL_WHITELIST", + "gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.5-flash-image", + ), + description="A comma-separated list of model IDs to show in the models list. " + "If set, only these models will be available (after MODEL_ADDITIONAL is applied). " + "Leave empty to show all models.", + ) + USE_ENTERPRISE_WEB_SEARCH: bool = Field( + default=os.getenv("GOOGLE_USE_ENTERPRISE_WEB_SEARCH", "false").lower() + == "true", + description="Whether to use Enterprise Web Search instead of standard Google search when grounding is enabled. " + "Only available on Vertex AI.", + ) + + # Image Processing Configuration + IMAGE_GENERATION_ASPECT_RATIO: str = Field( + default=os.getenv("GOOGLE_IMAGE_GENERATION_ASPECT_RATIO", "default"), + description="Default aspect ratio for image generation.", + json_schema_extra={"enum": ASPECT_RATIO_OPTIONS}, + ) + IMAGE_GENERATION_RESOLUTION: str = Field( + default=os.getenv("GOOGLE_IMAGE_GENERATION_RESOLUTION", "default"), + description="Default resolution for image generation.", + json_schema_extra={"enum": RESOLUTION_OPTIONS}, + ) + IMAGE_MAX_SIZE_MB: float = Field( + default=float(os.getenv("GOOGLE_IMAGE_MAX_SIZE_MB", "15.0")), + description="Maximum image size in MB before compression is applied", + ) + IMAGE_MAX_DIMENSION: int = Field( + default=int(os.getenv("GOOGLE_IMAGE_MAX_DIMENSION", "2048")), + description="Maximum width or height in pixels before resizing", + ) + IMAGE_COMPRESSION_QUALITY: int = Field( + default=int(os.getenv("GOOGLE_IMAGE_COMPRESSION_QUALITY", "85")), + description="JPEG compression quality (1-100, higher = better quality but larger size)", + ) + IMAGE_ENABLE_OPTIMIZATION: bool = Field( + default=os.getenv("GOOGLE_IMAGE_ENABLE_OPTIMIZATION", "true").lower() + == "true", + description="Enable intelligent image optimization for API compatibility", + ) + IMAGE_PNG_COMPRESSION_THRESHOLD_MB: float = Field( + default=float(os.getenv("GOOGLE_IMAGE_PNG_THRESHOLD_MB", "0.5")), + description="PNG files above this size (MB) will be converted to JPEG for better compression", + ) + IMAGE_HISTORY_MAX_REFERENCES: int = Field( + default=int(os.getenv("GOOGLE_IMAGE_HISTORY_MAX_REFERENCES", "5")), + description="Maximum total number of images (history + current message) to include in a generation call", + ) + IMAGE_ADD_LABELS: bool = Field( + default=os.getenv("GOOGLE_IMAGE_ADD_LABELS", "true").lower() == "true", + description="If true, add small text labels like [Image 1] before each image part so the model can reference them.", + ) + IMAGE_DEDUP_HISTORY: bool = Field( + default=os.getenv("GOOGLE_IMAGE_DEDUP_HISTORY", "true").lower() == "true", + description="If true, deduplicate identical images (by hash) when constructing history context", + ) + IMAGE_HISTORY_FIRST: bool = Field( + default=os.getenv("GOOGLE_IMAGE_HISTORY_FIRST", "true").lower() == "true", + description="If true (default), history images precede current message images; if false, current images first.", + ) + + # Video Generation Configuration (Veo models) + VIDEO_GENERATION_ASPECT_RATIO: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_ASPECT_RATIO", "default"), + description="Default aspect ratio for video generation (16:9 landscape or 9:16 portrait).", + json_schema_extra={"enum": VIDEO_ASPECT_RATIO_OPTIONS}, + ) + VIDEO_GENERATION_RESOLUTION: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_RESOLUTION", "default"), + description="Default resolution for video generation (720p, 1080p, or 4k).", + json_schema_extra={"enum": VIDEO_RESOLUTION_OPTIONS}, + ) + VIDEO_GENERATION_DURATION: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_DURATION", "default"), + description="Default duration in seconds for video generation (4, 5, 6, or 8 - availability varies by model).", + json_schema_extra={"enum": VIDEO_DURATION_OPTIONS}, + ) + VIDEO_GENERATION_NEGATIVE_PROMPT: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_NEGATIVE_PROMPT", ""), + description="Default negative prompt for video generation (describes what not to include).", + ) + VIDEO_GENERATION_PERSON_GENERATION: str = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_PERSON_GENERATION", "default"), + description="Controls generation of people in videos (allow_all, allow_adult, dont_allow).", + json_schema_extra={"enum": VIDEO_PERSON_GENERATION_OPTIONS}, + ) + VIDEO_GENERATION_ENHANCE_PROMPT: bool = Field( + default=os.getenv("GOOGLE_VIDEO_GENERATION_ENHANCE_PROMPT", "true").lower() + == "true", + description="Enable prompt enhancement for video generation.", + ) + VIDEO_POLL_INTERVAL: int = Field( + default=int(os.getenv("GOOGLE_VIDEO_POLL_INTERVAL", "10")), + description="Polling interval in seconds when waiting for video generation to complete.", + ) + VIDEO_POLL_TIMEOUT: int = Field( + default=int(os.getenv("GOOGLE_VIDEO_POLL_TIMEOUT", "600")), + description="Maximum time in seconds to wait for video generation before timing out (0=no limit).", + ) + + # ---------------- Internal Helpers ---------------- # + async def _gather_history_images( + self, + messages: List[Dict[str, Any]], + last_user_msg: Dict[str, Any], + optimization_stats: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + history_images: List[Dict[str, Any]] = [] + for msg in messages: + if msg is last_user_msg: + continue + if msg.get("role") not in {"user", "assistant"}: + continue + _p, parts = await self._extract_images_from_message( + msg, stats_list=optimization_stats + ) + if parts: + history_images.extend(parts) + return history_images + + def _deduplicate_images(self, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if not self.valves.IMAGE_DEDUP_HISTORY: + return images + seen: set[str] = set() + result: List[Dict[str, Any]] = [] + for part in images: + try: + data = part["inline_data"]["data"] + # Hash full base64 payload for stronger dedup reliability + h = hashlib.sha256(data.encode()).hexdigest() + if h in seen: + continue + seen.add(h) + except Exception as e: + # Skip images with malformed or missing data, but log for debugging. + self.log.debug(f"Skipping image in deduplication due to error: {e}") + result.append(part) + return result + + def _combine_system_prompts( + self, user_system_prompt: Optional[str] + ) -> Optional[str]: + """Combine default system prompt with user-defined system prompt. + + If DEFAULT_SYSTEM_PROMPT is set and user_system_prompt exists, + the default is prepended to the user's prompt. + If only DEFAULT_SYSTEM_PROMPT is set, it is used as the system prompt. + If only user_system_prompt is set, it is used as-is. + + Args: + user_system_prompt: The user-defined system prompt from messages (may be None) + + Returns: + Combined system prompt or None if neither is set + """ + default_prompt = self.valves.DEFAULT_SYSTEM_PROMPT.strip() + user_prompt = user_system_prompt.strip() if user_system_prompt else "" + + if default_prompt and user_prompt: + combined = f"{default_prompt}\n\n{user_prompt}" + self.log.debug( + f"Combined system prompts: default ({len(default_prompt)} chars) + " + f"user ({len(user_prompt)} chars) = {len(combined)} chars" + ) + return combined + elif default_prompt: + self.log.debug(f"Using default system prompt ({len(default_prompt)} chars)") + return default_prompt + elif user_prompt: + return user_prompt + return None + + def _apply_order_and_limit( + self, + history: List[Dict[str, Any]], + current: List[Dict[str, Any]], + ) -> Tuple[List[Dict[str, Any]], List[bool]]: + """Combine history & current image parts honoring order & global limit. + + Returns: + (combined_parts, reused_flags) where reused_flags[i] == True indicates + the image originated from history, False if from current message. + """ + history_first = self.valves.IMAGE_HISTORY_FIRST + limit = max(1, self.valves.IMAGE_HISTORY_MAX_REFERENCES) + combined: List[Dict[str, Any]] = [] + reused_flags: List[bool] = [] + + def append(parts: List[Dict[str, Any]], reused: bool): + for p in parts: + if len(combined) >= limit: + break + combined.append(p) + reused_flags.append(reused) + + if history_first: + append(history, True) + append(current, False) + else: + append(current, False) + append(history, True) + return combined, reused_flags + + async def _emit_image_stats( + self, + ordered_stats: List[Dict[str, Any]], + reused_flags: List[bool], + total_limit: int, + __event_emitter__: Callable, + ) -> None: + """Emit per-image optimization stats aligned with final combined order. + + ordered_stats: stats list in the exact order images will be sent (same length as combined image list) + reused_flags: parallel list indicating whether image originated from history + """ + if not ordered_stats: + return + for idx, stat in enumerate(ordered_stats, start=1): + reused = reused_flags[idx - 1] if idx - 1 < len(reused_flags) else False + stat_copy = dict(stat) if stat else {} + stat_copy.update({"index": idx, "reused": reused}) + if stat and stat.get("original_size_mb") is not None: + desc = f"Image {idx}: {stat['original_size_mb']:.2f}MB -> {stat['final_size_mb']:.2f}MB" + if stat.get("quality") is not None: + desc += f" (Q{stat['quality']})" + else: + desc = f"Image {idx}: (no metrics)" + reasons = stat.get("reasons") if stat else None + if reasons: + desc += " | " + ", ".join(reasons[:3]) + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_optimization", + "description": desc, + "index": idx, + "done": False, + "details": stat_copy, + }, + } + ) + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_optimization", + "description": f"{len(ordered_stats)} image(s) processed (limit {total_limit}).", + "done": True, + }, + } + ) + + async def _build_image_generation_contents( + self, + messages: List[Dict[str, Any]], + __event_emitter__: Callable, + ) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """Construct the contents payload for image-capable models. + + Returns tuple (contents, system_instruction) where system_instruction is extracted from system messages. + """ + # Extract user-defined system instruction first + user_system_instruction = next( + (msg["content"] for msg in messages if msg.get("role") == "system"), + None, + ) + + # Combine with default system prompt if configured + system_instruction = self._combine_system_prompts(user_system_instruction) + + last_user_msg = next( + (m for m in reversed(messages) if m.get("role") == "user"), None + ) + if not last_user_msg: + raise ValueError("No user message found") + + optimization_stats: List[Dict[str, Any]] = [] + history_images = await self._gather_history_images( + messages, last_user_msg, optimization_stats + ) + prompt, current_images = await self._extract_images_from_message( + last_user_msg, stats_list=optimization_stats + ) + + # Deduplicate + history_images = self._deduplicate_images(history_images) + current_images = self._deduplicate_images(current_images) + + combined, reused_flags = self._apply_order_and_limit( + history_images, current_images + ) + + if not prompt and not combined: + raise ValueError("No prompt or images provided") + if not prompt and combined: + prompt = "Analyze and describe the provided images." + + # Build ordered stats aligned with combined list + ordered_stats: List[Dict[str, Any]] = [] + if optimization_stats: + # Build map from final_hash -> stat (first wins) + hash_map: Dict[str, Dict[str, Any]] = {} + for s in optimization_stats: + fh = s.get("final_hash") + if fh and fh not in hash_map: + hash_map[fh] = s + for part in combined: + try: + fh = hashlib.sha256( + part["inline_data"]["data"].encode() + ).hexdigest() + ordered_stats.append(hash_map.get(fh) or {}) + except Exception: + ordered_stats.append({}) + # Emit stats AFTER final ordering so labels match + await self._emit_image_stats( + ordered_stats, + reused_flags, + self.valves.IMAGE_HISTORY_MAX_REFERENCES, + __event_emitter__, + ) + + # Emit mapping + if combined: + mapping = [ + { + "index": i + 1, + "label": ( + f"Image {i + 1}" if self.valves.IMAGE_ADD_LABELS else str(i + 1) + ), + "reused": reused_flags[i], + "origin": "history" if reused_flags[i] else "current", + } + for i in range(len(combined)) + ] + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_reference_map", + "description": f"{len(combined)} image(s) included (limit {self.valves.IMAGE_HISTORY_MAX_REFERENCES}).", + "images": mapping, + "done": True, + }, + } + ) + + # Build parts + parts: List[Dict[str, Any]] = [] + + # For image generation models, prepend system instruction to the prompt + # since system_instruction parameter may not be supported + final_prompt = prompt + if system_instruction and prompt: + final_prompt = f"{system_instruction}\n\n{prompt}" + self.log.debug( + f"Prepended system instruction to prompt for image generation. " + f"System instruction length: {len(system_instruction)}, " + f"Original prompt length: {len(prompt)}, " + f"Final prompt length: {len(final_prompt)}" + ) + elif system_instruction and not prompt: + final_prompt = system_instruction + self.log.debug( + f"Using system instruction as prompt for image generation " + f"(length: {len(system_instruction)})" + ) + + if final_prompt: + parts.append({"text": final_prompt}) + if self.valves.IMAGE_ADD_LABELS: + for idx, part in enumerate(combined, start=1): + parts.append({"text": f"[Image {idx}]"}) + parts.append(part) + else: + parts.extend(combined) + + self.log.debug( + f"Image-capable payload: history={len(history_images)} current={len(current_images)} used={len(combined)} limit={self.valves.IMAGE_HISTORY_MAX_REFERENCES} history_first={self.valves.IMAGE_HISTORY_FIRST} prompt_len={len(final_prompt)}" + ) + # Return None for system_instruction since we've incorporated it into the prompt + return [{"role": "user", "parts": parts}], None + + def __init__(self): + """Initializes the Pipe instance and configures the genai library.""" + self.valves = self.Valves() + self.name: str = "Google Gemini: " + + # Setup logging + self.log = logging.getLogger("google_ai.pipe") + self.log.setLevel(SRC_LOG_LEVELS.get("OPENAI", logging.INFO)) + + # Model cache + self._model_cache: Optional[List[Dict[str, str]]] = None + self._model_cache_time: float = 0 + + def _get_client(self) -> genai.Client: + """ + Validates API credentials and returns a genai.Client instance. + """ + self._validate_api_key() + + if self.valves.USE_VERTEX_AI: + self.log.debug( + f"Initializing Vertex AI client (Project: {self.valves.VERTEX_PROJECT}, Location: {self.valves.VERTEX_LOCATION})" + ) + return genai.Client( + vertexai=True, + project=self.valves.VERTEX_PROJECT, + location=self.valves.VERTEX_LOCATION, + ) + else: + self.log.debug("Initializing Google Generative AI client with API Key") + headers = {} + if ( + self.valves.ENABLE_FORWARD_USER_INFO_HEADERS + and hasattr(self, "user") + and self.user + ): + + def sanitize_header_value(value: Any, max_length: int = 255) -> str: + if value is None: + return "" + # Convert to string and remove all control characters + sanitized = re.sub(r"[\x00-\x1F\x7F]", "", str(value)) + sanitized = sanitized.strip() + return ( + sanitized[:max_length] + if len(sanitized) > max_length + else sanitized + ) + + user_attrs = { + "X-OpenWebUI-User-Name": sanitize_header_value( + getattr(self.user, "name", None) + ), + "X-OpenWebUI-User-Id": sanitize_header_value( + getattr(self.user, "id", None) + ), + "X-OpenWebUI-User-Email": sanitize_header_value( + getattr(self.user, "email", None) + ), + "X-OpenWebUI-User-Role": sanitize_header_value( + getattr(self.user, "role", None) + ), + } + headers = {k: v for k, v in user_attrs.items() if v not in (None, "")} + options = types.HttpOptions( + api_version=self.valves.API_VERSION, + base_url=self.valves.BASE_URL, + headers=headers, + ) + return genai.Client( + api_key=EncryptedStr.decrypt(self.valves.GOOGLE_API_KEY), + http_options=options, + ) + + def _validate_api_key(self) -> None: + """ + Validates that the necessary Google API credentials are set. + + Raises: + ValueError: If the required credentials are not set. + """ + if self.valves.USE_VERTEX_AI: + if not self.valves.VERTEX_PROJECT: + self.log.error("USE_VERTEX_AI is true, but VERTEX_PROJECT is not set.") + raise ValueError( + "VERTEX_PROJECT is not set. Please provide the Google Cloud project ID." + ) + # For Vertex AI, location has a default, so project is the main thing to check. + # Actual authentication will be handled by ADC or environment. + self.log.debug( + "Using Vertex AI. Ensure ADC or service account is configured." + ) + else: + if not self.valves.GOOGLE_API_KEY: + self.log.error("GOOGLE_API_KEY is not set (and not using Vertex AI).") + raise ValueError( + "GOOGLE_API_KEY is not set. Please provide the API key in the environment variables or valves." + ) + self.log.debug("Using Google Generative AI API with API Key.") + + def strip_prefix(self, model_name: str) -> str: + """ + Extract the model identifier using regex, handling various naming conventions. + e.g., "google_gemini_pipeline.gemini-2.5-flash-preview-04-17" -> "gemini-2.5-flash-preview-04-17" + e.g., "models/gemini-1.5-flash-001" -> "gemini-1.5-flash-001" + e.g., "publishers/google/models/gemini-1.5-pro" -> "gemini-1.5-pro" + """ + # Use regex to remove everything up to and including the last '/' or the first '.' + stripped = re.sub(r"^(?:.*/|[^.]*\.)", "", model_name) + return stripped + + def get_google_models(self, force_refresh: bool = False) -> List[Dict[str, str]]: + """ + Retrieve available Google models suitable for content generation. + Uses caching to reduce API calls. + + Args: + force_refresh: Whether to force refreshing the model cache + + Returns: + List of dictionaries containing model id and name. + """ + # Check cache first + current_time = time.time() + if ( + not force_refresh + and self._model_cache is not None + and (current_time - self._model_cache_time) < self.valves.MODEL_CACHE_TTL + ): + self.log.debug("Using cached model list") + return self._model_cache + + try: + client = self._get_client() + self.log.debug("Fetching models from Google API") + models = list(client.models.list()) + + # Process additional models (models not returned by SDK but that we want to add) + additional = self.valves.MODEL_ADDITIONAL + if additional: + self.log.debug(f"Processing additional models: {additional}") + existing_model_names = {self.strip_prefix(m.name) for m in models} + additional_ids = set(re.findall(r"[^,\s]+", additional)) + + for model_id in additional_ids.difference(existing_model_names): + self.log.debug(f"Adding additional model '{model_id}'.") + models.append(types.Model(name=f"models/{model_id}")) + + available_models = [] + for model in models: + actions = model.supported_actions + model_id_stripped = self.strip_prefix(model.name) + is_content_model = actions is None or "generateContent" in actions + is_video_model = ( + actions is not None and "generateVideos" in actions + ) or model_id_stripped.startswith("veo-") + if is_content_model or is_video_model: + model_id = model_id_stripped + model_name = model.display_name or model_id + # Override display names for specific models + if model_id == "gemini-2.5-flash-image": + model_name = "Nano Banana" + + # Check if model supports image generation + supports_image_generation = self._check_image_generation_support( + model_id + ) + if supports_image_generation: + model_name += " 🎨" # Add image generation indicator + + # Check if model supports video generation + supports_video_generation = self._check_video_generation_support( + model_id + ) + if supports_video_generation: + model_name += " 🎬" # Add video generation indicator + + available_models.append( + { + "id": model_id, + "name": model_name, + "image_generation": supports_image_generation, + "video_generation": supports_video_generation, + } + ) + + model_map = {model["id"]: model for model in available_models} + + # Apply MODEL_WHITELIST filter if configured (takes priority) + whitelist = self.valves.MODEL_WHITELIST + if whitelist: + self.log.debug(f"Applying model whitelist: {whitelist}") + whitelisted_ids = set(re.findall(r"[^,\s]+", whitelist)) + # Filter to only include whitelisted models + filtered_models = { + k: v for k, v in model_map.items() if k in whitelisted_ids + } + self.log.debug(f"After whitelist filter: {len(filtered_models)} models") + else: + # If no whitelist, filter to only include models starting with 'gemini-' or 'veo-' + filtered_models = { + k: v + for k, v in model_map.items() + if k.startswith("gemini-") or k.startswith("veo-") + } + self.log.debug(f"After prefix filter: {len(filtered_models)} models") + + # Update cache + self._model_cache = list(filtered_models.values()) + self._model_cache_time = current_time + self.log.debug(f"Found {len(self._model_cache)} Gemini models") + return self._model_cache + + except Exception as e: + self.log.exception(f"Could not fetch models from Google: {str(e)}") + # Return a specific error entry for the UI + return [{"id": "error", "name": f"Could not fetch models: {str(e)}"}] + + def _check_image_generation_support(self, model_id: str) -> bool: + """ + Check if a model supports image generation. + + Args: + model_id: The model ID to check + + Returns: + True if the model supports image generation, False otherwise + """ + # Known image generation models (both Gemini 2.5 and Gemini 3) + image_generation_models = [ + "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview", + "gemini-3-flash-image", + "gemini-3-flash-image-preview", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image", + "gemini-3-pro-image-preview", + ] + + # Check for exact matches or pattern matches + for pattern in image_generation_models: + if model_id == pattern or pattern in model_id: + return True + + # Additional pattern checking for future models + if "image" in model_id.lower() and ( + "generation" in model_id.lower() or "preview" in model_id.lower() + ): + return True + + return False + + def _is_gemini_3_family_model(self, model_id: str) -> bool: + """Return True for Gemini 3.x model IDs, including Gemini 3.1.""" + model_lower = model_id.lower() + return model_lower.startswith("gemini-3-") or model_lower.startswith( + "gemini-3." + ) + + def _is_gemini_3_image_model(self, model_id: str) -> bool: + """Return True for Gemini 3.x image generation models.""" + return self._is_gemini_3_family_model( + model_id + ) and self._check_image_generation_support(model_id) + + def _check_image_config_support(self, model_id: str) -> bool: + """ + Check if a model supports ImageConfig (aspect_ratio and image_size parameters). + + ImageConfig is only supported by Gemini 3 image generation models. + Gemini 2.5 image models support image generation but not ImageConfig. + + Args: + model_id: The model ID to check + + Returns: + True if the model supports ImageConfig, False otherwise + """ + return self._is_gemini_3_image_model(model_id) + + def _check_thinking_support(self, model_id: str) -> bool: + """ + Check if a model supports the thinking feature. + + Args: + model_id: The model ID to check + + Returns: + True if the model supports thinking, False otherwise + """ + # Models that do NOT support thinking + non_thinking_models = [ + "gemini-2.5-flash-image-preview", + "gemini-2.5-flash-image", + ] + + # Check for exact matches + for pattern in non_thinking_models: + if model_id == pattern or pattern in model_id: + return False + + # Gemini 3 image models support thinking and thinking-level controls. + if self._is_gemini_3_image_model(model_id): + return True + + # Older image generation preview models typically don't support thinking. + if "image" in model_id.lower() and ( + "generation" in model_id.lower() or "preview" in model_id.lower() + ): + return False + + # By default, assume models support thinking + return True + + def _check_thinking_level_support(self, model_id: str) -> bool: + """ + Check if a model supports the thinking_level parameter. + + Gemini 3 models support thinking_level and should NOT use thinking_budget. + Other models (like Gemini 2.5) use thinking_budget instead. + + Args: + model_id: The model ID to check + + Returns: + True if the model supports thinking_level, False otherwise + """ + return self._is_gemini_3_family_model(model_id) + + def _get_supported_thinking_levels(self, model_id: str) -> List[str]: + """Return the supported thinking levels for a specific Gemini 3 model.""" + model_lower = model_id.lower() + + if model_lower.startswith("gemini-3.1-flash-image"): + return ["minimal", "high"] + + if self._is_gemini_3_family_model(model_id): + return ["low", "high"] + + return [] + + def _coerce_thinking_level( + self, requested_level: str, supported_levels: List[str] + ) -> Optional[str]: + """Map unsupported thinking levels to the closest supported level.""" + if not supported_levels: + return None + + level_rank = {"minimal": 0, "low": 1, "medium": 2, "high": 3} + requested_rank = level_rank.get(requested_level) + if requested_rank is None: + return None + + supported_ranks = sorted( + (level_rank[level], level) + for level in supported_levels + if level in level_rank + ) + if not supported_ranks: + return None + + best_rank, best_level = min( + supported_ranks, + key=lambda item: (abs(item[0] - requested_rank), -item[0]), + ) + _ = best_rank + return best_level + + def _validate_thinking_level(self, level: str, model_id: str = "") -> Optional[str]: + """ + Validate and normalize the thinking level value for the current model. + + Args: + level: The thinking level string to validate + model_id: The model ID used to determine supported levels + + Returns: + Supported thinking level string or None if invalid/empty + """ + if not level: + return None + + normalized = level.strip().lower() + valid_levels = ["minimal", "low", "medium", "high"] + + if normalized not in valid_levels: + self.log.warning( + f"Invalid thinking level '{level}'. Valid values are: {', '.join(valid_levels)}. " + "Falling back to model default." + ) + return None + + supported_levels = self._get_supported_thinking_levels(model_id) + if not supported_levels or normalized in supported_levels: + return normalized + + coerced_level = self._coerce_thinking_level(normalized, supported_levels) + if coerced_level: + self.log.warning( + f"Thinking level '{level}' is not supported for model '{model_id}'. " + f"Using '{coerced_level}' instead. Supported values: {', '.join(supported_levels)}." + ) + return coerced_level + + self.log.warning( + f"Thinking level '{level}' is not supported for model '{model_id}'. Supported values: {', '.join(supported_levels)}. " + "Falling back to model default." + ) + return None + + def _validate_thinking_budget(self, budget: int) -> int: + """ + Validate and normalize the thinking budget value. + + Args: + budget: The thinking budget integer to validate + + Returns: + Validated budget: -1 for dynamic, 0 to disable, or 1-32768 for fixed limit + """ + # -1 means dynamic thinking (let the model decide) + if budget == -1: + return -1 + + # 0 means disable thinking + if budget == 0: + return 0 + + # Validate positive range (1-32768) + if budget > 0: + if budget > 32768: + self.log.warning( + f"Thinking budget {budget} exceeds maximum of 32768. Clamping to 32768." + ) + return 32768 + return budget + + # Negative values (except -1) are invalid, treat as -1 (dynamic) + self.log.warning( + f"Invalid thinking budget {budget}. Only -1 (dynamic), 0 (disabled), or 1-32768 are valid. " + "Falling back to dynamic thinking." + ) + return -1 + + def _validate_aspect_ratio(self, aspect_ratio: str) -> Optional[str]: + """ + Validate and normalize the aspect ratio value. + + Args: + aspect_ratio: The aspect ratio string to validate + + Returns: + Validated aspect ratio string, None for "default", or "1:1" as fallback for invalid values + """ + if not aspect_ratio or aspect_ratio == "default": + self.log.debug("Using default aspect ratio (None)") + return None + + normalized = aspect_ratio.strip() + valid_ratios = [r for r in ASPECT_RATIO_OPTIONS if r != "default"] + + if normalized in valid_ratios: + return normalized + + self.log.warning( + f"Invalid aspect ratio '{aspect_ratio}'. Valid values are: {', '.join(valid_ratios)}. " + "Using default '1:1'." + ) + return "1:1" + + def _validate_resolution(self, resolution: str) -> Optional[str]: + """ + Validate and normalize the resolution value. + + Args: + resolution: The resolution string to validate + + Returns: + Validated resolution string, None for "default", or "2K" as fallback for invalid values + """ + if not resolution or resolution.lower() == "default": + self.log.debug("Using default resolution (None)") + return None + + normalized = resolution.strip().upper() + valid_resolutions = [r for r in RESOLUTION_OPTIONS if r.lower() != "default"] + + if normalized in valid_resolutions: + return normalized + + self.log.warning( + f"Invalid resolution '{resolution}'. Valid values are: {', '.join(valid_resolutions)}. " + "Using default '2K'." + ) + return "2K" + + def _check_video_generation_support(self, model_id: str) -> bool: + model_lower = model_id.lower() + return model_lower.startswith("veo-") or ( + "veo" in model_lower and "generate" in model_lower + ) + + def _check_veo_3_1_support(self, model_id: str) -> bool: + """Check if a Veo model is version 3.1 (supports reference images, interpolation, 4k, extension).""" + return "veo-3.1" in model_id.lower() + + def _get_veo_model_capabilities(self, model_id: str) -> Dict[str, Any]: + """Return per-model feature support matrix based on official Google Veo documentation.""" + model_lower = model_id.lower() + is_fast = "fast" in model_lower + + if "veo-3.1" in model_lower: + return { + "version": "3.1", + "is_fast": is_fast, + "supports_enhance_prompt": not is_fast, + "supports_resolution": True, + "valid_resolutions": ["720p", "1080p", "4k"], + "valid_durations": [4, 6, 8], + "max_videos": 1, + "supports_reference_images": True, + "supports_last_frame": True, + "supports_extension": True, + } + if "veo-3" in model_lower: + return { + "version": "3", + "is_fast": is_fast, + "supports_enhance_prompt": not is_fast, + "supports_resolution": True, + "valid_resolutions": ["720p", "1080p"], + "valid_durations": [8], + "max_videos": 1, + "supports_reference_images": False, + "supports_last_frame": True, + "supports_extension": False, + } + if "veo-2" in model_lower: + return { + "version": "2", + "is_fast": False, + "supports_enhance_prompt": False, + "supports_resolution": False, + "valid_resolutions": [], + "valid_durations": [5, 6, 8], + "max_videos": 2, + "supports_reference_images": False, + "supports_last_frame": True, + "supports_extension": False, + } + return { + "version": "unknown", + "is_fast": is_fast, + "supports_enhance_prompt": False, + "supports_resolution": False, + "valid_resolutions": [], + "valid_durations": [8], + "max_videos": 1, + "supports_reference_images": False, + "supports_last_frame": False, + "supports_extension": False, + } + + def _validate_video_aspect_ratio(self, aspect_ratio: str) -> Optional[str]: + if not aspect_ratio or aspect_ratio == "default": + return None + normalized = aspect_ratio.strip() + valid = [r for r in VIDEO_ASPECT_RATIO_OPTIONS if r != "default"] + if normalized in valid: + return normalized + self.log.warning( + f"Invalid video aspect ratio '{aspect_ratio}'. Valid: {', '.join(valid)}. Using default." + ) + return None + + def _validate_video_resolution(self, resolution: str) -> Optional[str]: + if not resolution or resolution.lower() == "default": + return None + normalized = resolution.strip().lower() + valid = [r for r in VIDEO_RESOLUTION_OPTIONS if r.lower() != "default"] + if normalized in valid: + return normalized + self.log.warning( + f"Invalid video resolution '{resolution}'. Valid: {', '.join(valid)}. Using default." + ) + return None + + def _validate_video_duration(self, duration: str) -> Optional[int]: + if not duration or duration.lower() == "default": + return None + valid = {int(d) for d in VIDEO_DURATION_OPTIONS if d != "default"} + try: + val = int(duration) + if val in valid: + return val + except (ValueError, TypeError): + pass + self.log.warning( + f"Invalid video duration '{duration}'. Valid: {', '.join(str(v) for v in sorted(valid))}. Using default." + ) + return None + + def _build_video_generation_config( + self, + body: Dict[str, Any], + __user__: Optional[dict] = None, + model_id: str = "", + ) -> types.GenerateVideosConfig: + """Build GenerateVideosConfig from valves, user overrides, and model capabilities.""" + caps = self._get_veo_model_capabilities(model_id) + + user_ar = self._get_user_valve_value(__user__, "VIDEO_GENERATION_ASPECT_RATIO") + aspect_ratio = self._validate_video_aspect_ratio( + body.get( + "aspect_ratio", user_ar or self.valves.VIDEO_GENERATION_ASPECT_RATIO + ) + ) + + user_res = self._get_user_valve_value(__user__, "VIDEO_GENERATION_RESOLUTION") + resolution = self._validate_video_resolution( + body.get("resolution", user_res or self.valves.VIDEO_GENERATION_RESOLUTION) + ) + + user_dur = self._get_user_valve_value(__user__, "VIDEO_GENERATION_DURATION") + duration_seconds = self._validate_video_duration( + body.get("duration", user_dur or self.valves.VIDEO_GENERATION_DURATION) + ) + + negative_prompt = ( + body.get("negative_prompt", self.valves.VIDEO_GENERATION_NEGATIVE_PROMPT) + or None + ) + + person_generation_raw = body.get( + "person_generation", self.valves.VIDEO_GENERATION_PERSON_GENERATION + ) + person_generation = None + if person_generation_raw and person_generation_raw != "default": + valid_person_values = [ + v for v in VIDEO_PERSON_GENERATION_OPTIONS if v != "default" + ] + if person_generation_raw in valid_person_values: + person_generation = person_generation_raw + else: + self.log.warning( + f"Invalid person_generation '{person_generation_raw}'. " + f"Valid: {', '.join(valid_person_values)}. Ignoring." + ) + + enhance_prompt = body.get( + "enhance_prompt", self.valves.VIDEO_GENERATION_ENHANCE_PROMPT + ) + + number_of_videos_raw = body.get("number_of_videos", 1) + try: + number_of_videos = int(number_of_videos_raw) + except (ValueError, TypeError): + self.log.warning( + f"Invalid number_of_videos '{number_of_videos_raw}', defaulting to 1" + ) + number_of_videos = 1 + + config_params: Dict[str, Any] = { + "number_of_videos": min(max(number_of_videos, 1), caps["max_videos"]), + } + + # enhance_prompt: not supported by Fast models or Veo 2 + if caps["supports_enhance_prompt"] and enhance_prompt: + config_params["enhance_prompt"] = enhance_prompt + + if aspect_ratio: + config_params["aspect_ratio"] = aspect_ratio + + # Resolution: not supported by Veo 2; model-specific valid values + if resolution and caps["supports_resolution"]: + if resolution in caps["valid_resolutions"]: + config_params["resolution"] = resolution + else: + self.log.warning( + f"Resolution '{resolution}' not supported by {model_id}. " + f"Valid: {', '.join(caps['valid_resolutions'])}. Using default." + ) + + # Duration: model-specific valid values + if duration_seconds: + if duration_seconds in caps["valid_durations"]: + config_params["duration_seconds"] = duration_seconds + else: + self.log.warning( + f"Duration {duration_seconds}s not supported by {model_id}. " + f"Valid: {', '.join(str(d) for d in caps['valid_durations'])}. Using default." + ) + + if negative_prompt: + config_params["negative_prompt"] = negative_prompt + if person_generation: + config_params["person_generation"] = person_generation + + self.log.debug(f"Video generation config for {model_id}: {config_params}") + return types.GenerateVideosConfig(**config_params) + + def pipes(self) -> List[Dict[str, str]]: + """ + Returns a list of available Google Gemini models for the UI. + + Returns: + List of dictionaries containing model id and name. + """ + try: + self.name = "Google Gemini: " + return self.get_google_models() + except ValueError as e: + # Handle the case where API key is missing during pipe listing + self.log.error(f"Error during pipes listing (validation): {e}") + return [{"id": "error", "name": str(e)}] + except Exception as e: + # Handle other potential errors during model fetching + self.log.exception( + f"An unexpected error occurred during pipes listing: {str(e)}" + ) + return [{"id": "error", "name": f"An unexpected error occurred: {str(e)}"}] + + def _prepare_model_id(self, model_id: str) -> str: + """ + Prepare and validate the model ID for use with the API. + + Args: + model_id: The original model ID from the user + + Returns: + Properly formatted model ID + + Raises: + ValueError: If the model ID is invalid or unsupported + """ + original_model_id = model_id + model_id = self.strip_prefix(model_id) + + valid_prefixes = ("gemini-", "veo-") + + # If the model ID doesn't match a known prefix, try to find it by name + if not model_id.startswith(valid_prefixes): + models_list = self.get_google_models() + found_model = next( + (m["id"] for m in models_list if m["name"] == original_model_id), None + ) + if found_model and found_model.startswith(valid_prefixes): + model_id = found_model + self.log.debug( + f"Mapped model name '{original_model_id}' to model ID '{model_id}'" + ) + else: + if not model_id.startswith(valid_prefixes): + self.log.error( + f"Invalid or unsupported model ID: '{original_model_id}'" + ) + raise ValueError( + f"Invalid or unsupported Google model ID or name: '{original_model_id}'" + ) + + return model_id + + def _prepare_content( + self, messages: List[Dict[str, Any]] + ) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """ + Prepare messages content for the API and extract system message if present. + + Args: + messages: List of message objects from the request + + Returns: + Tuple of (prepared content list, system message string or None) + """ + # Extract user-defined system message + user_system_message = next( + (msg["content"] for msg in messages if msg.get("role") == "system"), + None, + ) + + # Combine with default system prompt if configured + system_message = self._combine_system_prompts(user_system_message) + + # Prepare contents for the API + contents = [] + for message in messages: + role = message.get("role") + if role == "system": + continue # Skip system messages, handled separately + + content = message.get("content", "") + parts = [] + + # Handle different content types + if isinstance(content, list): # Multimodal content + parts.extend(self._process_multimodal_content(content)) + elif isinstance(content, str): # Plain text content + parts.append({"text": content}) + else: + self.log.warning(f"Unsupported message content type: {type(content)}") + continue # Skip unsupported content + + # Map roles: 'assistant' -> 'model', 'user' -> 'user' + api_role = "model" if role == "assistant" else "user" + if parts: # Only add if there are parts + contents.append({"role": api_role, "parts": parts}) + + return contents, system_message + + def _process_multimodal_content( + self, content_list: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Process multimodal content (text and images). + + Args: + content_list: List of content items + + Returns: + List of processed parts for the Gemini API + """ + parts = [] + + for item in content_list: + if item.get("type") == "text": + parts.append({"text": item.get("text", "")}) + elif item.get("type") == "image_url": + image_url = item.get("image_url", {}).get("url", "") + + if image_url.startswith("data:image"): + # Handle base64 encoded image data with optimization + try: + # Optimize the image before processing + optimized_image = self._optimize_image_for_api(image_url) + header, encoded = optimized_image.split(",", 1) + mime_type = header.split(":")[1].split(";")[0] + + # Basic validation for image types + if mime_type not in [ + "image/jpeg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + ]: + self.log.warning( + f"Unsupported image mime type: {mime_type}" + ) + parts.append( + {"text": f"[Image type {mime_type} not supported]"} + ) + continue + + # Check if the encoded data is too large + if len(encoded) > 15 * 1024 * 1024: # 15MB limit for base64 + self.log.warning( + f"Image data too large: {len(encoded)} characters" + ) + parts.append( + { + "text": "[Image too large for processing - please use a smaller image]" + } + ) + continue + + parts.append( + { + "inline_data": { + "mime_type": mime_type, + "data": encoded, + } + } + ) + except Exception as img_ex: + self.log.exception(f"Could not parse image data URL: {img_ex}") + parts.append({"text": "[Image data could not be processed]"}) + else: + # Gemini API doesn't directly support image URLs + self.log.warning(f"Direct image URLs not supported: {image_url}") + parts.append({"text": f"[Image URL not processed: {image_url}]"}) + + return parts + + # _find_image removed (was single-image oriented and is superseded by multi-image logic) + + async def _extract_images_from_message( + self, + message: Dict[str, Any], + *, + stats_list: Optional[List[Dict[str, Any]]] = None, + ) -> Tuple[str, List[Dict[str, Any]]]: + """Extract prompt text and ALL images from a single user message. + + This replaces the previous single-image _find_image logic for image-capable + models so that multi-image prompts are respected. + + Returns: + (prompt_text, image_parts) + prompt_text: concatenated text content (may be empty) + image_parts: list of {"inline_data": {mime_type, data}} dicts + """ + content = message.get("content", "") + text_segments: List[str] = [] + image_parts: List[Dict[str, Any]] = [] + + # Helper to process a data URL or fetched file and append inline_data + def _add_image(data_url: str): + try: + optimized = self._optimize_image_for_api(data_url, stats_list) + header, b64 = optimized.split(",", 1) + mime = header.split(":", 1)[1].split(";", 1)[0] + image_parts.append({"inline_data": {"mime_type": mime, "data": b64}}) + except Exception as e: # pragma: no cover - defensive + self.log.warning(f"Skipping image (parse failure): {e}") + + # Regex to extract markdown image references + md_pattern = re.compile( + r"!\[[^\]]*\]\((data:image[^)]+|/files/[^)]+|/api/v1/files/[^)]+)\)" + ) + + # Structured multimodal array + if isinstance(content, list): + for item in content: + if item.get("type") == "text": + txt = item.get("text", "") + text_segments.append(txt) + # Also parse any markdown images embedded in the text + for match in md_pattern.finditer(txt): + url = match.group(1) + if url.startswith("data:"): + _add_image(url) + else: + b64 = await self._fetch_file_as_base64(url) + if b64: + _add_image(b64) + elif item.get("type") == "image_url": + url = item.get("image_url", {}).get("url", "") + if url.startswith("data:"): + _add_image(url) + elif "/files/" in url or "/api/v1/files/" in url: + b64 = await self._fetch_file_as_base64(url) + if b64: + _add_image(b64) + # Plain string message (may include markdown images) + elif isinstance(content, str): + text_segments.append(content) + for match in md_pattern.finditer(content): + url = match.group(1) + if url.startswith("data:"): + _add_image(url) + else: + b64 = await self._fetch_file_as_base64(url) + if b64: + _add_image(b64) + else: + self.log.debug( + f"Unsupported content type for image extraction: {type(content)}" + ) + + prompt_text = " ".join(s.strip() for s in text_segments if s.strip()) + return prompt_text, image_parts + + def _optimize_image_for_api( + self, image_data: str, stats_list: Optional[List[Dict[str, Any]]] = None + ) -> str: + """ + Optimize image data for Gemini API using configurable parameters. + + Returns: + Optimized base64 data URL + """ + # Check if optimization is enabled + if not self.valves.IMAGE_ENABLE_OPTIMIZATION: + self.log.debug("Image optimization disabled via configuration") + return image_data + + max_size_mb = self.valves.IMAGE_MAX_SIZE_MB + max_dimension = self.valves.IMAGE_MAX_DIMENSION + base_quality = self.valves.IMAGE_COMPRESSION_QUALITY + png_threshold = self.valves.IMAGE_PNG_COMPRESSION_THRESHOLD_MB + + self.log.debug( + f"Image optimization config: max_size={max_size_mb}MB, max_dim={max_dimension}px, quality={base_quality}, png_threshold={png_threshold}MB" + ) + try: + # Parse the data URL + if image_data.startswith("data:"): + header, encoded = image_data.split(",", 1) + mime_type = header.split(":")[1].split(";")[0] + else: + encoded = image_data + mime_type = "image/png" + + # Decode and analyze the image + image_bytes = base64.b64decode(encoded) + original_size_mb = len(image_bytes) / (1024 * 1024) + base64_size_mb = len(encoded) / (1024 * 1024) + + self.log.debug( + f"Original image: {original_size_mb:.2f} MB (decoded), {base64_size_mb:.2f} MB (base64), type: {mime_type}" + ) + + # Determine optimization strategy + reasons: List[str] = [] + if original_size_mb > max_size_mb: + reasons.append(f"size > {max_size_mb} MB") + if base64_size_mb > max_size_mb * 1.4: + reasons.append("base64 overhead") + if mime_type == "image/png" and original_size_mb > png_threshold: + reasons.append(f"PNG > {png_threshold}MB") + + # Always check dimensions + with Image.open(io.BytesIO(image_bytes)) as img: + width, height = img.size + resized_flag = False + if width > max_dimension or height > max_dimension: + reasons.append(f"dimensions > {max_dimension}px") + + # Early exit: no optimization triggers -> keep original, record stats + if not reasons: + if stats_list is not None: + stats_list.append( + { + "original_size_mb": round(original_size_mb, 4), + "final_size_mb": round(original_size_mb, 4), + "quality": None, + "format": mime_type.split("/")[-1].upper(), + "resized": False, + "reasons": ["no_optimization_needed"], + "final_hash": hashlib.sha256( + encoded.encode() + ).hexdigest(), + } + ) + self.log.debug( + "Skipping optimization: image already within thresholds" + ) + return image_data + + self.log.debug(f"Optimization triggers: {', '.join(reasons)}") + + # Convert to RGB for JPEG compression + if img.mode in ("RGBA", "LA", "P"): + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste( + img, + mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None, + ) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + + # Resize if needed + if width > max_dimension or height > max_dimension: + ratio = min(max_dimension / width, max_dimension / height) + new_size = (int(width * ratio), int(height * ratio)) + self.log.debug( + f"Resizing from {width}x{height} to {new_size[0]}x{new_size[1]}" + ) + img = img.resize(new_size, Image.Resampling.LANCZOS) + resized_flag = True + + # Determine quality levels based on original size and user configuration + if original_size_mb > 5.0: + quality_levels = [ + base_quality, + base_quality - 10, + base_quality - 20, + base_quality - 30, + base_quality - 40, + max(base_quality - 50, 25), + ] + elif original_size_mb > 2.0: + quality_levels = [ + base_quality, + base_quality - 5, + base_quality - 15, + base_quality - 25, + max(base_quality - 35, 35), + ] + else: + quality_levels = [ + min(base_quality + 5, 95), + base_quality, + base_quality - 10, + max(base_quality - 20, 50), + ] + + # Ensure quality levels are within valid range (1-100) + quality_levels = [max(1, min(100, q)) for q in quality_levels] + + # Try compression levels + for quality in quality_levels: + output_buffer = io.BytesIO() + format_type = ( + "JPEG" + if original_size_mb > png_threshold or "jpeg" in mime_type + else "PNG" + ) + output_mime = f"image/{format_type.lower()}" + + img.save( + output_buffer, + format=format_type, + quality=quality, + optimize=True, + ) + output_bytes = output_buffer.getvalue() + output_size_mb = len(output_bytes) / (1024 * 1024) + + if output_size_mb <= max_size_mb: + optimized_b64 = base64.b64encode(output_bytes).decode("utf-8") + self.log.debug( + f"Optimized: {original_size_mb:.2f} MB → {output_size_mb:.2f} MB (Q{quality})" + ) + if stats_list is not None: + stats_list.append( + { + "original_size_mb": round(original_size_mb, 4), + "final_size_mb": round(output_size_mb, 4), + "quality": quality, + "format": format_type, + "resized": resized_flag, + "reasons": reasons, + "final_hash": hashlib.sha256( + optimized_b64.encode() + ).hexdigest(), + } + ) + return f"data:{output_mime};base64,{optimized_b64}" + + # Fallback: minimum quality + output_buffer = io.BytesIO() + img.save(output_buffer, format="JPEG", quality=15, optimize=True) + output_bytes = output_buffer.getvalue() + output_size_mb = len(output_bytes) / (1024 * 1024) + optimized_b64 = base64.b64encode(output_bytes).decode("utf-8") + + self.log.warning( + f"Aggressive optimization: {output_size_mb:.2f} MB (Q15)" + ) + if stats_list is not None: + stats_list.append( + { + "original_size_mb": round(original_size_mb, 4), + "final_size_mb": round(output_size_mb, 4), + "quality": 15, + "format": "JPEG", + "resized": resized_flag, + "reasons": reasons + ["fallback_min_quality"], + "final_hash": hashlib.sha256( + optimized_b64.encode() + ).hexdigest(), + } + ) + return f"data:image/jpeg;base64,{optimized_b64}" + + except Exception as e: + self.log.error(f"Image optimization failed: {e}") + # Return original or safe fallback + if image_data.startswith("data:"): + if stats_list is not None: + stats_list.append( + { + "original_size_mb": None, + "final_size_mb": None, + "quality": None, + "format": None, + "resized": False, + "reasons": ["optimization_failed"], + "final_hash": ( + hashlib.sha256(encoded.encode()).hexdigest() + if "encoded" in locals() + else None + ), + } + ) + return image_data + return f"data:image/jpeg;base64,{encoded if 'encoded' in locals() else image_data}" + + async def _fetch_file_as_base64(self, file_url: str) -> Optional[str]: + """ + Fetch a file from Open WebUI's file system and convert to base64. + + Args: + file_url: File URL from Open WebUI + + Returns: + Base64 encoded file data or None if file not found + """ + try: + if "/api/v1/files/" in file_url: + fid = file_url.split("/api/v1/files/")[-1].split("/")[0].split("?")[0] + else: + fid = file_url.split("/files/")[-1].split("/")[0].split("?")[0] + + from pathlib import Path + from open_webui.models.files import Files + from open_webui.storage.provider import Storage + + file_obj = Files.get_file_by_id(fid) + if file_obj and file_obj.path: + file_path = Storage.get_file(file_obj.path) + file_path = Path(file_path) + if file_path.is_file(): + async with aiofiles.open(file_path, "rb") as fp: + raw = await fp.read() + enc = base64.b64encode(raw).decode() + mime = file_obj.meta.get("content_type", "image/png") + return f"data:{mime};base64,{enc}" + except Exception as e: + self.log.warning(f"Could not fetch file {file_url}: {e}") + return None + + async def _upload_image_with_status( + self, + image_data: Any, + mime_type: str, + __request__: Request, + __user__: dict, + __event_emitter__: Callable, + ) -> str: + """ + Unified image upload method with status updates and fallback handling. + + Returns: + URL to uploaded image or data URL fallback + """ + try: + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_upload", + "description": "Uploading generated image to your library...", + "done": False, + }, + } + ) + + self.user = user = Users.get_user_by_id(__user__["id"]) + + # Convert image data to base64 string if needed + if isinstance(image_data, bytes): + image_data_b64 = base64.b64encode(image_data).decode("utf-8") + else: + image_data_b64 = str(image_data) + + image_url = self._upload_image( + __request__=__request__, + user=user, + image_data=image_data_b64, + mime_type=mime_type, + ) + + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_upload", + "description": "Image uploaded successfully!", + "done": True, + }, + } + ) + + return image_url + + except Exception as e: + self.log.warning(f"File upload failed, falling back to data URL: {e}") + + if isinstance(image_data, bytes): + image_data_b64 = base64.b64encode(image_data).decode("utf-8") + else: + image_data_b64 = str(image_data) + + await __event_emitter__( + { + "type": "status", + "data": { + "action": "image_upload", + "description": "Using inline image (upload failed)", + "done": True, + }, + } + ) + + return f"data:{mime_type};base64,{image_data_b64}" + + def _upload_image( + self, __request__: Request, user: UserModel, image_data: str, mime_type: str + ) -> str: + """ + Upload generated image to Open WebUI's file system. + Expects base64 encoded string input. + + Args: + __request__: FastAPI request object + user: User model object + image_data: Base64 encoded image data string + mime_type: MIME type of the image + + Returns: + URL to the uploaded image or data URL fallback + """ + try: + self.log.debug( + f"Processing image data, type: {type(image_data)}, length: {len(image_data)}" + ) + + # Decode base64 string to bytes + try: + decoded_data = base64.b64decode(image_data) + self.log.debug( + f"Successfully decoded image data: {len(decoded_data)} bytes" + ) + except Exception as decode_error: + self.log.error(f"Failed to decode base64 data: {decode_error}") + # Try to add padding if missing + try: + missing_padding = len(image_data) % 4 + if missing_padding: + image_data += "=" * (4 - missing_padding) + decoded_data = base64.b64decode(image_data) + self.log.debug( + f"Successfully decoded with padding: {len(decoded_data)} bytes" + ) + except Exception as second_decode_error: + self.log.error(f"Still failed to decode: {second_decode_error}") + return f"data:{mime_type};base64,{image_data}" + + bio = io.BytesIO(decoded_data) + bio.seek(0) + + # Determine file extension + extension = "png" + if "jpeg" in mime_type or "jpg" in mime_type: + extension = "jpg" + elif "webp" in mime_type: + extension = "webp" + elif "gif" in mime_type: + extension = "gif" + + # Create filename + filename = f"gemini-generated-{uuid.uuid4().hex}.{extension}" + + # Upload with simple approach like reference + up_obj = upload_file( + request=__request__, + background_tasks=BackgroundTasks(), + file=UploadFile( + file=bio, + filename=filename, + headers=Headers({"content-type": mime_type}), + ), + process=False, # Matching reference - no heavy processing + user=user, + metadata={"mime_type": mime_type, "source": "gemini_image_generation"}, + ) + + self.log.debug( + f"Upload completed. File ID: {up_obj.id}, Decoded size: {len(decoded_data)} bytes" + ) + + # Generate URL using reference method + return __request__.app.url_path_for("get_file_content_by_id", id=up_obj.id) + + except Exception as e: + self.log.exception(f"Image upload failed, using data URL fallback: {e}") + # Fallback to data URL if upload fails + return f"data:{mime_type};base64,{image_data}" + + def _upload_video( + self, + __request__: Request, + user: UserModel, + video_data: bytes, + mime_type: str = "video/mp4", + ) -> Tuple[str, str]: + """Upload generated video to Open WebUI's file system. + + Returns: + Tuple of (content_url, file_id) + """ + bio = io.BytesIO(video_data) + bio.seek(0) + + extension = "mp4" + if "webm" in mime_type: + extension = "webm" + + filename = f"veo-generated-{uuid.uuid4().hex}.{extension}" + + up_obj = upload_file( + request=__request__, + background_tasks=BackgroundTasks(), + file=UploadFile( + file=bio, + filename=filename, + headers=Headers({"content-type": mime_type}), + ), + process=False, + user=user, + metadata={"mime_type": mime_type, "source": "veo_video_generation"}, + ) + + content_url = __request__.app.url_path_for( + "get_file_content_by_id", id=up_obj.id + ) + self.log.debug( + f"Video upload completed. File ID: {up_obj.id}, Size: {len(video_data)} bytes" + ) + return content_url, up_obj.id + + async def _upload_video_with_status( + self, + video_data: bytes, + mime_type: str, + __request__: Request, + __user__: dict, + __event_emitter__: Callable, + ) -> Tuple[str, Optional[str]]: + """Upload video with status updates and data-URL fallback. + + Returns: + Tuple of (content_url_or_data_url, file_id_or_None) + """ + try: + await __event_emitter__( + { + "type": "status", + "data": { + "action": "video_upload", + "description": "Uploading generated video to your library...", + "done": False, + }, + } + ) + + self.user = user = Users.get_user_by_id(__user__["id"]) + video_url, file_id = self._upload_video( + __request__=__request__, + user=user, + video_data=video_data, + mime_type=mime_type, + ) + + await __event_emitter__( + { + "type": "status", + "data": { + "action": "video_upload", + "description": "Video uploaded successfully!", + "done": True, + }, + } + ) + return video_url, file_id + + except Exception as e: + self.log.warning(f"Video upload failed, falling back to data URL: {e}") + video_data_b64 = base64.b64encode(video_data).decode("utf-8") + await __event_emitter__( + { + "type": "status", + "data": { + "action": "video_upload", + "description": "Using inline video (upload failed)", + "done": True, + }, + } + ) + return f"data:{mime_type};base64,{video_data_b64}", None + + def _get_user_valve_value( + self, __user__: Optional[dict], valve_name: str + ) -> Optional[str]: + """Get a user valve value, returning None if not set or set to 'default'""" + if __user__ and "valves" in __user__: + value = getattr(__user__["valves"], valve_name, None) + if value and value != "default": + return value + return None + + def _configure_generation( + self, + body: Dict[str, Any], + system_instruction: Optional[str], + __metadata__: Dict[str, Any], + __tools__: dict[str, Any] | None = None, + __user__: Optional[dict] = None, + enable_image_generation: bool = False, + model_id: str = "", + ) -> types.GenerateContentConfig: + """ + Configure generation parameters and safety settings. + + Args: + body: The request body containing generation parameters + system_instruction: Optional system instruction string + enable_image_generation: Whether to enable image generation + model_id: The model ID being used (for feature support checks) + + Returns: + types.GenerateContentConfig + """ + gen_config_params = { + "temperature": body.get("temperature"), + "top_p": body.get("top_p"), + "top_k": body.get("top_k"), + "max_output_tokens": body.get("max_tokens"), + "stop_sequences": body.get("stop") or None, + "system_instruction": system_instruction, + } + + # Enable image generation if requested + if enable_image_generation: + gen_config_params["response_modalities"] = ["TEXT", "IMAGE"] + + # Configure image generation parameters (aspect ratio and resolution) + # ImageConfig is only supported by Gemini 3 models + if self._check_image_config_support(model_id): + # Body parameters override valve defaults for per-request customization + # Get aspect_ratio: body > user_valves (if not "default") > system valves + user_aspect_ratio = self._get_user_valve_value( + __user__, "IMAGE_GENERATION_ASPECT_RATIO" + ) + aspect_ratio = body.get( + "aspect_ratio", + user_aspect_ratio or self.valves.IMAGE_GENERATION_ASPECT_RATIO, + ) + + # Get resolution: body > user_valves (if not "default") > system valves + user_resolution = self._get_user_valve_value( + __user__, "IMAGE_GENERATION_RESOLUTION" + ) + resolution = body.get( + "resolution", + user_resolution or self.valves.IMAGE_GENERATION_RESOLUTION, + ) + + # Validate and normalize the values + validated_aspect_ratio = self._validate_aspect_ratio(aspect_ratio) + validated_resolution = self._validate_resolution(resolution) + + # Create image config if we have at least one valid value + if validated_aspect_ratio or validated_resolution: + try: + image_config_params = {} + if validated_aspect_ratio: + image_config_params["aspect_ratio"] = validated_aspect_ratio + if validated_resolution: + image_config_params["image_size"] = validated_resolution + gen_config_params["image_config"] = types.ImageConfig( + **image_config_params + ) + self.log.debug( + f"Image generation config: aspect_ratio={validated_aspect_ratio}, resolution={validated_resolution}" + ) + except (AttributeError, TypeError) as e: + # Fall back if SDK does not support ImageConfig + self.log.warning( + f"ImageConfig not supported by SDK version: {e}. Image generation will use default settings." + ) + except Exception as e: + # Log unexpected errors but continue without image config + self.log.warning( + f"Unexpected error configuring ImageConfig: {e}" + ) + else: + self.log.debug( + f"Model {model_id} does not support ImageConfig (aspect_ratio/resolution). " + "ImageConfig is only available for Gemini 3 image models." + ) + + # Configure Gemini thinking/reasoning for models that support it + # This is independent of include_thoughts - thinking config controls HOW the model reasons, + # while include_thoughts controls whether the reasoning is shown in the output + if self._check_thinking_support(model_id): + try: + thinking_config_params: Dict[str, Any] = {} + + # Determine include_thoughts setting + include_thoughts = body.get("include_thoughts", True) + if not self.valves.INCLUDE_THOUGHTS: + include_thoughts = False + self.log.debug( + "Thoughts output disabled via GOOGLE_INCLUDE_THOUGHTS" + ) + thinking_config_params["include_thoughts"] = include_thoughts + + # Check if model supports thinking_level (Gemini 3 models) + if self._check_thinking_level_support(model_id): + # For Gemini 3 models, use thinking_level (not thinking_budget) + # Per-chat reasoning_effort overrides environment-level THINKING_LEVEL + reasoning_effort = body.get("reasoning_effort") + validated_level = None + source = None + + if reasoning_effort: + validated_level = self._validate_thinking_level( + reasoning_effort, model_id + ) + if validated_level: + source = "per-chat reasoning_effort" + else: + self.log.debug( + f"Invalid reasoning_effort '{reasoning_effort}', falling back to THINKING_LEVEL" + ) + + # Fall back to environment-level THINKING_LEVEL if no valid reasoning_effort + if not validated_level: + validated_level = self._validate_thinking_level( + self.valves.THINKING_LEVEL, model_id + ) + if validated_level: + source = "THINKING_LEVEL" + + if validated_level: + thinking_config_params["thinking_level"] = validated_level + self.log.debug( + f"Using thinking_level='{validated_level}' from {source} for model {model_id}" + ) + else: + self.log.debug( + f"Using default thinking level for model {model_id}" + ) + else: + # For non-Gemini 3 models (e.g., Gemini 2.5), use thinking_budget + # Body-level thinking_budget overrides environment-level THINKING_BUDGET + body_thinking_budget = body.get("thinking_budget") + validated_budget = None + source = None + + if body_thinking_budget is not None: + validated_budget = self._validate_thinking_budget( + body_thinking_budget + ) + if validated_budget is not None: + source = "body thinking_budget" + else: + self.log.debug( + f"Invalid body thinking_budget '{body_thinking_budget}', falling back to THINKING_BUDGET" + ) + + # Fall back to environment-level THINKING_BUDGET + if validated_budget is None: + validated_budget = self._validate_thinking_budget( + self.valves.THINKING_BUDGET + ) + if validated_budget is not None: + source = "THINKING_BUDGET" + + if validated_budget == 0: + # Disable thinking if budget is 0 + thinking_config_params["thinking_budget"] = 0 + self.log.debug( + f"Thinking disabled via thinking_budget=0 from {source} for model {model_id}" + ) + elif validated_budget is not None and validated_budget > 0: + thinking_config_params["thinking_budget"] = validated_budget + self.log.debug( + f"Using thinking_budget={validated_budget} from {source} for model {model_id}" + ) + else: + # -1 or None means dynamic thinking + thinking_config_params["thinking_budget"] = -1 + self.log.debug( + f"Using dynamic thinking (model decides) for model {model_id}" + ) + + gen_config_params["thinking_config"] = types.ThinkingConfig( + **thinking_config_params + ) + except (AttributeError, TypeError) as e: + # Fall back if SDK/model does not support ThinkingConfig + self.log.debug(f"ThinkingConfig not supported: {e}") + except Exception as e: + # Log unexpected errors but continue without thinking config + self.log.warning(f"Unexpected error configuring ThinkingConfig: {e}") + + # Configure safety settings + if self.valves.USE_PERMISSIVE_SAFETY: + safety_settings = [ + types.SafetySetting( + category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE" + ), + types.SafetySetting( + category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE" + ), + ] + gen_config_params |= {"safety_settings": safety_settings} + + # Add various tools to Gemini as required + features = __metadata__.get("features", {}) + params = __metadata__.get("params", {}) + tools = [] + + if features.get("google_search_tool", False): + if self.valves.USE_ENTERPRISE_WEB_SEARCH: + self.log.debug("Enabling Enterprise Web Search grounding") + tools.append( + types.Tool(enterprise_web_search=types.EnterpriseWebSearch()) + ) + else: + self.log.debug("Enabling Google search grounding") + tools.append(types.Tool(google_search=types.GoogleSearch())) + self.log.debug("Enabling URL context grounding") + tools.append(types.Tool(url_context=types.UrlContext())) + + if features.get("vertex_ai_search", False) or ( + self.valves.USE_VERTEX_AI + and (self.valves.VERTEX_AI_RAG_STORE or os.getenv("VERTEX_AI_RAG_STORE")) + ): + vertex_rag_store = ( + params.get("vertex_rag_store") + or self.valves.VERTEX_AI_RAG_STORE + or os.getenv("VERTEX_AI_RAG_STORE") + ) + if vertex_rag_store: + self.log.debug( + f"Enabling Vertex AI Search grounding: {vertex_rag_store}" + ) + tools.append( + types.Tool( + retrieval=types.Retrieval( + vertex_ai_search=types.VertexAISearch( + datastore=vertex_rag_store + ) + ) + ) + ) + else: + self.log.warning( + "Vertex AI Search requested but vertex_rag_store not provided in params, valves, or env" + ) + + if __tools__ is not None and params.get("function_calling") == "native": + for name, tool_def in __tools__.items(): + if not name.startswith("_"): + tool = tool_def["callable"] + self.log.debug( + f"Adding tool '{name}' with signature {tool.__signature__}" + ) + tools.append(tool) + + if tools: + gen_config_params["tools"] = tools + + # Filter out None values for generation config + filtered_params = {k: v for k, v in gen_config_params.items() if v is not None} + return types.GenerateContentConfig(**filtered_params) + + @staticmethod + def _format_grounding_chunks_as_sources( + grounding_chunks: list[types.GroundingChunk], + ): + formatted_sources = [] + for chunk in grounding_chunks: + if hasattr(chunk, "retrieved_context") and chunk.retrieved_context: + context = chunk.retrieved_context + formatted_sources.append( + { + "source": { + "name": getattr(context, "title", None) or "Document", + "type": "vertex_ai_search", + "uri": getattr(context, "uri", None), + }, + "document": [getattr(context, "chunk_text", None) or ""], + "metadata": [ + {"source": getattr(context, "title", None) or "Document"} + ], + } + ) + elif hasattr(chunk, "web") and chunk.web: + context = chunk.web + uri = context.uri + title = context.title or "Source" + + formatted_sources.append( + { + "source": { + "name": title, + "type": "web_search_results", + "url": uri, + }, + "document": ["Click the link to view the content."], + "metadata": [{"source": title}], + } + ) + return formatted_sources + + async def _process_grounding_metadata( + self, + grounding_metadata_list: List[types.GroundingMetadata], + text: str, + __event_emitter__: Callable, + ): + """Process and emit grounding metadata events.""" + grounding_chunks = [] + web_search_queries = [] + grounding_supports = [] + + for metadata in grounding_metadata_list: + if metadata.grounding_chunks: + grounding_chunks.extend(metadata.grounding_chunks) + if metadata.web_search_queries: + web_search_queries.extend(metadata.web_search_queries) + if metadata.grounding_supports: + grounding_supports.extend(metadata.grounding_supports) + + # Add sources to the response + if grounding_chunks: + sources = self._format_grounding_chunks_as_sources(grounding_chunks) + await __event_emitter__( + {"type": "chat:completion", "data": {"sources": sources}} + ) + + # Add status specifying google queries used for grounding + if web_search_queries: + await __event_emitter__( + { + "type": "status", + "data": { + "action": "web_search", + "description": "This response was grounded with Google Search", + "urls": [ + f"https://www.google.com/search?q={query}" + for query in web_search_queries + ], + }, + } + ) + + # Add citations in the text body + replaced_text: Optional[str] = None + if grounding_supports: + # Citation indexes are in bytes + ENCODING = "utf-8" + text_bytes = text.encode(ENCODING) + last_byte_index = 0 + cited_chunks = [] + + for support in grounding_supports: + cited_chunks.append( + text_bytes[last_byte_index : support.segment.end_index].decode( + ENCODING + ) + ) + + # Generate and append citations (e.g., "[1][2]") + footnotes = "".join( + [f"[{i + 1}]" for i in support.grounding_chunk_indices] + ) + cited_chunks.append(f" {footnotes}") + + # Update index for the next segment + last_byte_index = support.segment.end_index + + # Append any remaining text after the last citation + if last_byte_index < len(text_bytes): + cited_chunks.append(text_bytes[last_byte_index:].decode(ENCODING)) + + replaced_text = "".join(cited_chunks) + + return replaced_text if replaced_text is not None else text + + async def _handle_streaming_response( + self, + response_iterator: Any, + __event_emitter__: Callable, + __request__: Optional[Request] = None, + __user__: Optional[dict] = None, + ) -> AsyncIterator[Union[str, Dict[str, Any]]]: + """ + Handle streaming response from Gemini API. + + Args: + response_iterator: Iterator from generate_content + __event_emitter__: Event emitter for status updates + + Returns: + Generator yielding text chunks + """ + + async def emit_chat_event(event_type: str, data: Dict[str, Any]) -> None: + if not __event_emitter__: + return + try: + await __event_emitter__({"type": event_type, "data": data}) + except Exception as emit_error: # pragma: no cover - defensive + self.log.warning(f"Failed to emit {event_type} event: {emit_error}") + + await emit_chat_event("chat:start", {"role": "assistant"}) + + grounding_metadata_list = [] + # Accumulate content separately for answer and thoughts + answer_chunks: list[str] = [] + thought_chunks: list[str] = [] + thinking_started_at: Optional[float] = None + stream_usage_metadata = None + + try: + async for chunk in response_iterator: + # Capture usage metadata (final chunk has complete data) + if getattr(chunk, "usage_metadata", None): + stream_usage_metadata = chunk.usage_metadata + + # Check for safety feedback or empty chunks + if not chunk.candidates: + # Check prompt feedback + if chunk.prompt_feedback and chunk.prompt_feedback.block_reason: + block_reason = chunk.prompt_feedback.block_reason.name + message = f"[Blocked due to Prompt Safety: {block_reason}]" + await emit_chat_event( + "chat:finish", + { + "role": "assistant", + "content": message, + "done": True, + "error": True, + }, + ) + yield message + else: + message = "[Blocked by safety settings]" + await emit_chat_event( + "chat:finish", + { + "role": "assistant", + "content": message, + "done": True, + "error": True, + }, + ) + yield message + return # Stop generation + + if chunk.candidates[0].grounding_metadata: + grounding_metadata_list.append( + chunk.candidates[0].grounding_metadata + ) + # Prefer fine-grained parts to split thoughts vs. normal text + parts = [] + try: + parts = chunk.candidates[0].content.parts or [] + except Exception as parts_error: + # Fallback: use aggregated text if parts aren't accessible + self.log.warning(f"Failed to access content parts: {parts_error}") + if hasattr(chunk, "text") and chunk.text: + answer_chunks.append(chunk.text) + await __event_emitter__( + { + "type": "chat:message:delta", + "data": { + "role": "assistant", + "content": chunk.text, + }, + } + ) + continue + + for part in parts: + try: + # Thought parts (internal reasoning) + if getattr(part, "thought", False) and getattr( + part, "text", None + ): + if thinking_started_at is None: + thinking_started_at = time.time() + thought_chunks.append(part.text) + # Emit a live preview of what is currently being thought + preview = part.text.replace("\n", " ").strip() + MAX_PREVIEW = 120 + if len(preview) > MAX_PREVIEW: + preview = preview[:MAX_PREVIEW].rstrip() + "…" + await __event_emitter__( + { + "type": "status", + "data": { + "action": "thinking", + "description": f"Thinking… {preview}", + "done": False, + "hidden": False, + }, + } + ) + + # Regular answer text + elif getattr(part, "text", None): + answer_chunks.append(part.text) + await __event_emitter__( + { + "type": "chat:message:delta", + "data": { + "role": "assistant", + "content": part.text, + }, + } + ) + except Exception as part_error: + # Log part processing errors but continue with the stream + self.log.warning(f"Error processing content part: {part_error}") + continue + + # After processing all chunks, handle grounding data + final_answer_text = "".join(answer_chunks) + if grounding_metadata_list and __event_emitter__: + cited = await self._process_grounding_metadata( + grounding_metadata_list, + final_answer_text, + __event_emitter__, + ) + final_answer_text = cited or final_answer_text + + final_content = final_answer_text + details_block: Optional[str] = None + + if thought_chunks: + duration_s = int( + max(0, time.time() - (thinking_started_at or time.time())) + ) + # Format each line with > for blockquote while preserving formatting + thought_content = "".join(thought_chunks).strip() + quoted_lines = [] + for line in thought_content.split("\n"): + quoted_lines.append(f"> {line}") + quoted_content = "\n".join(quoted_lines) + + details_block = f"""
+Thought ({duration_s}s) + +{quoted_content} + +
""".strip() + final_content = f"{details_block}{final_answer_text}" + + if not final_content: + final_content = "" + + # Ensure downstream consumers (UI, TTS) receive the complete response once streaming ends. + await emit_chat_event( + "replace", {"role": "assistant", "content": final_content} + ) + await emit_chat_event( + "chat:message", + {"role": "assistant", "content": final_content, "done": True}, + ) + + if thought_chunks: + # Clear the thinking status without a summary in the status emitter + await __event_emitter__( + { + "type": "status", + "data": {"action": "thinking", "done": True, "hidden": True}, + } + ) + + # Yield usage data as dict so the middleware can extract and save it to DB + usage = self._build_usage_dict(stream_usage_metadata) + if usage: + yield {"usage": usage} + + await emit_chat_event( + "chat:finish", + {"role": "assistant", "content": final_content, "done": True}, + ) + + # Yield final content to ensure the async iterator completes properly. + # This ensures the response is persisted even if the user navigates away. + yield final_content + + except Exception as e: + self.log.exception(f"Error during streaming: {e}") + # Check if it's a chunk size error and provide specific guidance + error_msg = str(e).lower() + if "chunk too big" in error_msg or "chunk size" in error_msg: + message = "Error: Image too large for processing. Please try with a smaller image (max 15 MB recommended) or reduce image quality." + elif "quota" in error_msg or "rate limit" in error_msg: + message = "Error: API quota exceeded. Please try again later." + else: + message = f"Error during streaming: {e}" + await emit_chat_event( + "chat:finish", + { + "role": "assistant", + "content": message, + "done": True, + "error": True, + }, + ) + yield message + + @staticmethod + def _build_usage_dict(usage_metadata: Any) -> Optional[Dict[str, int]]: + """Extract token usage from Gemini usage_metadata into a standardised dict.""" + if not usage_metadata: + return None + usage: Dict[str, int] = {} + if getattr(usage_metadata, "prompt_token_count", None) is not None: + usage["prompt_tokens"] = usage_metadata.prompt_token_count + if getattr(usage_metadata, "candidates_token_count", None) is not None: + usage["completion_tokens"] = usage_metadata.candidates_token_count + if usage: + usage["total_tokens"] = usage.get("prompt_tokens", 0) + usage.get( + "completion_tokens", 0 + ) + return usage + return None + + def _get_safety_block_message(self, response: Any) -> Optional[str]: + """Check for safety blocks and return appropriate message.""" + # Check prompt feedback + if response.prompt_feedback and response.prompt_feedback.block_reason: + return f"[Blocked due to Prompt Safety: {response.prompt_feedback.block_reason.name}]" + + # Check candidates + if not response.candidates: + return "[Blocked by safety settings or no candidates generated]" + + # Check candidate finish reason + candidate = response.candidates[0] + if candidate.finish_reason == types.FinishReason.SAFETY: + blocking_rating = next( + (r for r in candidate.safety_ratings if r.blocked), None + ) + reason = f" ({blocking_rating.category.name})" if blocking_rating else "" + return f"[Blocked by safety settings{reason}]" + elif candidate.finish_reason == types.FinishReason.PROHIBITED_CONTENT: + return "[Content blocked due to prohibited content policy violation]" + + return None + + async def _generate_video( + self, + body: Dict[str, Any], + model_id: str, + __event_emitter__: Callable, + __request__: Optional[Request] = None, + __user__: Optional[dict] = None, + ) -> Union[str, Dict[str, Any]]: + """Generate video using Google Veo models (long-running operation with polling).""" + + async def emit_status(description: str, done: bool) -> None: + if not __event_emitter__: + return + try: + await __event_emitter__( + { + "type": "status", + "data": { + "action": "video_generation", + "description": description, + "done": done, + }, + } + ) + except Exception as e: + self.log.warning(f"Failed to emit video status event: {e}") + + messages = body.get("messages", []) + last_user_msg = next( + (m for m in reversed(messages) if m.get("role") == "user"), None + ) + if not last_user_msg: + return "Error: No user message found for video generation" + + prompt, images = await self._extract_images_from_message(last_user_msg) + if not prompt: + return "Error: No prompt provided for video generation" + + # Convert first attached image to types.Image for image-to-video + reference_image = None + if images: + first_img = images[0] + try: + img_data = first_img.get("inline_data", {}) + raw_data = img_data.get("data", "") + img_bytes = base64.b64decode(raw_data) + reference_image = types.Image( + image_bytes=img_bytes, + mime_type=img_data.get("mime_type", "image/png"), + ) + self.log.debug("Using attached image for image-to-video generation") + except Exception as e: + self.log.warning(f"Failed to convert image for Veo: {e}") + + config = self._build_video_generation_config(body, __user__, model_id=model_id) + + await emit_status(f"Starting video generation with {model_id}...", False) + + client = self._get_client() + try: + generate_kwargs: Dict[str, Any] = { + "model": model_id, + "prompt": prompt, + "config": config, + } + if reference_image: + generate_kwargs["image"] = reference_image + operation = await client.aio.models.generate_videos(**generate_kwargs) + except Exception as e: + self.log.exception(f"Video generation request failed: {e}") + await emit_status(f"Video generation failed: {e}", True) + return f"Error starting video generation: {e}" + + poll_interval = max(self.valves.VIDEO_POLL_INTERVAL, 5) + poll_timeout = max(self.valves.VIDEO_POLL_TIMEOUT, 0) + elapsed = 0 + while not operation.done: + await asyncio.sleep(poll_interval) + elapsed += poll_interval + if poll_timeout > 0 and elapsed >= poll_timeout: + error_msg = ( + f"Video generation timed out after {elapsed}s " + f"(limit: {poll_timeout}s)" + ) + self.log.error(error_msg) + await emit_status(error_msg, True) + return f"Error: {error_msg}" + try: + operation = await client.aio.operations.get(operation) + except Exception as e: + self.log.warning(f"Polling error (will retry): {e}") + await emit_status(f"Generating video... ({elapsed}s elapsed)", False) + + if operation.error: + error_msg = str(operation.error) + self.log.error(f"Video generation failed: {error_msg}") + await emit_status(f"Video generation failed: {error_msg}", True) + return f"Video generation failed: {error_msg}" + + generated_videos = [] + response = operation.response + if not response or not response.generated_videos: + return "Error: No videos were generated" + + for idx, gen_video in enumerate(response.generated_videos): + video = gen_video.video + if not video: + self.log.warning(f"Video {idx}: no video object in response") + continue + + self.log.debug( + f"Video {idx}: uri={getattr(video, 'uri', None)}, " + f"name={getattr(video, 'name', None)}, " + f"has_bytes={bool(getattr(video, 'video_bytes', None))}" + ) + + video_bytes = None + if getattr(video, "video_bytes", None): + video_bytes = video.video_bytes + + # Download video bytes via SDK (sync version is more reliable) + if not video_bytes: + try: + await asyncio.to_thread(client.files.download, file=video) + video_bytes = getattr(video, "video_bytes", None) + self.log.debug( + f"Video {idx}: SDK download complete, " + f"has_bytes={bool(video_bytes)}" + ) + except Exception as dl_err: + self.log.warning(f"Video {idx} SDK download failed: {dl_err}") + + # Fallback: save to temp file via SDK + if not video_bytes: + tmp_path = None + try: + import tempfile + + with tempfile.NamedTemporaryFile( + suffix=".mp4", delete=False + ) as tmp: + tmp_path = tmp.name + await asyncio.to_thread(video.save, tmp_path) + async with aiofiles.open(tmp_path, "rb") as f: + video_bytes = await f.read() + self.log.debug( + f"Video {idx}: temp-file download complete, " + f"size={len(video_bytes)} bytes" + ) + except Exception as save_err: + self.log.warning(f"Video {idx} temp-file save failed: {save_err}") + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + if not video_bytes: + self.log.warning(f"Video {idx}: could not obtain video bytes") + continue + + mime_type = getattr(video, "mime_type", "video/mp4") or "video/mp4" + + file_id = None + video_url = None + if __request__ and __user__: + video_url, file_id = await self._upload_video_with_status( + video_bytes, mime_type, __request__, __user__, __event_emitter__ + ) + else: + video_data_b64 = base64.b64encode(video_bytes).decode("utf-8") + video_url = f"data:{mime_type};base64,{video_data_b64}" + + # Wrap in
so marked.lexer recognizes it as block-level HTML; + # HTMLToken.svelte then detects the inner