Compare commits

..

1 Commits

Author SHA1 Message Date
5d137ad31f chore(deps): bump the github-actions-dependencies group across 1 directory with 12 updates
Bumps the github-actions-dependencies group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `6.0.3` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.1.0` | `8.2.0` |
| [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `7.0.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.0.0` | `6.1.0` |
| [depot/build-push-action](https://github.com/depot/build-push-action) | `1.17.0` | `1.18.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [oven-sh/setup-bun](https://github.com/oven-sh/setup-bun) | `2.0.1` | `2.2.0` |
| [hoverkraft-tech/compose-action](https://github.com/hoverkraft-tech/compose-action) | `2.6.0` | `3.0.0` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |
| [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) | `1.0.127` | `1.0.140` |



Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...df4cb1c069)

Updates `astral-sh/setup-uv` from 8.1.0 to 8.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](08807647e7...fac544c07d)

Updates `codecov/codecov-action` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...fb8b3582c8e4def4969c97caa2f19720cb33a72f)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

Updates `docker/metadata-action` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](030e881283...80c7e94dd9)

Updates `depot/build-push-action` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/depot/build-push-action/releases)
- [Commits](5f3b3c2e5a...98e78adca7)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](4d04d5d948...d7f5e7f509)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](bcafcacb16...f9f3042f7e)

Updates `oven-sh/setup-bun` from 2.0.1 to 2.2.0
- [Release notes](https://github.com/oven-sh/setup-bun/releases)
- [Commits](4bc047ad25...0c5077e514)

Updates `hoverkraft-tech/compose-action` from 2.6.0 to 3.0.0
- [Release notes](https://github.com/hoverkraft-tech/compose-action/releases)
- [Commits](d2bee4f07e...11beaa1c2d)

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](b5d41d4e1d...eb5cf3af3a)

Updates `anthropics/claude-code-action` from 1.0.127 to 1.0.140
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](1dc994ee7a...fbda2eb1bd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: astral-sh/setup-uv
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: codecov/codecov-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: depot/build-push-action
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: oven-sh/setup-bun
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: hoverkraft-tech/compose-action
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.140
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 23:39:35 +00:00
1280 changed files with 12191 additions and 48174 deletions

View File

@ -1,33 +0,0 @@
---
name: karpathy-guidelines
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
---
# Karpathy Guidelines
Use this skill whenever you touch code in this repository.
## Principles
- Keep the change small and directly tied to the user request.
- Prefer the simplest implementation that fits the existing codebase.
- Read the nearby code first, then match its patterns.
- Avoid unrelated refactors, broad rewrites, or style churn.
- Preserve existing behavior unless the user explicitly asked to change it.
- Treat regressions as a signal to narrow the change, not to add workaround layers.
## Workflow
1. Inspect the current implementation and tests around the change.
2. Make the smallest coherent edit.
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
4. Run the narrowest relevant verification first.
5. Report exactly what was verified and anything left unverified.
## Review Checklist
- Does this change solve the stated problem without expanding scope?
- Did it preserve existing route/component/data-flow semantics?
- Are new abstractions justified by real complexity?
- Are tests focused on the behavior that could regress?
- Are unrelated files and generated artifacts left alone?

View File

@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
@ -195,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./coverage.xml
disable_search: true

View File

@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@ -66,7 +66,7 @@ jobs:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@ -68,7 +68,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@ -78,13 +78,13 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env[matrix.image_name_env] }}
- name: Build Docker image
id: build
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -124,10 +124,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Validate Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
push: false
context: ${{ matrix.build_context }}
@ -156,14 +156,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |

View File

@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
@ -114,7 +114,7 @@ jobs:
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.0.2
with:
bun-version-file: cli/.bun-version

View File

@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -46,7 +46,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
directory: cli/coverage
flags: cli

View File

@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build Docker Image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -48,7 +48,7 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true

View File

@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true

View File

@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false
python-version: "3.12"
@ -71,7 +71,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -114,7 +114,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -171,7 +171,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
uses: anthropics/claude-code-action@fbda2eb1bdc90d319b8d853f5deb53bca199a7c1 # v1.0.140
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -83,7 +83,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
directory: web/coverage
flags: web
@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -117,7 +117,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
directory: packages/dify-ui/coverage
flags: dify-ui

View File

@ -11,7 +11,6 @@ from .data_migration import (
migration_data_wizard,
)
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@ -50,7 +49,6 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",

View File

@ -1,11 +1,10 @@
import json
import logging
import time
from typing import Any, cast
import click
from pydantic import TypeAdapter
from sqlalchemy import delete, func, select
from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult
from configs import dify_config
@ -16,13 +15,11 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from models.tools import ToolOAuthSystemClient
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_migration import PluginMigration
logger = logging.getLogger(__name__)
@ -405,110 +402,6 @@ def migrate_data_for_plugin():
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
)
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
yield from db.session.scalars(stmt)
@click.command(
"backfill-plugin-auto-upgrade",
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
def backfill_plugin_auto_upgrade(
tenant_id: tuple[str, ...],
limit: int | None,
batch_size: int,
dry_run: bool,
):
"""
Backfill historical auto-upgrade strategies after the category column exists.
Missing category rows are created from the tenant's tool/default row. Pure default
strategies become latest for model plugins and fix-only for all other categories.
Tenants with include/exclude plugin IDs are split
by installed plugin category using plugin daemon metadata.
"""
start_at = time.perf_counter()
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
if dry_run:
elapsed = time.perf_counter() - start_at
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
return
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
backfilled_count = 0
created_count = 0
normalized_count = 0
skipped_count = 0
failed_count = 0
for index, current_tenant_id in enumerate(tenant_ids, start=1):
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
)
except Exception as e:
failed_count += 1
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
continue
if result.created_count > 0:
backfilled_count += 1
created_count += result.created_count
elif not result.normalized:
skipped_count += 1
if result.normalized:
normalized_count += 1
if batch_size > 0 and index % batch_size == 0:
click.echo(
click.style(
f"Processed {index}/{candidate_count} tenants. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={time.perf_counter() - start_at:.2f}s",
fg="yellow",
)
)
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"Backfill plugin auto-upgrade strategy categories completed. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={elapsed:.2f}s",
fg="green",
)
)
@click.command("extract-plugins", help="Extract plugins.")
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)

View File

@ -1,7 +1,6 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, cast
@ -15,12 +14,7 @@ from werkzeug.exceptions import BadRequest
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
from controllers.common.helpers import FileInfo
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.workspace.models import LoadBalancingPayload
@ -42,12 +36,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.helper import build_icon_url, to_timestamp
from libs.login import login_required
from models import Account, App, DatasetPermissionEnum, Workflow
from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@ -74,14 +68,10 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
class AppListBaseQuery(BaseModel):
class AppListQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
sort_by: AppListSortBy = Field(
default="last_modified",
description="Sort apps by last modified, recently created, or earliest created",
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
@ -124,14 +114,6 @@ class AppListBaseQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class AppListQuery(AppListBaseQuery):
pass
class StarredAppListQuery(AppListBaseQuery):
pass
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
@ -395,7 +377,6 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@ -465,54 +446,12 @@ class AppExportResponse(ResponseModel):
data: str
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in apps:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in apps:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@ -556,7 +495,7 @@ register_schema_models(
class AppListApi(Resource):
@console_ns.doc("list_apps")
@console_ns.doc(description="Get list of applications with pagination and filtering")
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.expect(console_ns.models[AppListQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@ -572,7 +511,6 @@ class AppListApi(Resource):
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@ -586,7 +524,46 @@ class AppListApi(Resource):
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == current_tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@ -622,78 +599,6 @@ class AppListApi(Resource):
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
@console_ns.doc(description="Get applications starred by the current account")
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@console_ns.route("/apps/<uuid:app_id>/star")
class AppStarApi(Resource):
@console_ns.doc("star_app")
@console_ns.doc(description="Star an application for the current account")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def post(self, session: Session, current_user_id: str, app_model: App):
AppService.star_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def delete(self, session: Session, current_user_id: str, app_model: App):
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
class AppApi(Resource):
@console_ns.doc("get_app_detail")

View File

@ -155,28 +155,19 @@ class InstalledAppsListApi(Resource):
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
app_ids = [installed_app.app_id for installed_app in installed_apps]
apps = db.session.scalars(select(App).where(App.id.in_(app_ids))).all() if app_ids else []
apps_by_id = {app.id: app for app in apps}
installed_app_list: list[dict[str, Any]] = []
for installed_app in installed_apps:
app_model = apps_by_id.get(installed_app.app_id)
if app_model is None:
continue
installed_app_list.append(
{
"id": installed_app.id,
"app": app_model,
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
"is_pinned": installed_app.is_pinned,
"last_used_at": installed_app.last_used_at,
"editable": current_user.role in {"owner", "admin"},
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
}
)
installed_app_list: list[dict[str, Any]] = [
{
"id": installed_app.id,
"app": installed_app.app,
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
"is_pinned": installed_app.is_pinned,
"last_used_at": installed_app.last_used_at,
"editable": current_user.role in {"owner", "admin"},
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
}
for installed_app in installed_apps
if installed_app.app is not None
]
# filter out apps that user doesn't have access to
if FeatureService.get_system_features().webapp_auth.enabled:

View File

@ -65,28 +65,15 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@ -97,7 +84,13 @@ class RecommendedAppListApi(Resource):
def get(self, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
language = args.language
if language and language in languages:
language_prefix = language
elif current_user.interface_language:
language_prefix = current_user.interface_language
else:
language_prefix = languages[0]
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(language_prefix),
@ -105,23 +98,6 @@ class RecommendedAppListApi(Resource):
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(language_prefix),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@login_required

View File

@ -1,51 +1,28 @@
import io
from collections.abc import Mapping
from datetime import datetime
from typing import Any, Literal, TypedDict
from typing import Any, Literal
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, Field
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.fields import SuccessResponse
from controllers.common.schema import (
query_params_from_model,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.workspace import plugin_permission_required
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class ParserList(BaseModel):
@ -53,11 +30,6 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class ParserLatest(BaseModel):
plugin_ids: list[str]
@ -116,8 +88,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
class ParserDynamicOptions(BaseModel):
@ -153,40 +125,13 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
include_plugins: list[str] = Field(default_factory=list)
class PluginAutoUpgradeChangeResponse(ResponseModel):
success: bool
message: str | None = None
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserPreferencesChange(BaseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@ -200,67 +145,9 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
class PluginCategoryInstalledPluginResponse(ResponseModel):
id: str
name: str
tenant_id: str
plugin_id: str
plugin_unique_identifier: str
endpoints_active: int
endpoints_setups: int
installation_id: str
declaration: PluginDeclaration
runtime_type: str
version: str
created_at: datetime
updated_at: datetime
source: PluginInstallationSource
checksum: str
meta: Mapping[str, Any]
class PluginCategoryBuiltinToolResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
author: str
name: str
label: I18nObject
description: I18nObject
parameters: list[Mapping[str, Any]] | None = None
labels: list[str]
output_schema: Mapping[str, object]
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
id: str
author: str
name: str
plugin_id: str | None
plugin_unique_identifier: str | None
description: I18nObject
icon: str | Mapping[str, str]
icon_dark: str | Mapping[str, str] | None
label: I18nObject
type: ToolProviderType
team_credentials: Mapping[str, object]
is_team_authorization: bool
allow_delete: bool
tools: list[PluginCategoryBuiltinToolResponse]
labels: list[str]
class PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@ -277,57 +164,21 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserPreferencesChange,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(
console_ns,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
PluginDebuggingKeyResponse,
PluginCategoryInstalledPluginResponse,
PluginCategoryBuiltinToolResponse,
PluginCategoryBuiltinToolProviderResponse,
PluginCategoryListResponse,
SuccessResponse,
)
register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse)
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": strategy.strategy_setting,
"upgrade_time_of_day": strategy.upgrade_time_of_day,
"upgrade_mode": strategy.upgrade_mode,
"exclude_plugins": strategy.exclude_plugins,
"include_plugins": strategy.include_plugins,
}
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
"""
Read the uploaded file and validate its actual size before delegating to the plugin service.
@ -342,33 +193,6 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
}
builtin_providers = []
for provider in ToolManager.list_hardcoded_providers():
if is_filtered(
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
data=provider,
name_func=lambda provider_controller: provider_controller.entity.identity.name,
):
continue
user_provider = ToolTransformService.builtin_provider_to_user_provider(
provider_controller=provider,
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@ -406,41 +230,6 @@ class PluginListApi(Resource):
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
class PluginCategoryListApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, category: str):
_, tenant_id = current_account_with_tenant()
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_category = PluginCategory(category)
except ValueError:
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
return dump_response(
PluginCategoryListResponse,
{
"plugins": jsonable_encoder(plugins.list),
"builtin_tools": builtin_tools,
"has_more": plugins.has_more,
},
)
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@ -843,13 +632,11 @@ class PluginChangePermissionApi(Resource):
tenant_id = current_tenant_id
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
return jsonable_encoder({"success": True})
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
@console_ns.route("/workspaces/current/plugin/permission/fetch")
@ -938,10 +725,9 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
class PluginChangeAutoUpgradeApi(Resource):
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
@setup_required
@login_required
@account_initialization_required
@ -950,17 +736,38 @@ class PluginChangeAutoUpgradeApi(Resource):
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
args = ParserPreferencesChange.model_validate(console_ns.payload)
permission = args.permission
install_permission = permission.install_permission
debug_permission = permission.debug_permission
auto_upgrade = args.auto_upgrade
strategy_setting = auto_upgrade.strategy_setting
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
upgrade_mode = auto_upgrade.upgrade_mode
exclude_plugins = auto_upgrade.exclude_plugins
include_plugins = auto_upgrade.include_plugins
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
auto_upgrade.strategy_setting,
auto_upgrade.upgrade_time_of_day,
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
@ -968,36 +775,48 @@ class PluginChangeAutoUpgradeApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
class PluginFetchAutoUpgradeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
return jsonable_encoder(
{
"category": args.category,
"auto_upgrade": auto_upgrade_dict,
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
}
)
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
class PluginAutoUpgradeExcludePluginApi(Resource):
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -1007,9 +826,7 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder(
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
)
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
@console_ns.route("/workspaces/current/plugin/readme")

View File

@ -20,7 +20,7 @@ from controllers.console.wraps import (
setup_required,
)
from core.db.session_factory import session_factory
from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
from core.mcp.auth.auth_flow import auth, handle_callback
from core.mcp.error import MCPAuthError, MCPError, MCPRefreshTokenError
from core.mcp.mcp_client import MCPClient
@ -210,30 +210,6 @@ class MCPProviderBasePayload(BaseModel):
configuration: dict[str, Any] | None = Field(default_factory=dict)
headers: dict[str, Any] | None = Field(default_factory=dict)
authentication: dict[str, Any] | None = Field(default_factory=dict)
# None means "leave unchanged" on update; the controller resolves it to a
# concrete IdentityMode before calling the service (see _resolve_identity_mode).
identity_mode: IdentityMode | None = None
def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityMode) -> IdentityMode:
"""Resolve the effective MCP identity_mode for a create/update request.
Keeps two API-layer concerns out of the service so the service always
receives a concrete value:
* ``None`` means "leave unchanged" (update semantics) — fall back to
``current`` (``IdentityMode.OFF`` for a brand-new provider).
* Identity forwarding is an enterprise-only capability. On non-enterprise
deployments any non-OFF value is coerced back to OFF so a persisted row
can never imply forwarding that the runtime won't perform. This gates the
API surface to match the backend gate in
``MCPTool._forwarding_requested`` — both the API and the backend
invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``.
"""
mode = current if requested is None else requested
if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED:
return IdentityMode.OFF
return mode
class MCPProviderCreatePayload(MCPProviderBasePayload):
@ -1024,7 +1000,6 @@ class ToolProviderMCPApi(Resource):
headers=payload.headers or {},
configuration=configuration,
authentication=authentication,
identity_mode=_resolve_identity_mode(payload.identity_mode, current=IdentityMode.OFF),
)
# 2) Try to fetch tools immediately after creation so they appear without a second save.
@ -1079,11 +1054,6 @@ class ToolProviderMCPApi(Resource):
# Step 3: Perform database update in a transaction
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
# Resolve "leave unchanged" (None) against the stored value, and gate
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
# the service receives a concrete IdentityMode.
existing = service.get_provider(provider_id=payload.provider_id, tenant_id=current_tenant_id)
identity_mode = _resolve_identity_mode(payload.identity_mode, current=IdentityMode(existing.identity_mode))
service.update_provider(
tenant_id=current_tenant_id,
provider_id=payload.provider_id,
@ -1097,7 +1067,6 @@ class ToolProviderMCPApi(Resource):
configuration=configuration,
authentication=authentication,
validation_result=validation_result,
identity_mode=identity_mode,
)
return {"result": "success"}

View File

@ -31,9 +31,9 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.login import login_required
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
@ -144,7 +144,6 @@ tenants_fields = {
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
@ -159,12 +158,7 @@ class TenantListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenants = TenantService.get_join_tenants(current_user)
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
@ -177,7 +171,7 @@ class TenantListApi(Resource):
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant, membership in tenant_rows:
for tenant in tenants:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
@ -196,7 +190,6 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}

View File

@ -37,13 +37,6 @@ class MCPSupportGrantType(StrEnum):
REFRESH_TOKEN = "refresh_token"
class IdentityMode(StrEnum):
"""How Dify forwards the end-user's identity to an MCP server."""
OFF = "off"
IDP_TOKEN = "idp_token"
class MCPAuthentication(BaseModel):
client_id: str
client_secret: str | None = None
@ -83,8 +76,6 @@ class MCPProviderEntity(BaseModel):
created_at: datetime
updated_at: datetime
identity_mode: IdentityMode = IdentityMode.OFF
@classmethod
def from_db_model(cls, db_provider: MCPToolProvider) -> MCPProviderEntity:
"""Create entity from database model with decryption"""
@ -105,7 +96,6 @@ class MCPProviderEntity(BaseModel):
icon=db_provider.icon or "",
created_at=db_provider.created_at,
updated_at=db_provider.updated_at,
identity_mode=IdentityMode(db_provider.identity_mode),
)
@property
@ -180,7 +170,6 @@ class MCPProviderEntity(BaseModel):
"updated_at": int(self.updated_at.timestamp()),
"label": I18nObject(en_US=self.name, zh_Hans=self.name).to_dict(),
"description": I18nObject(en_US="", zh_Hans="").to_dict(),
"identity_mode": self.identity_mode,
}
# Add configuration

View File

@ -316,7 +316,6 @@ class IndexingRunner:
qa_preview_texts: list[QAPreviewDetail] = []
total_segments = 0
deleted_preview_images = False
# doc_form represents the segmentation method (general, parent-child, QA)
index_type = doc_form
index_processor = IndexProcessorFactory(index_type).init_index_processor()
@ -369,10 +368,6 @@ class IndexingRunner:
upload_file_id,
)
db.session.delete(image_file)
deleted_preview_images = True
if deleted_preview_images:
db.session.commit()
if doc_form and doc_form == "qa_model":
return IndexingEstimate(total_segments=total_segments * 20, qa_preview=qa_preview_texts, preview=[])

View File

@ -40,7 +40,6 @@ class MCPClientWithAuthRetry(MCPClient):
provider_entity: MCPProviderEntity | None = None,
authorization_code: str | None = None,
by_server_id: bool = False,
forward_identity_active: bool = False,
):
"""
Initialize the MCP client with auth retry capability.
@ -53,15 +52,12 @@ class MCPClientWithAuthRetry(MCPClient):
provider_entity: Provider entity for authentication
authorization_code: Optional authorization code for initial auth
by_server_id: Whether to look up provider by server ID
forward_identity_active: If True, suppress the static-OAuth retry
on 401 — the forwarded identity must propagate as-is.
"""
super().__init__(server_url, headers, timeout, sse_read_timeout)
self.provider_entity = provider_entity
self.authorization_code = authorization_code
self.by_server_id = by_server_id
self.forward_identity_active = forward_identity_active
self._has_retried = False
def _handle_auth_error(self, error: MCPAuthError) -> None:
@ -77,8 +73,6 @@ class MCPClientWithAuthRetry(MCPClient):
Raises:
MCPAuthError: If authentication fails or max retries reached
"""
if self.forward_identity_active:
raise error
if not self.provider_entity:
raise error
if self._has_retried:

View File

@ -7,7 +7,7 @@ import threading
import time
from collections.abc import Mapping
from datetime import timedelta
from typing import TYPE_CHECKING, Any, TypedDict, override
from typing import TYPE_CHECKING, Any, TypedDict
from uuid import UUID, uuid4
from cachetools import LRUCache
@ -221,7 +221,6 @@ class TracingProviderConfigEntry(TypedDict):
class OpsTraceProviderConfigMap(collections.UserDict[str, TracingProviderConfigEntry]):
@override
def __getitem__(self, key: str) -> TracingProviderConfigEntry:
try:
match key:

View File

@ -168,7 +168,6 @@ class PluginInstallTask(BasePluginEntity):
class PluginInstallTaskStartResponse(BaseModel):
all_installed: bool = Field(description="Whether all plugins are installed.")
task_id: str = Field(description="The ID of the install task.")
task: PluginInstallTask | None = Field(default=None, description="The install task.")
class PluginVerification(BaseModel):
@ -207,11 +206,6 @@ class PluginListResponse(BaseModel):
total: int
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
class PluginDynamicSelectOptionsResponse(BaseModel):
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")

View File

@ -6,7 +6,6 @@ from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
MissingPluginDependency,
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -17,7 +16,6 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginReadmeResponse,
)
from core.plugin.impl.base import BasePluginClient
@ -76,16 +74,6 @@ class PluginInstaller(BasePluginClient):
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
return self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/{category.value}/list",
PluginListWithoutTotalResponse,
params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def upload_pkg(
self,
tenant_id: str,

View File

@ -23,7 +23,6 @@ from core.helper.marketplace import download_plugin_pkg
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
PluginCategory,
PluginDeclaration,
PluginEntity,
PluginInstallation,
@ -34,7 +33,6 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderEntity,
PluginVerification,
)
@ -297,19 +295,6 @@ class PluginService:
plugins = manager.list_plugins_with_total(tenant_id, page, page_size)
return plugins
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
@staticmethod
def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]:
"""

View File

@ -1,7 +1,7 @@
import base64
import logging
import pickle
from typing import Any, cast, override
from typing import Any, cast
import numpy as np
from sqlalchemy import select
@ -25,7 +25,6 @@ class CacheEmbedding(Embeddings):
def __init__(self, model_instance: ModelInstance):
self._model_instance = model_instance
@override
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed search docs in batches of 10."""
# use doc embedding cache or store if not exists
@ -107,7 +106,6 @@ class CacheEmbedding(Embeddings):
return text_embeddings
@override
def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]:
"""Embed file documents."""
# use doc embedding cache or store if not exists
@ -191,7 +189,6 @@ class CacheEmbedding(Embeddings):
return multimodel_embeddings
@override
def embed_query(self, text: str) -> list[float]:
"""Embed query text."""
# use doc embedding cache or store if not exists
@ -235,7 +232,6 @@ class CacheEmbedding(Embeddings):
return embedding_results # type: ignore
@override
def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]:
"""Embed multimodal documents."""
# use doc embedding cache or store if not exists

View File

@ -1,32 +1,13 @@
"""Excel document extractor used for RAG ingestion.
"""Abstract interface for document loader implementations."""
Supports cell hyperlinks for both `.xls` and `.xlsx`, and embedded worksheet images
for `.xlsx` files by converting them into markdown image links. Embedded images are
stored with deterministic keys derived from the source upload file and anchor cell so
retries can safely reuse the same assets.
"""
import hashlib
import logging
import mimetypes
import os
from typing import TypedDict, override
import pandas as pd
from openpyxl import load_workbook
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from core.rag.extractor.extractor_base import BaseExtractor
from core.rag.models.document import Document
from extensions.ext_storage import storage
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import UploadFile
logger = logging.getLogger(__name__)
class Candidate(TypedDict):
@ -35,42 +16,17 @@ class Candidate(TypedDict):
map: dict[int, str]
class SheetImageCandidate(TypedDict):
anchor: tuple[int, int]
content_hash: str
file_key: str
image_bytes: bytes
image_ext: str
class ExcelExtractor(BaseExtractor):
"""Load Excel files.
Args:
file_path: Path to the file to load.
"""
_file_path: str
_encoding: str | None
_autodetect_encoding: bool
_tenant_id: str | None
_user_id: str | None
_source_file_id: str | None
def __init__(
self,
file_path: str,
tenant_id: str | None = None,
user_id: str | None = None,
source_file_id: str | None = None,
encoding: str | None = None,
autodetect_encoding: bool = False,
):
def __init__(self, file_path: str, encoding: str | None = None, autodetect_encoding: bool = False):
"""Initialize with file path."""
self._file_path = file_path
self._tenant_id = tenant_id
self._user_id = user_id
self._source_file_id = source_file_id
self._encoding = encoding
self._autodetect_encoding = autodetect_encoding
@ -81,8 +37,7 @@ class ExcelExtractor(BaseExtractor):
file_extension = os.path.splitext(self._file_path)[-1].lower()
if file_extension == ".xlsx":
# Worksheet drawing objects, including embedded images, are not available in read-only mode.
wb = load_workbook(self._file_path, data_only=True)
wb = load_workbook(self._file_path, read_only=True, data_only=True)
try:
for sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
@ -90,15 +45,10 @@ class ExcelExtractor(BaseExtractor):
if not column_map:
continue
start_row = header_row_idx + 1
sheet_image_map = self._extract_images_from_sheet(
sheet_name=sheet_name,
sheet=sheet,
valid_columns={column_idx + 1 for column_idx in column_map},
min_row=start_row,
)
for row in sheet.iter_rows(min_row=start_row, max_col=max_col_idx, values_only=False):
if all(cell.value is None for cell in row):
continue
page_content = []
row_has_content = False
for col_idx, cell in enumerate(row):
value = cell.value
if col_idx in column_map:
@ -106,27 +56,14 @@ class ExcelExtractor(BaseExtractor):
if hasattr(cell, "hyperlink") and cell.hyperlink:
target = getattr(cell.hyperlink, "target", None)
if target:
display_value = value if value is not None and str(value).strip() else target
value = f"[{display_value}]({target})"
cell_row = getattr(cell, "row", None)
cell_column = getattr(cell, "column", None)
image_links = (
sheet_image_map.get((cell_row, cell_column), [])
if isinstance(cell_row, int) and isinstance(cell_column, int)
else []
)
value = f"[{value}]({target})"
if value is None:
value = ""
elif not isinstance(value, str):
value = str(value)
if image_links:
value = " ".join(filter(None, [value, " ".join(image_links)]))
value = value.strip()
if value:
row_has_content = True
value = value.replace('"', '\\"')
value = value.strip().replace('"', '\\"')
page_content.append(f'"{col_name}":"{value}"')
if row_has_content and page_content:
if page_content:
documents.append(
Document(page_content=";".join(page_content), metadata={"source": self._file_path})
)
@ -152,166 +89,6 @@ class ExcelExtractor(BaseExtractor):
return documents
def _extract_images_from_sheet(
self, sheet_name: str, sheet, valid_columns: set[int], min_row: int
) -> dict[tuple[int, int], list[str]]:
"""
Extract embedded worksheet images and map them to their anchor cell.
Images are stored with deterministic keys derived from the source upload file,
sheet, anchor cell, and content hash so retried tasks can reuse the same
UploadFile rows and storage objects.
"""
if not self._tenant_id or not self._user_id or not self._source_file_id:
return {}
images = getattr(sheet, "_images", None) or []
image_candidates: list[SheetImageCandidate] = []
for image in images:
marker = getattr(getattr(image, "anchor", None), "_from", None)
row_idx = getattr(marker, "row", None)
col_idx = getattr(marker, "col", None)
if row_idx is None or col_idx is None:
continue
if row_idx + 1 < min_row or col_idx + 1 not in valid_columns:
continue
image_bytes = self._get_image_bytes(image)
if not image_bytes:
continue
image_ext = self._get_image_extension(image)
if not image_ext:
continue
anchor_row = row_idx + 1
anchor_column = col_idx + 1
content_hash = self._hash_image_bytes(image_bytes)
image_candidates.append(
{
"anchor": (anchor_row, anchor_column),
"content_hash": content_hash,
"file_key": self._build_image_file_key(
sheet_name=sheet_name,
anchor_row=anchor_row,
anchor_column=anchor_column,
content_hash=content_hash,
image_ext=image_ext,
),
"image_bytes": image_bytes,
"image_ext": image_ext,
}
)
if not image_candidates:
return {}
image_map: dict[tuple[int, int], list[str]] = {}
base_url = dify_config.FILES_URL
candidate_keys = sorted({candidate["file_key"] for candidate in image_candidates})
with session_factory.create_session() as session:
existing_upload_files = session.scalars(
select(UploadFile).where(
UploadFile.tenant_id == self._tenant_id,
UploadFile.key.in_(candidate_keys),
)
).all()
upload_files_by_key = {upload_file.key: upload_file for upload_file in existing_upload_files}
new_upload_files: list[UploadFile] = []
for candidate in image_candidates:
upload_file = upload_files_by_key.get(candidate["file_key"])
if upload_file is None:
storage.save(candidate["file_key"], candidate["image_bytes"])
mime_type, _ = mimetypes.guess_type(candidate["file_key"])
upload_file = UploadFile(
tenant_id=self._tenant_id,
storage_type=StorageType(dify_config.STORAGE_TYPE),
key=candidate["file_key"],
name=candidate["file_key"],
size=len(candidate["image_bytes"]),
extension=candidate["image_ext"],
mime_type=mime_type or "",
created_by=self._user_id,
created_by_role=CreatorUserRole.ACCOUNT,
created_at=naive_utc_now(),
used=True,
used_by=self._user_id,
used_at=naive_utc_now(),
hash=candidate["content_hash"],
)
upload_files_by_key[candidate["file_key"]] = upload_file
new_upload_files.append(upload_file)
image_map.setdefault(candidate["anchor"], []).append(
f"![image]({base_url}/files/{upload_file.id}/file-preview)"
)
if new_upload_files:
session.add_all(new_upload_files)
session.commit()
return image_map
@staticmethod
def _hash_image_bytes(image_bytes: bytes) -> str:
"""Return a stable content hash for extracted image bytes."""
return hashlib.sha256(image_bytes).hexdigest()
def _build_image_file_key(
self,
*,
sheet_name: str,
anchor_row: int,
anchor_column: int,
content_hash: str,
image_ext: str,
) -> str:
"""Build a deterministic storage key for an embedded worksheet image."""
assert self._tenant_id is not None, "tenant_id is required for image extraction"
assert self._source_file_id is not None, "source_file_id is required for image extraction"
normalized_ext = image_ext.strip().lower()
sheet_hash = hashlib.sha256(sheet_name.encode("utf-8")).hexdigest()[:16]
return (
f"image_files/{self._tenant_id}/{self._source_file_id}/"
f"{sheet_hash}_r{anchor_row}_c{anchor_column}_{content_hash}.{normalized_ext}"
)
def _get_image_bytes(self, image) -> bytes | None:
"""Return embedded image bytes from an openpyxl image object."""
data_loader = getattr(image, "_data", None)
if not callable(data_loader):
return None
try:
data = data_loader()
if isinstance(data, bytes):
return data
if isinstance(data, bytearray):
return bytes(data)
logger.warning("Unexpected embedded image payload type: %s", type(data).__name__)
return None
except Exception:
logger.warning("Failed to read embedded image bytes from Excel sheet", exc_info=True)
return None
def _get_image_extension(self, image) -> str | None:
"""Resolve an image extension from openpyxl metadata."""
image_format = getattr(image, "format", None)
if isinstance(image_format, str) and image_format.strip():
return image_format.strip().lower()
image_path = getattr(image, "path", None)
if isinstance(image_path, str):
_, extension = os.path.splitext(image_path)
if extension:
return extension.lstrip(".").lower()
return None
def _find_header_and_columns(self, sheet, scan_rows=10) -> tuple[int, dict[int, str], int]:
"""
Scan first N rows to find the most likely header row.

View File

@ -113,12 +113,7 @@ class ExtractProcessor:
unstructured_api_key = dify_config.UNSTRUCTURED_API_KEY or ""
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(
file_path,
upload_file.tenant_id,
upload_file.created_by,
upload_file.id,
)
extractor = ExcelExtractor(file_path)
elif file_extension == ".pdf":
assert upload_file is not None
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)
@ -156,12 +151,7 @@ class ExtractProcessor:
extractor = TextExtractor(file_path, autodetect_encoding=True)
else:
if file_extension in {".xlsx", ".xls"}:
extractor = ExcelExtractor(
file_path,
upload_file.tenant_id,
upload_file.created_by,
upload_file.id,
)
extractor = ExcelExtractor(file_path)
elif file_extension == ".pdf":
assert upload_file is not None
extractor = PdfExtractor(file_path, upload_file.tenant_id, upload_file.created_by)

View File

@ -3,7 +3,7 @@
import logging
import re
import uuid
from typing import Any, TypedDict, cast, override
from typing import Any, TypedDict, cast
logger = logging.getLogger(__name__)
@ -61,7 +61,6 @@ class ParagraphFormatPreviewDict(TypedDict):
class ParagraphIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -72,7 +71,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
process_rule = kwargs.get("process_rule")
if not process_rule:
@ -122,7 +120,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
all_documents.extend(split_documents)
return all_documents
@override
def load(
self,
dataset: Dataset,
@ -145,7 +142,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
keyword.add_texts(documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
@ -182,7 +178,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
keyword.delete()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -211,7 +206,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
docs.append(doc)
return docs
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
documents: list[Any] = []
all_multimodal_documents: list[Any] = []
@ -277,7 +271,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
keyword = Keyword(dataset)
keyword.add_texts(documents)
@override
def format_preview(self, chunks: Any) -> ParagraphFormatPreviewDict:
if isinstance(chunks, list):
preview = []
@ -292,7 +285,6 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
else:
raise ValueError("Chunks is not a list")
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -3,7 +3,7 @@
import json
import logging
import uuid
from typing import Any, TypedDict, override
from typing import Any, TypedDict
from sqlalchemy import delete, select
@ -44,7 +44,6 @@ class ParentChildFormatPreviewDict(TypedDict):
class ParentChildIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -55,7 +54,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
process_rule = kwargs.get("process_rule")
if not process_rule:
@ -131,7 +129,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
return all_documents
@override
def load(
self,
dataset: Dataset,
@ -152,7 +149,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
if multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(multimodal_documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# node_ids is segment's node_ids
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
@ -223,7 +219,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
)
db.session.commit()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -288,7 +283,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
child_nodes.append(child_document)
return child_nodes
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
parent_childs = ParentChildStructureChunk.model_validate(chunks)
documents = []
@ -362,7 +356,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
if all_multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(all_multimodal_documents)
@override
def format_preview(self, chunks: Any) -> ParentChildFormatPreviewDict:
parent_childs = ParentChildStructureChunk.model_validate(chunks)
preview = []
@ -376,7 +369,6 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
}
return result
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -4,7 +4,7 @@ import logging
import re
import threading
import uuid
from typing import Any, TypedDict, override
from typing import Any, TypedDict
import pandas as pd
from flask import Flask, current_app
@ -43,7 +43,6 @@ class QAFormatPreviewDict(TypedDict):
class QAIndexProcessor(BaseIndexProcessor):
@override
def extract(self, extract_setting: ExtractSetting, **kwargs) -> list[Document]:
text_docs = ExtractProcessor.extract(
extract_setting=extract_setting,
@ -53,7 +52,6 @@ class QAIndexProcessor(BaseIndexProcessor):
)
return text_docs
@override
def transform(self, documents: list[Document], current_user: Account | None = None, **kwargs) -> list[Document]:
preview = kwargs.get("preview")
process_rule = kwargs.get("process_rule")
@ -141,7 +139,6 @@ class QAIndexProcessor(BaseIndexProcessor):
raise ValueError(str(e))
return text_docs
@override
def load(
self,
dataset: Dataset,
@ -156,7 +153,6 @@ class QAIndexProcessor(BaseIndexProcessor):
if multimodal_documents and dataset.is_multimodal:
vector.create_multimodal(multimodal_documents)
@override
def clean(self, dataset: Dataset, node_ids: list[str] | None, with_keywords: bool = True, **kwargs) -> None:
# Note: Summary indexes are now disabled (not deleted) when segments are disabled.
# This method is called for actual deletion scenarios (e.g., when segment is deleted).
@ -187,7 +183,6 @@ class QAIndexProcessor(BaseIndexProcessor):
else:
vector.delete()
@override
def retrieve(
self,
retrieval_method: RetrievalMethod,
@ -216,7 +211,6 @@ class QAIndexProcessor(BaseIndexProcessor):
docs.append(doc)
return docs
@override
def index(self, dataset: Dataset, document: DatasetDocument, chunks: Any) -> None:
qa_chunks = QAStructureChunk.model_validate(chunks)
documents = []
@ -240,7 +234,6 @@ class QAIndexProcessor(BaseIndexProcessor):
else:
raise ValueError("Indexing technique must be high quality.")
@override
def format_preview(self, chunks: Any) -> QAFormatPreviewDict:
qa_chunks = QAStructureChunk.model_validate(chunks)
preview = []
@ -253,7 +246,6 @@ class QAIndexProcessor(BaseIndexProcessor):
}
return result
@override
def generate_summary_preview(
self,
tenant_id: str,

View File

@ -1,5 +1,4 @@
import base64
from typing import override
from core.model_manager import ModelInstance, ModelManager
from core.rag.index_processor.constant.doc_type import DocType
@ -17,7 +16,6 @@ class RerankModelRunner(BaseRerankRunner):
def __init__(self, rerank_model_instance: ModelInstance):
self.rerank_model_instance = rerank_model_instance
@override
def run(
self,
query: str,

View File

@ -1,6 +1,5 @@
import math
from collections import Counter
from typing import override
import numpy as np
@ -20,7 +19,6 @@ class WeightRerankRunner(BaseRerankRunner):
self.tenant_id = tenant_id
self.weights = weights
@override
def run(
self,
query: str,

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import codecs
import re
from collections.abc import Set as AbstractSet
from typing import Any, Literal, override
from typing import Any, Literal
from core.model_manager import ModelInstance
from core.rag.splitter.text_splitter import RecursiveCharacterTextSplitter
@ -51,7 +51,6 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
self._separators = separators or ["\n\n", "\n", "", ". ", " ", ""]
@override
def split_text(self, text: str) -> list[str]:
"""Split incoming text and return chunks."""
if self._fixed_separator:

View File

@ -7,7 +7,7 @@ from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from typing import Any, Literal, override
from typing import Any, Literal
from core.rag.models.document import BaseDocumentTransformer, Document
@ -148,12 +148,10 @@ class TextSplitter(BaseDocumentTransformer, ABC):
)
return cls(length_function=lambda x: [_huggingface_tokenizer_length(text) for text in x], **kwargs)
@override
def transform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
"""Transform sequence of documents by splitting them."""
return self.split_documents(list(documents))
@override
async def atransform_documents(self, documents: Sequence[Document], **kwargs: Any) -> Sequence[Document]:
"""Asynchronously transform a sequence of documents by splitting them."""
raise NotImplementedError
@ -213,7 +211,6 @@ class TokenTextSplitter(TextSplitter):
self._allowed_special: Literal["all"] | AbstractSet[str] = allowed_special
self._disallowed_special: Literal["all"] | AbstractSet[str] = disallowed_special
@override
def split_text(self, text: str) -> list[str]:
def _encode(_text: str) -> list[int]:
return self._tokenizer.encode(
@ -290,6 +287,5 @@ class RecursiveCharacterTextSplitter(TextSplitter):
return final_chunks
@override
def split_text(self, text: str) -> list[str]:
return self._split_text(text, self._separators)

View File

@ -1,6 +1,6 @@
from abc import abstractmethod
from os import listdir, path
from typing import Any, override
from typing import Any
from core.entities.provider_entities import ProviderConfig
from core.helper.module_import_helper import load_single_subclass_from_source
@ -105,7 +105,6 @@ class BuiltinToolProviderController(ToolProviderController):
"""
return self.tools
@override
def get_credentials_schema(self) -> list[ProviderConfig]:
"""
returns the credentials schema of the provider
@ -183,7 +182,6 @@ class BuiltinToolProviderController(ToolProviderController):
)
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider

View File

@ -1,9 +1,8 @@
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class AudioToolProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,6 +1,6 @@
import io
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.model_manager import ModelManager
from core.plugin.entities.parameters import PluginParameterOption
@ -14,7 +14,6 @@ from services.model_provider_service import ModelProviderService
class ASRTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,
@ -57,7 +56,6 @@ class ASRTool(BuiltinTool):
items.append((provider, model.model))
return items
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,6 +1,6 @@
import io
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.model_manager import ModelManager
from core.plugin.entities.parameters import PluginParameterOption
@ -12,7 +12,6 @@ from services.model_provider_service import ModelProviderService
class TTSTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,
@ -67,7 +66,6 @@ class TTSTool(BuiltinTool):
items.append((provider, model.model, voices))
return items
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,9 +1,8 @@
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class CodeToolProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.helper.code_executor.code_executor import CodeExecutor, CodeLanguage
from core.tools.builtin_tool.tool import BuiltinTool
@ -8,7 +8,6 @@ from core.tools.errors import ToolInvokeError
class SimpleCode(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,9 +1,8 @@
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class WikiPediaProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
pass

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import UTC, datetime
from typing import Any, override
from typing import Any
from pytz import timezone as pytz_timezone # type: ignore[import-untyped]
@ -9,7 +9,6 @@ from core.tools.entities.tool_entities import ToolInvokeMessage
class CurrentTimeTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any, override
from typing import Any
import pytz # type: ignore[import-untyped]
@ -10,7 +10,6 @@ from core.tools.errors import ToolInvokeError
class LocaltimeToTimestampTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any, override
from typing import Any
import pytz # type: ignore[import-untyped]
@ -10,7 +10,6 @@ from core.tools.errors import ToolInvokeError
class TimestampToLocaltimeTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,6 +1,6 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any, override
from typing import Any
import pytz # type: ignore[import-untyped]
@ -10,7 +10,6 @@ from core.tools.errors import ToolInvokeError
class TimezoneConversionTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,14 +1,13 @@
import calendar
from collections.abc import Generator
from datetime import datetime
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
class WeekdayTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
@ -8,7 +8,6 @@ from core.tools.utils.web_reader_tool import get_url
class WebscraperTool(BuiltinTool):
@override
def _invoke(
self,
user_id: str,

View File

@ -1,10 +1,9 @@
from typing import Any, override
from typing import Any
from core.tools.builtin_tool.provider import BuiltinToolProviderController
class WebscraperProvider(BuiltinToolProviderController):
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
"""
Validate credentials

View File

@ -1,7 +1,5 @@
from __future__ import annotations
from typing import override
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import ToolProviderType
@ -28,7 +26,6 @@ class BuiltinTool(Tool):
super().__init__(**kwargs)
self.provider = provider
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> BuiltinTool:
"""
fork a new tool with metadata
@ -59,7 +56,6 @@ class BuiltinTool(Tool):
caller_user_id=self.runtime.user_id,
)
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.BUILT_IN

View File

@ -1,7 +1,5 @@
from __future__ import annotations
from typing import override
from pydantic import Field
from sqlalchemy import select
@ -124,7 +122,6 @@ class ApiToolProviderController(ToolProviderController):
)
@property
@override
def provider_type(self) -> ToolProviderType:
return ToolProviderType.API
@ -197,7 +194,6 @@ class ApiToolProviderController(ToolProviderController):
self.tools = tools
return tools
@override
def get_tool(self, tool_name: str) -> ApiTool:
"""
get tool by name

View File

@ -2,7 +2,7 @@ import json
from collections.abc import Generator
from dataclasses import dataclass
from os import getenv
from typing import Any, Union, override
from typing import Any, Union
from urllib.parse import urlencode
import httpx
@ -45,7 +45,6 @@ class ApiTool(Tool):
self.api_bundle = api_bundle
self.provider_id = provider_id
@override
def fork_tool_runtime(self, runtime: ToolRuntime):
"""
fork a new tool with metadata
@ -78,7 +77,6 @@ class ApiTool(Tool):
# For credential validation, always return as string
return parsed_response.to_string()
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.API
@ -375,7 +373,6 @@ class ApiTool(Tool):
except ValueError:
return value
@override
def _invoke(
self,
user_id: str,

View File

@ -54,9 +54,6 @@ class ToolProviderApiEntity(BaseModel):
configuration: MCPConfiguration | None = Field(
default=None, description="The timeout and sse_read_timeout of the MCP tool"
)
# M3 — user-identity forwarding selector. Round-tripped through the
# console API so the create/edit modal can hydrate the toggle state.
identity_mode: str = Field(default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'")
# Workflow
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
@ -95,9 +92,6 @@ class ToolProviderApiEntity(BaseModel):
optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration))
optional_fields.update(self.optional_field("masked_headers", self.masked_headers))
optional_fields.update(self.optional_field("original_headers", self.original_headers))
# M3 — forwarding selector. Always emit ("off" is a valid
# value that the UI must hydrate, not skip).
optional_fields["identity_mode"] = self.identity_mode
case ToolProviderType.WORKFLOW:
optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id))
case _:

View File

@ -1,6 +1,6 @@
from typing import Any, Self, override
from typing import Any, Self
from core.entities.mcp_provider import IdentityMode, MCPProviderEntity
from core.entities.mcp_provider import MCPProviderEntity
from core.mcp.types import Tool as RemoteMCPTool
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
@ -28,7 +28,6 @@ class MCPToolProviderController(ToolProviderController):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
identity_mode: IdentityMode = IdentityMode.OFF,
):
super().__init__(entity)
self.entity: ToolProviderEntityWithPlugin = entity
@ -38,10 +37,8 @@ class MCPToolProviderController(ToolProviderController):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.identity_mode: IdentityMode = identity_mode
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider
@ -108,7 +105,6 @@ class MCPToolProviderController(ToolProviderController):
headers=entity.headers,
timeout=entity.timeout,
sse_read_timeout=entity.sse_read_timeout,
identity_mode=entity.identity_mode,
)
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
@ -117,7 +113,6 @@ class MCPToolProviderController(ToolProviderController):
"""
pass
@override
def get_tool(self, tool_name: str) -> MCPTool:
"""
return tool with given name
@ -139,7 +134,6 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
def get_tools(self) -> list[MCPTool]:
@ -157,7 +151,6 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
for tool_entity in self.entity.tools
]

View File

@ -4,10 +4,8 @@ import base64
import json
import logging
from collections.abc import Generator, Mapping
from typing import Any, cast, override
from typing import Any, cast
from configs import dify_config
from core.entities.mcp_provider import IdentityMode
from core.mcp.auth_client import MCPClientWithAuthRetry
from core.mcp.error import MCPConnectionError
from core.mcp.types import (
@ -27,11 +25,6 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetada
logger = logging.getLogger(__name__)
# Custom header used to carry the forwarded SSO access token. Picked to avoid
# stomping on the workspace-scoped Authorization header (provider OAuth /
# user-supplied custom credentials), which would silently break those flows.
FORWARDED_IDENTITY_HEADER = "X-Dify-SSO-Access-Token"
class MCPTool(Tool):
def __init__(
@ -45,7 +38,6 @@ class MCPTool(Tool):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
identity_mode: IdentityMode = IdentityMode.OFF,
):
super().__init__(entity, runtime)
self.tenant_id = tenant_id
@ -55,14 +47,11 @@ class MCPTool(Tool):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.identity_mode: IdentityMode = identity_mode
self._latest_usage = LLMUsage.empty_usage()
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.MCP
@override
def _invoke(
self,
user_id: str,
@ -71,7 +60,7 @@ class MCPTool(Tool):
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
result = self.invoke_remote_mcp_tool(tool_parameters, user_id=user_id, app_id=app_id)
result = self.invoke_remote_mcp_tool(tool_parameters)
# Extract usage metadata from MCP protocol's _meta field
self._latest_usage = self._derive_usage_from_result(result)
@ -234,7 +223,6 @@ class MCPTool(Tool):
return found
return None
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> MCPTool:
return MCPTool(
entity=self.entity,
@ -246,7 +234,6 @@ class MCPTool(Tool):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
identity_mode=self.identity_mode,
)
def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
@ -259,26 +246,7 @@ class MCPTool(Tool):
if value is not None and not (isinstance(value, str) and value.strip() == "")
}
@property
def _forwarding_requested(self) -> bool:
"""True only when the configured identity_mode wants forwarding AND
the deployment actually has the enterprise side that can mint tokens.
Non-enterprise installs treat the DB value as a no-op — a stale row
won't trigger a 5xx against a missing inner-API endpoint."""
return self.identity_mode != IdentityMode.OFF and dify_config.ENTERPRISE_ENABLED
def invoke_remote_mcp_tool(
self,
tool_parameters: dict[str, Any],
user_id: str | None = None,
app_id: str | None = None,
) -> CallToolResult:
# Fail closed: forwarding requires user_id (refuse before any DB I/O).
if self._forwarding_requested and not user_id:
raise ToolInvokeError(
"Forward-user-identity is enabled for this MCP provider but no end-user context was supplied."
)
def invoke_remote_mcp_tool(self, tool_parameters: dict[str, Any]) -> CallToolResult:
headers = self.headers.copy() if self.headers else {}
tool_parameters = self._handle_none_parameter(tool_parameters)
@ -303,15 +271,6 @@ class MCPTool(Tool):
if tokens and tokens.access_token:
headers["Authorization"] = f"{tokens.token_type.capitalize()} {tokens.access_token}"
# Forwarded identity rides in a custom header so workspace-scoped
# provider credentials (Authorization / custom Headers) keep working
# untouched. The MCP server is expected to read X-Dify-SSO-Access-Token
# when identity forwarding is configured.
forward_identity_active = False
if self._forwarding_requested and user_id:
self._inject_forwarded_identity(headers, user_id=user_id, app_id=app_id, audience=server_url)
forward_identity_active = True
# Step 2: Session is now closed, perform network operations without holding database connection
# MCPClientWithAuthRetry will create a new session lazily only if auth retry is needed
try:
@ -321,44 +280,9 @@ class MCPTool(Tool):
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
provider_entity=provider_entity,
forward_identity_active=forward_identity_active,
) as mcp_client:
return mcp_client.invoke_tool(tool_name=self.entity.identity.name, tool_args=tool_parameters)
except MCPConnectionError as e:
raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
except Exception as e:
raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
def _inject_forwarded_identity(
self,
headers: dict[str, str],
*,
user_id: str,
app_id: str | None,
audience: str,
) -> None:
"""Call the enterprise IssueMCPToken endpoint and stamp the issued
token into X-Dify-SSO-Access-Token.
A custom header is used (rather than Authorization) so it composes
with workspace-scoped provider credentials — the user may have OAuth
tokens or a custom Authorization header configured on the MCP
provider, and forwarding must not silently overwrite them.
Errors are surfaced as ToolInvokeError so the workflow halts with a
clear message instead of silently dropping identity and hitting the
MCP server unauthenticated.
"""
from services.enterprise.base import MCPTokenError
from services.enterprise.enterprise_service import EnterpriseService
try:
token, _expires_at = EnterpriseService.issue_mcp_token(
user_id=user_id,
tenant_id=self.tenant_id,
app_id=app_id,
audience=audience,
)
except MCPTokenError as e:
raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e
headers[FORWARDED_IDENTITY_HEADER] = token

View File

@ -1,4 +1,4 @@
from typing import Any, override
from typing import Any
from core.plugin.impl.tool import PluginToolManager
from core.tools.__base.tool_runtime import ToolRuntime
@ -23,7 +23,6 @@ class PluginToolProviderController(BuiltinToolProviderController):
self.plugin_unique_identifier = plugin_unique_identifier
@property
@override
def provider_type(self) -> ToolProviderType:
"""
returns the type of the provider
@ -32,7 +31,6 @@ class PluginToolProviderController(BuiltinToolProviderController):
"""
return ToolProviderType.PLUGIN
@override
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
"""
validate the credentials of the provider

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.plugin.impl.tool import PluginToolManager
from core.plugin.utils.converter import convert_parameters_to_plugin_format
@ -20,11 +20,9 @@ class PluginTool(Tool):
self.plugin_unique_identifier = plugin_unique_identifier
self.runtime_parameters: list[ToolParameter] | None = None
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.PLUGIN
@override
def _invoke(
self,
user_id: str,
@ -50,7 +48,6 @@ class PluginTool(Tool):
message_id=message_id,
)
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> PluginTool:
return PluginTool(
entity=self.entity,
@ -60,7 +57,6 @@ class PluginTool(Tool):
plugin_unique_identifier=self.plugin_unique_identifier,
)
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,

View File

@ -1,5 +1,4 @@
import threading
from typing import override
from flask import Flask, current_app
from pydantic import BaseModel, Field
@ -47,7 +46,6 @@ class DatasetMultiRetrieverTool(DatasetRetrieverBaseTool):
name=f"dataset_{tenant_id.replace('-', '_')}", tenant_id=tenant_id, dataset_ids=dataset_ids, **kwargs
)
@override
def _run(self, query: str) -> str:
threads = []
all_documents: list[RagDocument] = []

View File

@ -1,4 +1,4 @@
from typing import Any, cast, override
from typing import Any, cast
from pydantic import BaseModel, Field
from sqlalchemy import select
@ -56,7 +56,6 @@ class DatasetRetrieverTool(DatasetRetrieverBaseTool):
**kwargs,
)
@override
def _run(self, query: str) -> str:
dataset_stmt = select(Dataset).where(Dataset.tenant_id == self.tenant_id, Dataset.id == self.dataset_id)
dataset = db.session.scalar(dataset_stmt)

View File

@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Any, override
from typing import Any
from core.app.app_config.entities import DatasetRetrieveConfigEntity
from core.app.entities.app_invoke_entities import InvokeFrom
@ -85,7 +85,6 @@ class DatasetRetrieverTool(Tool):
return tools
@override
def get_runtime_parameters(
self,
conversation_id: str | None = None,
@ -106,11 +105,9 @@ class DatasetRetrieverTool(Tool):
),
]
@override
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.DATASET_RETRIEVAL
@override
def _invoke(
self,
user_id: str,

View File

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import override
from pydantic import Field
from sqlalchemy import select
@ -81,7 +80,6 @@ class WorkflowToolProviderController(ToolProviderController):
return controller
@property
@override
def provider_type(self) -> ToolProviderType:
return ToolProviderType.WORKFLOW

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import Any, cast, override
from typing import Any, cast
from sqlalchemy import select
@ -67,7 +67,6 @@ class WorkflowTool(Tool):
super().__init__(entity=entity, runtime=runtime)
@override
def tool_provider_type(self) -> ToolProviderType:
"""
get the tool provider type
@ -76,7 +75,6 @@ class WorkflowTool(Tool):
"""
return ToolProviderType.WORKFLOW
@override
def _invoke(
self,
user_id: str,
@ -208,7 +206,6 @@ class WorkflowTool(Tool):
return found
return None
@override
def fork_tool_runtime(self, runtime: ToolRuntime) -> WorkflowTool:
"""
fork a new tool with metadata

View File

@ -6,7 +6,7 @@ import time
from abc import ABC, abstractmethod
from collections.abc import Mapping
from datetime import datetime
from typing import Any, override
from typing import Any
from pydantic import BaseModel
@ -62,7 +62,6 @@ class TriggerDebugEventPoller(ABC):
class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
@override
def poll(self) -> TriggerDebugEvent | None:
from services.trigger.trigger_service import TriggerService
@ -104,7 +103,6 @@ class PluginTriggerDebugEventPoller(TriggerDebugEventPoller):
class WebhookTriggerDebugEventPoller(TriggerDebugEventPoller):
@override
def poll(self) -> TriggerDebugEvent | None:
pool_key = build_webhook_pool_key(
tenant_id=self.tenant_id,
@ -192,7 +190,6 @@ class ScheduleTriggerDebugEventPoller(TriggerDebugEventPoller):
inputs={},
)
@override
def poll(self) -> TriggerDebugEvent | None:
schedule_debug_runtime = self.get_or_create_schedule_debug_runtime()
if schedule_debug_runtime.next_run_at > naive_utc_now():

View File

@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Union, override
from typing import Union
from core.entities.provider_entities import BasicProviderConfig, ProviderConfig
from core.helper.provider_cache import ProviderCredentialsCache
@ -16,7 +16,6 @@ class TriggerProviderCredentialsCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str, credential_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id, credential_id=credential_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]
@ -30,7 +29,6 @@ class TriggerProviderOAuthClientParamsCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]
@ -43,7 +41,6 @@ class TriggerProviderPropertiesCache(ProviderCredentialsCache):
def __init__(self, tenant_id: str, provider_id: str, subscription_id: str):
super().__init__(tenant_id=tenant_id, provider_id=provider_id, subscription_id=subscription_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
tenant_id = kwargs["tenant_id"]
provider_id = kwargs["provider_id"]

View File

@ -12,7 +12,6 @@ examples accurate or the LLM will invent fields.
"""
import json
from collections.abc import Iterable
from typing import Any
# Per-node-type configuration cheatsheet.
@ -23,24 +22,11 @@ from typing import Any
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
# runtime entity validation each node performs when the workflow runs.
#
# The cheatsheet is assembled DYNAMICALLY per request: the planner decides
# which node types the workflow needs, and ``build_node_config_cheatsheet``
# stitches together only the snippets for those types (plus the always-needed
# wrapper / shared-field / edge-handle preamble, and the containers section
# when an iteration / loop is planned). This keeps the builder prompt tight —
# a 3-node summariser no longer carries the schema for 12 unrelated node
# types — and lets each snippet document its FULL schema (e.g. a "file" start
# variable's required ``allowed_file_types``) without bloating every prompt.
#
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
# LLM only needs to emit semantically meaningful fields.
# Always-included preamble: the node/edge wrapper shape and the shared
# ``data`` fields that apply to every node type, plus the "## Per type" header
# the per-type snippets slot under.
_CHEATSHEET_PREAMBLE = """\
NODE_CONFIG_CHEATSHEET = """\
## Node wrapper (every node, top-level)
{"id": "node1" (digits + letters only — see "Node IDs" below),
@ -60,26 +46,14 @@ Children of iteration / loop containers additionally need
"desc": "<one-liner>",
"selected": false}
## Per type — additional "data" fields (only the node types in your plan are shown)"""
## Per type — additional "data" fields
# node_type → its per-type schema snippet. Keyed by the exact ``node_type``
# string the planner emits so ``build_node_config_cheatsheet`` can look each
# one up directly. Iteration / loop are documented in the Containers section
# (they are subgraphs, not leaf nodes) rather than here.
_NODE_SNIPPETS: dict[str, str] = {
"start": """\
- start:
{"variables": [
{"variable": "url", "label": "URL", "type": "text-input",
"required": true, "max_length": 256, "options": []},
{"variable": "topic", "label": "Topic", "type": "paragraph",
"required": false, "max_length": 4096, "options": []},
{"variable": "doc", "label": "Document", "type": "file",
"required": true,
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["local_file", "remote_url"],
"allowed_file_extensions": []}
"required": false, "max_length": 4096, "options": []}
]}
EVERY user-supplied value referenced by a downstream node
(``{{#node-id.var#}}`` in a prompt / answer / template, or
@ -88,29 +62,19 @@ _NODE_SNIPPETS: dict[str, str] = {
If the planner's ``start_inputs`` list is non-empty, use it verbatim
(the user prompt section "Start inputs" surfaces it). Types:
text-input | paragraph | select | number | file | file-list.
For a "file" or "file-list" variable you MUST also set
``allowed_file_types`` to a NON-EMPTY subset of
["document", "image", "audio", "video", "custom"] — it is a REQUIRED
field and the draft fails to load (showing "supported file types is
required") without it. Choose by purpose: ["document"] for text
extraction (PDF / Word / PPT / Markdown / …), ["image"] for vision,
etc. Always set ``allowed_file_upload_methods`` to
["local_file", "remote_url"]. Only when you include "custom" must you
also set ``allowed_file_extensions`` to a non-empty list like
[".epub", ".rtf"]; otherwise leave it [].
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
system variables — downstream nodes may reference them; do NOT add
them to ``variables``.""",
"end": """\
them to ``variables``.
- end (Workflow mode only):
{"outputs": [
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
]}""",
"answer": """\
]}
- answer (Advanced Chat mode only):
{"variables": [],
"answer": "<text with {{#<src>.<var>#}} placeholders>"}""",
"llm": """\
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
- llm:
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
"completion_params": {"temperature": 0.7}},
@ -136,26 +100,26 @@ _NODE_SNIPPETS: dict[str, str] = {
values are the translations.
Input: {{#node1.text#}}
* Each placeholder only resolves the variable from its source node —
it cannot be a Jinja template or call a function.""",
"knowledge-retrieval": """\
it cannot be a Jinja template or call a function.
- knowledge-retrieval:
{"query_variable_selector": ["<src>", "<var>"],
"query_attachment_selector": [],
"dataset_ids": [],
"retrieval_mode": "multiple",
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
"reranking_enable": false}}""",
"code": """\
"reranking_enable": false}}
- code (escape hatch — only if no installed tool fits):
{"code_language": "python3",
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
"outputs": {"result": {"type": "string", "children": null}}}""",
"template-transform": """\
"outputs": {"result": {"type": "string", "children": null}}}
- template-transform:
{"template": "Hello {{ name }}",
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}""",
"http-request": """\
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
- http-request (escape hatch — only if no installed tool fits):
{"variables": [], "method": "get", "url": "https://example.com",
"authorization": {"type": "no-auth", "config": null},
@ -165,8 +129,8 @@ _NODE_SNIPPETS: dict[str, str] = {
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
"max_write_timeout": 0},
"retry_config": {"retry_enabled": true, "max_retries": 3,
"retry_interval": 100}}""",
"tool": """\
"retry_interval": 100}}
- tool (PREFERRED for external actions when listed in Available tools):
{"provider_id": "<provider>", # provider portion of provider/tool
"provider_type": "builtin", # exact value from catalogue
@ -180,8 +144,8 @@ _NODE_SNIPPETS: dict[str, str] = {
Parameter ``type`` is one of:
"mixed" — string template referencing variables ({{#...#}})
"variable" — direct reference, value is ["<src>", "<var>"]
"constant" — literal value""",
"if-else": """\
"constant" — literal value
- if-else:
{"_targetBranches": [{"id": "true", "name": "IF"},
{"id": "false", "name": "ELSE"}],
@ -194,8 +158,8 @@ _NODE_SNIPPETS: dict[str, str] = {
"comparison_operator": "is",
"value": "<value>"}]}
]}
Source handle for downstream edges = the case_id ("true" / "false").""",
"question-classifier": """\
Source handle for downstream edges = the case_id ("true" / "false").
- question-classifier:
{"query_variable_selector": ["<src>", "<var>"],
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -205,8 +169,8 @@ _NODE_SNIPPETS: dict[str, str] = {
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
"vision": {"enabled": false},
"instruction": ""}
Source handle for downstream edges = the class_id ("1" / "2" / ...).""",
"parameter-extractor": """\
Source handle for downstream edges = the class_id ("1" / "2" / ...).
- parameter-extractor:
{"query": [["<src>", "<var>"]], # array of value_selector arrays
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
@ -215,8 +179,8 @@ _NODE_SNIPPETS: dict[str, str] = {
"description": "<purpose>", "required": true}],
"reasoning_mode": "prompt",
"vision": {"enabled": false},
"instruction": ""}""",
"document-extractor": """\
"instruction": ""}
- document-extractor:
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
"is_array_file": false} # true when the input is a
@ -224,9 +188,8 @@ _NODE_SNIPPETS: dict[str, str] = {
Single output variable ``text``: a string when ``is_array_file`` is false,
an array of strings (one per file) when it is true. ``variable_selector``
MUST point at a ``start`` variable declared with type "file" / "file-list"
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
"variable-aggregator": """\
(or ``sys.files`` in Advanced-Chat mode).
- variable-aggregator (merge mutually-exclusive branches into one output):
{"output_type": "string", # VarType of the merged value — one of
# string | number | object | array[string] |
@ -237,8 +200,8 @@ _NODE_SNIPPETS: dict[str, str] = {
Output variable: ``output`` (the first branch that actually ran). Place it
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
array, NOT a placeholder string.""",
"list-operator": """\
array, NOT a placeholder string.
- list-operator (filter / sort / slice an array variable):
{"variable": ["<src>", "<array-var>"],
"filter_by": {"enabled": false, "conditions": []},
@ -247,12 +210,8 @@ _NODE_SNIPPETS: dict[str, str] = {
"limit": {"enabled": false, "size": 10}}
Enable only the sub-features you need; ``conditions`` reuse the if-else
condition shape (key / comparison_operator / value). Outputs: ``result``
(the processed array), ``first_record``, ``last_record``.""",
}
(the processed array), ``first_record``, ``last_record``.
# Pulled into the cheatsheet only when an iteration / loop appears in the plan.
_CONTAINERS_SECTION = """\
## Containers — iteration / loop
These are SUBGRAPH nodes. To use one you MUST emit, in order:
@ -311,59 +270,16 @@ These are SUBGRAPH nodes. To use one you MUST emit, in order:
5. The container's incoming/outgoing edges connect to the container's id
(``nodeK``), NOT to inner nodes. The first inner edge connects from
``nodeKstart``."""
``nodeKstart``.
# Always-included trailer: edge handle conventions for every graph.
_EDGE_HANDLES_SECTION = """\
## Edge handles
- Most nodes: sourceHandle "source", targetHandle "target".
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
- iteration-start / sourceHandle "source"; the edge from the *start node
loop-start: is what kicks off the first inner step."""
# Container node types are described in ``_CONTAINERS_SECTION`` rather than as
# leaf snippets; their presence in a plan pulls that section in.
_CONTAINER_NODE_TYPES = frozenset({"iteration", "loop"})
def build_node_config_cheatsheet(node_types: Iterable[str] | None = None) -> str:
"""
Assemble the builder cheatsheet for exactly the node types in the plan.
``node_types`` is the set of ``node_type`` strings the planner chose. We
emit the always-on preamble (wrapper / shared fields), then only the
per-type snippets for the requested types (``start`` is always included —
every graph has one), the Containers section when an iteration / loop is
planned, and the edge-handles trailer. Unknown / unrecognised type strings
are ignored (the runtime / structural validator catches genuinely bogus
types).
``None`` returns the FULL cheatsheet (every snippet + containers) — used to
build the static back-compat constants below and as a safe fallback.
"""
if node_types is None:
requested: set[str] = set(_NODE_SNIPPETS) | set(_CONTAINER_NODE_TYPES)
else:
requested = {str(t).strip() for t in node_types if str(t).strip()}
requested.add("start") # every workflow has exactly one start node
parts: list[str] = [_CHEATSHEET_PREAMBLE]
# Iterate _NODE_SNIPPETS (not ``requested``) to keep a stable, readable order.
parts.extend(snippet for node_type, snippet in _NODE_SNIPPETS.items() if node_type in requested)
if requested & _CONTAINER_NODE_TYPES:
parts.append(_CONTAINERS_SECTION)
parts.append(_EDGE_HANDLES_SECTION)
return "\n\n".join(parts) + "\n"
# Full cheatsheet (all node types) — retained as a module constant so callers
# and tests that want the complete reference can import it directly. The
# dynamic per-request prompt is built by ``get_builder_system_prompt``.
NODE_CONFIG_CHEATSHEET = build_node_config_cheatsheet()
loop-start: is what kicks off the first inner step.
"""
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
@ -486,24 +402,21 @@ _ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow
"""
def _assemble_builder_system_prompt(mode: str, node_types: Iterable[str] | None) -> str:
"""Stitch the builder system prompt for ``mode`` around a cheatsheet built
for ``node_types`` (``None`` → full cheatsheet)."""
mode_rules = _ADVANCED_CHAT_MODE_RULES if mode == "advanced-chat" else _WORKFLOW_MODE_RULES
return (
_BASE_SYSTEM_PROMPT_HEAD
+ mode_rules
+ _BASE_SYSTEM_PROMPT_TAIL
+ build_node_config_cheatsheet(node_types)
+ _BASE_SYSTEM_PROMPT_FOOTER
)
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
_BASE_SYSTEM_PROMPT_HEAD
+ _WORKFLOW_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
# Static full-cheatsheet prompts — the back-compat default returned by
# ``get_builder_system_prompt`` when the caller doesn't pin a node-type set.
BUILDER_SYSTEM_PROMPT_WORKFLOW = _assemble_builder_system_prompt("workflow", None)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = _assemble_builder_system_prompt("advanced-chat", None)
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
_BASE_SYSTEM_PROMPT_HEAD
+ _ADVANCED_CHAT_MODE_RULES
+ _BASE_SYSTEM_PROMPT_TAIL
+ NODE_CONFIG_CHEATSHEET
+ _BASE_SYSTEM_PROMPT_FOOTER
)
BUILDER_USER_PROMPT = """# User instruction
@ -633,16 +546,8 @@ def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
return "\n".join(lines)
def get_builder_system_prompt(mode: str, node_types: Iterable[str] | None = None) -> str:
"""
Build the builder system prompt for ``mode``, with a cheatsheet scoped to
``node_types`` (the planner's chosen node types).
When ``node_types`` is ``None`` we return the cached full-cheatsheet
constant (back-compat default). When the runner passes the plan's node-type
set we assemble a fresh prompt carrying only the relevant per-type schemas,
so the builder isn't handed config for node types the workflow never uses.
"""
if node_types is None:
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT if mode == "advanced-chat" else BUILDER_SYSTEM_PROMPT_WORKFLOW
return _assemble_builder_system_prompt(mode, node_types)
def get_builder_system_prompt(mode: str) -> str:
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
if mode == "advanced-chat":
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
return BUILDER_SYSTEM_PROMPT_WORKFLOW

View File

@ -74,21 +74,6 @@ _DEFAULT_VIEWPORT: GraphViewportDict = {"x": 0.0, "y": 0.0, "zoom": 0.7}
_DEFAULT_NODE_WIDTH = 244
_DEFAULT_NODE_HEIGHT = 100
# Start-node input variable types that carry file uploads. Mirrors
# ``graphon.variables.input_entities.VariableEntityType.FILE / FILE_LIST``.
_FILE_VARIABLE_TYPES = frozenset({"file", "file-list"})
# Backstop defaults for a file / file-list start variable when the builder
# omits the required upload config. ``allowed_file_types`` is a REQUIRED field
# (Studio rejects the draft with "supported file types is required" when it's
# empty — see ``config-var/config-modal/utils.ts``); we default to every
# standard type so no valid upload is rejected. ``custom`` is intentionally
# excluded because it would in turn require a non-empty
# ``allowed_file_extensions``. The real fix is the builder now documenting and
# emitting these fields; this is the safety net that guarantees a loadable draft.
_DEFAULT_ALLOWED_FILE_TYPES = ("document", "image", "audio", "video")
_DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
# Token ceiling for the planner call when the caller didn't pin one. The plan
# is a short JSON node list (a handful of nodes with labels/purposes), so this
# is generous headroom while still bounding a runaway response. The builder is
@ -527,15 +512,8 @@ class WorkflowGenerator:
tool_catalogue_section=format_builder_tool_catalogue_section(tool_catalogue_text),
start_inputs_section=format_start_inputs_section(start_inputs or []),
)
# Scope the builder cheatsheet to exactly the node types the planner
# chose, so the prompt carries each type's FULL schema (e.g. a file
# start variable's required ``allowed_file_types``) without dragging in
# config for unrelated node types.
plan_node_types = {
str(node.get("node_type") or "").strip() for node in plan_nodes if str(node.get("node_type") or "").strip()
}
messages = [
SystemPromptMessage(content=get_builder_system_prompt(mode, plan_node_types)),
SystemPromptMessage(content=get_builder_system_prompt(mode)),
UserPromptMessage(content=user_prompt),
]
parsed = cls._invoke_and_parse_json(
@ -680,13 +658,6 @@ class WorkflowGenerator:
# variables before we surface them as errors.
cls._reconcile_variable_references(nodes=nodes, mode=mode)
# Schema backstop: a "file" / "file-list" start variable MUST carry a
# non-empty ``allowed_file_types`` or Studio refuses to load the draft
# ("supported file types is required"). The builder is now told to set
# it, but we fill safe defaults for any variable that still lacks it so
# the generated workflow always loads and runs.
cls._normalize_start_file_variables(nodes=nodes)
return cast(GraphDict, {"nodes": nodes, "edges": deduped_edges, "viewport": viewport})
# ------------------------------------------------------------------
@ -722,21 +693,6 @@ class WorkflowGenerator:
# remapping when we defensively strip hyphens out of LLM-emitted ids.
_ID_FIELDS: ClassVar = frozenset({"start_node_id", "iteration_id", "loop_id", "parentId"})
# ``data`` keys whose value is a plain string list, never a
# ``[node_id, var]`` value-selector — so the reference walker must not read
# a 2-element one as a selector. ``default`` holds an input's default value;
# ``options`` holds select choices; the ``allowed_file_*`` keys hold a file
# variable's upload config (types / extensions / methods).
_NON_SELECTOR_LIST_KEYS: ClassVar = frozenset(
{
"default",
"options",
"allowed_file_types",
"allowed_file_extensions",
"allowed_file_upload_methods",
}
)
@classmethod
def _reconcile_variable_references(cls, *, nodes: list[dict[str, Any]], mode: WorkflowGenerationMode) -> None:
"""
@ -791,16 +747,12 @@ class WorkflowGenerator:
# Known selector shapes: 2-element [node_id, var] lists.
for k, v in value.items():
# ``value_selector`` / ``query_variable_selector`` / etc.: a
# flat 2-element list of strings. Skip keys whose value is a
# plain string list that merely HAPPENS to have two entries —
# a 2-option ``select`` or a file variable's two allowed upload
# methods are NOT ``[node_id, var]`` selectors and must not be
# mistaken for references.
# flat 2-element list of strings.
if (
isinstance(v, list)
and len(v) == 2
and all(isinstance(x, str) for x in v)
and k not in cls._NON_SELECTOR_LIST_KEYS
and k != "default" # default values for input variables are not selectors
):
node_id, var = v[0].strip(), v[1].strip()
if node_id and var:
@ -971,96 +923,6 @@ class WorkflowGenerator:
}
)
@classmethod
def _normalize_start_file_variables(cls, *, nodes: list[dict[str, Any]]) -> None:
"""
Fill the required upload config on every file / file-list start variable.
A start variable of type ``file`` / ``file-list`` is invalid without a
non-empty ``allowed_file_types`` — Studio rejects the draft with
"supported file types is required" (see the front-end validator in
``config-var/config-modal/utils.ts``) and the workflow never runs. The
builder prompt now documents these fields, but LLMs still drop them, so
we backfill safe defaults here:
* a start variable a ``document-extractor`` consumes but that wasn't
declared as a file type → promoted to ``file`` (or ``file-list``
when the extractor's ``is_array_file`` is set), defaulting its
allowed types to ``["document"]`` (what extraction needs);
* empty / missing ``allowed_file_types`` → every standard file type;
* ``custom`` present without ``allowed_file_extensions`` → drop
``custom`` (it would otherwise require a non-empty extension list);
* empty / missing ``allowed_file_upload_methods`` → local + remote;
* ensure ``allowed_file_extensions`` is at least an empty list.
Idempotent: a variable that already declares valid file config is left
untouched.
"""
start_node = next(
(n for n in nodes if (n.get("data") or {}).get("type") == BuiltinNodeTypes.START),
None,
)
if start_node is None:
return
variables = (start_node.get("data") or {}).get("variables")
if not isinstance(variables, list):
return
# Start variables a document-extractor reads → whether it wants an
# array (file-list). These MUST be file inputs even if the builder
# mistyped them (e.g. declared "paragraph"), or the extractor fails at
# run time. ``["document"]`` is the right default for text extraction.
extractor_file_vars = cls._document_extractor_start_vars(nodes=nodes, start_id=start_node.get("id", ""))
for var in variables:
if not isinstance(var, dict):
continue
name = var.get("variable")
if name in extractor_file_vars and var.get("type") not in _FILE_VARIABLE_TYPES:
var["type"] = "file-list" if extractor_file_vars[name] else "file"
var.setdefault("allowed_file_types", ["document"])
if var.get("type") not in _FILE_VARIABLE_TYPES:
continue
allowed_types = var.get("allowed_file_types")
if not isinstance(allowed_types, list) or not allowed_types:
allowed_types = list(_DEFAULT_ALLOWED_FILE_TYPES)
var["allowed_file_types"] = allowed_types
# ``custom`` demands a non-empty extension list; without one, drop it
# so the variable doesn't trip the "file extensions required" check.
extensions = var.get("allowed_file_extensions")
has_extensions = isinstance(extensions, list) and bool(extensions)
if "custom" in allowed_types and not has_extensions:
pruned = [t for t in allowed_types if t != "custom"]
var["allowed_file_types"] = pruned or list(_DEFAULT_ALLOWED_FILE_TYPES)
methods = var.get("allowed_file_upload_methods")
if not isinstance(methods, list) or not methods:
var["allowed_file_upload_methods"] = list(_DEFAULT_FILE_UPLOAD_METHODS)
if not isinstance(var.get("allowed_file_extensions"), list):
var["allowed_file_extensions"] = []
@classmethod
def _document_extractor_start_vars(cls, *, nodes: list[dict[str, Any]], start_id: str) -> dict[str, bool]:
"""
Map start-variable name → ``is_array_file`` for every start variable a
``document-extractor`` node reads via its ``variable_selector``.
When two extractors read the same variable we keep ``True`` (file-list)
if any of them wants an array, since a file-list also satisfies a
single-file read.
"""
out: dict[str, bool] = {}
if not start_id:
return out
for node in nodes:
data = node.get("data") or {}
if data.get("type") != BuiltinNodeTypes.DOCUMENT_EXTRACTOR:
continue
selector = data.get("variable_selector")
if isinstance(selector, list) and len(selector) == 2 and selector[0] == start_id:
var_name = selector[1]
out[var_name] = out.get(var_name, False) or bool(data.get("is_array_file"))
return out
@classmethod
def _fill_node_defaults(cls, node: dict[str, Any]) -> None:
"""Ensure every node has the wrapper-level fields the Studio canvas needs."""

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import enum
import uuid
from collections.abc import Mapping, Sequence
from typing import Annotated, Any, ClassVar, Literal, override
from typing import Annotated, Any, ClassVar, Literal
import bleach
import markdown
@ -158,7 +158,6 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
type: Literal[DeliveryMethodType.EMAIL] = DeliveryMethodType.EMAIL
config: EmailDeliveryConfig
@override
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
variable_template_parser = VariableTemplateParser(template=self.config.body)
selectors: list[Sequence[str]] = []

View File

@ -195,16 +195,13 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
snapshot.update(self._overrides)
return snapshot
@override
def __getitem__(self, key: NodeType) -> Mapping[str, type[Node]]:
return self._snapshot()[key]
@override
def __setitem__(self, key: NodeType, value: Mapping[str, type[Node]]) -> None:
self._deleted.discard(key)
self._overrides[key] = value
@override
def __delitem__(self, key: NodeType) -> None:
if key in self._overrides:
del self._overrides[key]
@ -214,11 +211,9 @@ class _LazyNodeTypeClassesMapping(MutableMapping[NodeType, Mapping[str, type[Nod
return
raise KeyError(key)
@override
def __iter__(self) -> Iterator[NodeType]:
return iter(self._snapshot())
@override
def __len__(self) -> int:
return len(self._snapshot())

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Generator, Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, cast, overload, override
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -136,7 +136,6 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
def __init__(self, run_context: Mapping[str, Any] | DifyRunContext) -> None:
self._run_context = resolve_dify_run_context(run_context)
@override
def build_from_mapping(self, *, mapping: Mapping[str, Any]):
return file_factory.build_from_mapping(
mapping=mapping,
@ -152,31 +151,25 @@ class DifyPreparedLLM(LLMProtocol):
self._model_instance = model_instance
@property
@override
def provider(self) -> str:
return self._model_instance.provider
@property
@override
def model_name(self) -> str:
return self._model_instance.model_name
@property
@override
def parameters(self) -> Mapping[str, Any]:
return self._model_instance.parameters
@parameters.setter
@override
def parameters(self, value: Mapping[str, Any]) -> None:
self._model_instance.parameters = value
@property
@override
def stop(self) -> Sequence[str] | None:
return self._model_instance.stop
@override
def get_model_schema(self) -> AIModelEntity:
model_schema = cast(LargeLanguageModel, self._model_instance.model_type_instance).get_model_schema(
self._model_instance.model_name,
@ -186,7 +179,6 @@ class DifyPreparedLLM(LLMProtocol):
raise ValueError(f"Model schema not found for {self._model_instance.model_name}")
return model_schema
@override
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
return self._model_instance.get_llm_num_tokens(prompt_messages)
@ -212,7 +204,6 @@ class DifyPreparedLLM(LLMProtocol):
stream: Literal[True],
) -> Generator[LLMResultChunk, None, None]: ...
@override
def invoke_llm(
self,
*,
@ -252,7 +243,6 @@ class DifyPreparedLLM(LLMProtocol):
stream: Literal[True],
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
@override
def invoke_llm_with_structured_output(
self,
*,
@ -273,13 +263,11 @@ class DifyPreparedLLM(LLMProtocol):
stream=stream,
)
@override
def is_structured_output_parse_error(self, error: Exception) -> bool:
return isinstance(error, OutputParserError)
class DifyPromptMessageSerializer(PromptMessageSerializerProtocol):
@override
def serialize(
self,
*,
@ -306,7 +294,6 @@ class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol):
self._file_reference_factory = file_reference_factory
self._segment_access_checker = segment_access_checker
@override
def load(self, *, segment_id: str) -> Sequence[File]:
if not is_retriever_segment_access_granted(segment_id):
return []
@ -354,7 +341,6 @@ class DifyToolFileManager(ToolFileManagerProtocol):
self._manager = ToolFileManager()
self._conversation_id_getter = conversation_id_getter
@override
def create_file_by_raw(
self,
*,
@ -372,7 +358,6 @@ class DifyToolFileManager(ToolFileManagerProtocol):
filename=filename,
)
@override
def get_file_generator_by_tool_file_id(self, tool_file_id: str):
return self._manager.get_file_generator_by_tool_file_id(tool_file_id)
@ -409,11 +394,9 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
def file_reference_factory(self) -> FileReferenceFactoryProtocol:
return self._file_reference_factory
@override
def build_file_reference(self, *, mapping: Mapping[str, Any]):
return self._file_reference_factory.build_from_mapping(mapping=mapping)
@override
def get_runtime(
self,
*,
@ -464,7 +447,6 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
)
)
@override
def get_runtime_parameters(
self,
*,
@ -476,7 +458,6 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
for parameter in (tool.get_merged_runtime_parameters() or [])
]
@override
def invoke(
self,
*,
@ -522,7 +503,6 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
return self._adapt_messages(transformed_messages, provider_name=provider_name)
@override
def get_usage(
self,
*,
@ -765,7 +745,6 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
form_repository=form_repository,
)
@override
def get_form(self, *, node_id: str) -> HumanInputFormStateProtocol | None:
repo = self.build_form_repository()
return repo.get_form(node_id)
@ -787,7 +766,6 @@ class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol):
)
return restored_data
@override
def create_form(
self,
*,

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.workflow.system_variables import SystemVariableKey, get_system_text
@ -56,11 +56,9 @@ class AgentNode(Node[AgentNodeData]):
self._message_transformer = message_transformer
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def populate_start_event(self, event) -> None:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
event.extras["agent_strategy"] = {
@ -71,7 +69,6 @@ class AgentNode(Node[AgentNodeData]):
),
}
@override
def _run(self) -> Generator[NodeEventBase, None, None]:
from core.plugin.impl.exc import PluginDaemonClientSideError
@ -170,7 +167,6 @@ class AgentNode(Node[AgentNodeData]):
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,14 +1,11 @@
from __future__ import annotations
from typing import override
from factories.agent_factory import get_plugin_agent_strategy
from .strategy_protocols import AgentStrategyPresentationProvider, AgentStrategyResolver, ResolvedAgentStrategy
class PluginAgentStrategyResolver(AgentStrategyResolver):
@override
def resolve(
self,
*,
@ -24,7 +21,6 @@ class PluginAgentStrategyResolver(AgentStrategyResolver):
class PluginAgentStrategyPresentationProvider(AgentStrategyPresentationProvider):
@override
def get_icon(self, *, tenant_id: str, agent_strategy_provider_name: str) -> str | None:
from core.plugin.impl.plugin import PluginInstaller

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any
from agenton.compositor import CompositorSessionSnapshot
@ -101,15 +101,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
self._session_store = session_store
@classmethod
@override
def version(cls) -> str:
return "2"
@override
def populate_start_event(self, event) -> None:
event.extras["agent_node"] = {"version": "2", "agent_node_kind": self.node_data.agent_node_kind}
@override
def _run(self) -> Generator[NodeEventBase, None, None]:
dify_ctx = DifyRunContext.model_validate(self.require_run_context_value(DIFY_RUN_CONTEXT_KEY))
workflow_id = self.graph_init_params.workflow_id
@ -580,7 +577,6 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
metadata["agent_backend"] = agent_backend
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,5 +1,5 @@
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
from core.datasource.datasource_manager import DatasourceManager
@ -49,12 +49,10 @@ class DatasourceNode(Node[DatasourceNodeData]):
)
self.datasource_manager = DatasourceManager
@override
def populate_start_event(self, event) -> None:
event.provider_id = f"{self.node_data.plugin_id}/{self.node_data.provider_name}"
event.provider_type = self.node_data.provider_type
@override
def _run(self) -> Generator:
"""
Run the datasource node
@ -185,7 +183,6 @@ class DatasourceNode(Node[DatasourceNodeData]):
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,
@ -222,6 +219,5 @@ class DatasourceNode(Node[DatasourceNodeData]):
return result
@classmethod
@override
def version(cls) -> str:
return "1"

View File

@ -1,6 +1,6 @@
import logging
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, override
from typing import TYPE_CHECKING, Any
from core.rag.index_processor.index_processor import IndexProcessor
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
@ -46,7 +46,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
self.index_processor = IndexProcessor()
self.summary_index_service = SummaryIndex()
@override
def _run(self) -> NodeRunResult:
node_data = self.node_data
variable_pool = self.graph_runtime_state.variable_pool
@ -146,7 +145,6 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]):
return rst
@classmethod
@override
def version(cls) -> str:
return "1"

View File

@ -6,7 +6,7 @@ the workflow node registry.
import logging
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, override
from typing import TYPE_CHECKING, Any, Literal
from core.app.app_config.entities import DatasetRetrieveConfigEntity
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
@ -87,11 +87,9 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
self._rag_retrieval = DatasetRetrieval()
@classmethod
@override
def version(cls):
return "1"
@override
def _run(self) -> NodeRunResult:
usage = LLMUsage.empty_usage()
if not self._node_data.query_variable_selector and not self._node_data.query_attachment_selector:
@ -329,7 +327,6 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD
)
@classmethod
@override
def _extract_variable_selector_to_variable_mapping(
cls,
*,

View File

@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Any, override
from typing import Any
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
@ -15,7 +15,6 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
execution_type = NodeExecutionType.ROOT
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": "plugin",
@ -31,15 +30,12 @@ class TriggerEventNode(Node[TriggerEventNodeData]):
}
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def populate_start_event(self, event) -> None:
event.provider_id = self.node_data.provider_id
@override
def _run(self) -> NodeRunResult:
"""
Run the plugin trigger node.

View File

@ -1,5 +1,4 @@
from collections.abc import Mapping
from typing import override
from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
@ -15,12 +14,10 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
execution_type = NodeExecutionType.ROOT
@classmethod
@override
def version(cls) -> str:
return "1"
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": TRIGGER_SCHEDULE_NODE_TYPE,
@ -32,7 +29,6 @@ class TriggerScheduleNode(Node[TriggerScheduleNodeData]):
},
}
@override
def _run(self) -> NodeRunResult:
node_inputs = dict(self.graph_runtime_state.variable_pool.get_by_prefix(self.id))
system_inputs = self.graph_runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)

View File

@ -1,6 +1,6 @@
import logging
from collections.abc import Mapping
from typing import Any, override
from typing import Any
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
from core.workflow.file_reference import resolve_file_record_id
@ -25,14 +25,12 @@ class TriggerWebhookNode(Node[WebhookData]):
_file_reference_factory: FileReferenceFactoryProtocol
@override
def post_init(self) -> None:
from core.workflow.node_runtime import DifyFileReferenceFactory
self._file_reference_factory = DifyFileReferenceFactory(self.run_context)
@classmethod
@override
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
return {
"type": "webhook",
@ -50,11 +48,9 @@ class TriggerWebhookNode(Node[WebhookData]):
}
@classmethod
@override
def version(cls) -> str:
return "1"
@override
def _run(self) -> NodeRunResult:
"""
Run the webhook node.

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, override
from typing import Any
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor
from graphon.nodes.code.entities import CodeLanguage
@ -11,7 +11,6 @@ from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderErr
class CodeExecutorJinja2TemplateRenderer(Jinja2TemplateRenderer):
"""Sandbox-backed Jinja2 renderer for workflow-owned node composition."""
@override
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
try:
result = CodeExecutor.execute_workflow_code_template(

View File

@ -1,5 +1,3 @@
from typing import override
"""Custom OTEL ID Generator for correlation-based trace/span ID derivation.
Uses contextvars for thread-safe correlation_id -> trace_id mapping.
@ -54,7 +52,6 @@ class CorrelationIdGenerator(IdGenerator):
parent-child linking), otherwise random
"""
@override
def generate_trace_id(self) -> int:
correlation_id = _correlation_id_context.get()
if correlation_id:
@ -64,7 +61,6 @@ class CorrelationIdGenerator(IdGenerator):
pass
return random.getrandbits(128)
@override
def generate_span_id(self) -> int:
source = _span_id_source_context.get()
if source:

View File

@ -5,7 +5,6 @@ def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
backfill_plugin_auto_upgrade,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@ -54,7 +53,6 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,

View File

@ -1,5 +1,4 @@
import json
from typing import override
from flask_restx import fields
@ -8,7 +7,6 @@ from libs.helper import AppIconUrlField, TimestampField
class JsonStringField(fields.Raw):
@override
def format(self, value):
if isinstance(value, str):
try:

View File

@ -1,12 +1,9 @@
from typing import override
from flask_restx import fields
from graphon.file import File
class FilesContainedField(fields.Raw):
@override
def format(self, value):
return self._format_file_object(value)

View File

@ -1,5 +1,3 @@
from typing import override
from flask_restx import fields
from core.helper import encrypter
@ -13,7 +11,6 @@ ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER,
class EnvironmentVariableField(fields.Raw):
@override
def format(self, value):
# Mask secret variables values in environment_variables
if isinstance(value, SecretVariable):

View File

@ -1,42 +0,0 @@
"""add plugin auto upgrade category
Revision ID: f6a7b8c9d012
Revises: 8d4c2a1b9f03
Create Date: 2026-05-15 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6a7b8c9d012"
down_revision = "8d4c2a1b9f03"
branch_labels = None
depends_on = None
LEGACY_CATEGORY = "tool"
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
def upgrade():
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.add_column(
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
def downgrade():
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
batch_op.drop_column("category")
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])

View File

@ -1,26 +0,0 @@
"""add learn dify flag to recommended apps
Revision ID: f5e8a9c0d2b3
Revises: f6a7b8c9d012
Create Date: 2026-05-18 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f5e8a9c0d2b3"
down_revision = "f6a7b8c9d012"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
def downgrade():
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
batch_op.drop_column("is_learn_dify")

View File

@ -1,44 +0,0 @@
"""add identity mode to mcp tool provider
Revision ID: 3df4dbcc1e21
Revises: 2b3c4d5e6f70
Create Date: 2026-05-29 15:00:00.000000
Adds the `identity_mode` column to `tool_mcp_providers` to drive the M2 MCP
user-identity forwarding feature. Reserved values:
"off" — no header forwarded (default; pre-M2 behaviour).
"idp_token" — call dify-enterprise /inner/api/mcp/issue-token, stamp the
returned SSO access token on the outbound MCP request as
`X-Dify-SSO-Access-Token: <token>`.
The column is filled with the safe default "off" for existing rows so older
providers keep their current behaviour until an admin opts in.
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "3df4dbcc1e21"
down_revision = "2b3c4d5e6f70"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"tool_mcp_providers",
sa.Column(
"identity_mode",
sa.String(length=32),
nullable=False,
server_default=sa.text("'off'"),
),
)
def downgrade():
op.drop_column("tool_mcp_providers", "identity_mode")

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