Compare commits

..
Author SHA1 Message Date
WH-2099 5362195253 fix(api): scope segment batch deletes 2026-07-06 12:47:02 +08:00
386 changed files with 4478 additions and 8583 deletions
+6 -6
View File
@@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
+3 -3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@@ -73,12 +73,12 @@ jobs:
dify-agent/pyproject.toml
dify-agent/uv.lock
- if: github.event_name != 'merge_group'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.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'
+6 -6
View File
@@ -97,7 +97,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@@ -107,7 +107,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env[matrix.image_name_env] }}
@@ -159,10 +159,10 @@ jobs:
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Validate Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
push: false
context: ${{ matrix.build_context }}
@@ -197,14 +197,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |
+6 -6
View File
@@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
@@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
+2 -2
View File
@@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build Docker Image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
push: false
context: ${{ matrix.context }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
+2 -2
View File
@@ -46,8 +46,8 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: |
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: |
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: false
python-version: "3.12"
@@ -88,7 +88,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -139,7 +139,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -168,7 +168,7 @@ jobs:
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
@@ -185,7 +185,7 @@ jobs:
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .eslintcache
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
@@ -196,7 +196,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@@ -214,7 +214,7 @@ jobs:
.editorconfig
- name: Super-linter
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
if: steps.changed-files.outputs.any_changed == 'true'
env:
BASH_SEVERITY: warning
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: "3.12"
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -16,7 +16,7 @@ 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 TenantPluginAutoUpgradeCategory, TenantPluginAutoUpgradeStrategy
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
@@ -406,7 +406,7 @@ def migrate_data_for_plugin():
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeCategory)
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
@@ -1,52 +0,0 @@
from typing import Any
from sqlalchemy import select
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from extensions.ext_database import db
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App
def get_published_agent_app_feature_dict_and_user_input_form(
app_model: App,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return public Agent App parameters backed by the published Agent Soul."""
app_model_config = app_model.app_model_config
agent_id = app_model.bound_agent_id
if not agent_id:
raise AgentAppGeneratorError("Agent App has no bound Agent")
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
features_dict = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@@ -108,8 +108,14 @@ class SandboxReadResponse(ResponseModel):
text: str | None = None
class SandboxToolFileResponse(ResponseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
class SandboxUploadResponse(ResponseModel):
url: str
path: str
file: SandboxToolFileResponse
register_schema_models(
@@ -219,7 +225,7 @@ class AgentAppSandboxReadResource(Resource):
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@@ -316,7 +322,7 @@ class WorkflowAgentSandboxReadResource(Resource):
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@@ -124,7 +124,6 @@ Use only the current Build chat message history to identify changes that need to
validate old config unless the message history already shows that the old config is invalid.
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
Write the config note in the language used by the message history.
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
skills, config env, tools, models, knowledge, or prompt settings.
+16 -29
View File
@@ -9,12 +9,7 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from constants.languages import get_valid_language
from controllers.common.fields import (
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
from controllers.common.fields import SimpleResultDataResponse, SimpleResultOptionalDataResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
@@ -92,7 +87,6 @@ register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPay
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
@@ -160,19 +154,16 @@ class LoginApi(Resource):
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return SimpleResultOptionalDataResponse(
result="fail",
data="workspace not found, please contact system admin to invite you to join in a workspace",
).model_dump(mode="json")
return {
"result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
}
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
# Create response with cookies instead of returning tokens in body
# response-contract:ignore cookie-bearing Flask response
response = make_response(
SimpleResultOptionalDataResponse(result="success").model_dump(mode="json", exclude_none=True)
)
response = make_response({"result": "success"})
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
@@ -187,11 +178,12 @@ class LogoutApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user
def post(self, account: Account):
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
if not isinstance(account, flask_login.AnonymousUserMixin):
if isinstance(account, flask_login.AnonymousUserMixin):
response = make_response({"result": "success"})
else:
AccountService.logout(account=account)
flask_login.logout_user()
response = make_response({"result": "success"})
# Clear cookies on logout
clear_access_token_from_cookie(response)
@@ -227,7 +219,7 @@ class ResetPasswordSendEmailApi(Resource):
is_allow_register=FeatureService.get_system_features().is_allow_register,
)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
return {"result": "success", "data": token}
@console_ns.route("/email-code-login")
@@ -260,7 +252,7 @@ class EmailCodeLoginSendEmailApi(Resource):
else:
token = AccountService.send_email_code_login_email(account=account, language=language)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
return {"result": "success", "data": token}
@console_ns.route("/email-code-login/validity")
@@ -334,8 +326,7 @@ class EmailCodeLoginApi(Resource):
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
response = make_response({"result": "success"})
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
# Set HTTP-only secure cookies for tokens
@@ -347,22 +338,18 @@ class EmailCodeLoginApi(Resource):
@console_ns.route("/refresh-token")
class RefreshTokenApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(401, "Unauthorized", console_ns.models[SimpleResultMessageResponse.__name__])
def post(self):
# Get refresh token from cookie instead of request body
refresh_token = extract_refresh_token(request)
if not refresh_token:
return SimpleResultMessageResponse(result="fail", message="No refresh token provided").model_dump(
mode="json"
), 401
return {"result": "fail", "message": "No refresh token provided"}, 401
try:
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
# Create response with new cookies
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
response = make_response({"result": "success"})
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
@@ -370,7 +357,7 @@ class RefreshTokenApi(Resource):
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
except Exception as e:
return SimpleResultMessageResponse(result="fail", message=str(e)).model_dump(mode="json"), 401
return {"result": "fail", "message": str(e)}, 401
def _get_account_with_case_fallback(email: str):
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.fields import RedirectResponse, SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
@@ -19,13 +19,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.entities.provider_entities import ProviderConfig
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
from core.plugin.impl.oauth import OAuthHandler
from core.tools.entities.common_entities import I18nObject
from fields.base import ResponseModel
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from libs.helper import dump_response
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
from models import Account
from models.provider_ids import DatasourceProviderID
@@ -35,9 +33,7 @@ from services.plugin.oauth_service import OAuthProxyService
class DatasourceCredentialPayload(BaseModel):
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any] = Field(
description="Plugin-defined credential parameters. The schema is declared by the datasource provider."
)
credentials: dict[str, Any]
class DatasourceCredentialDeletePayload(BaseModel):
@@ -47,17 +43,11 @@ class DatasourceCredentialDeletePayload(BaseModel):
class DatasourceCredentialUpdatePayload(BaseModel):
credential_id: str
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined credential parameters. The schema is declared by the datasource provider.",
)
credentials: dict[str, Any] | None = Field(default=None)
class DatasourceCustomClientPayload(BaseModel):
client_params: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined OAuth client parameters. The schema is declared by the datasource provider.",
)
client_params: dict[str, Any] | None = Field(default=None)
enable_oauth_custom_client: bool | None = None
@@ -81,48 +71,8 @@ class DatasourceOAuthCallbackQuery(BaseModel):
context_id: str | None = Field(default=None, description="OAuth proxy context ID")
class DatasourceCredentialResponse(ResponseModel):
credential: dict[str, Any] = Field(
description="Obfuscated plugin-defined credential parameters from the datasource provider."
)
type: str
name: str
avatar_url: str | None
id: str
is_default: bool
class DatasourceCredentialListResponse(ResponseModel):
result: list[DatasourceCredentialResponse]
class DatasourceOAuthSchemaResponse(ResponseModel):
client_schema: list[ProviderConfig]
credentials_schema: list[ProviderConfig]
oauth_custom_client_params: dict[str, Any] | None = Field(
description="Masked plugin-defined OAuth client parameters, when configured for the tenant."
)
is_oauth_custom_client_enabled: bool
is_system_oauth_params_exists: bool
redirect_uri: str
class DatasourceProviderAuthResponse(ResponseModel):
author: str
provider: str
plugin_id: str
plugin_unique_identifier: str
icon: str
name: str
label: I18nObject
description: I18nObject
credential_schema: list[ProviderConfig]
oauth_schema: DatasourceOAuthSchemaResponse | None
credentials_list: list[DatasourceCredentialResponse]
class DatasourceProviderAuthListResponse(ResponseModel):
result: list[DatasourceProviderAuthResponse]
class DatasourceCredentialsResponse(ResponseModel):
result: Any
register_schema_models(
@@ -138,9 +88,9 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DatasourceCredentialListResponse,
DatasourceProviderAuthListResponse,
DatasourceCredentialsResponse,
PluginOAuthAuthorizationUrlResponse,
RedirectResponse,
SimpleResultResponse,
)
@@ -150,7 +100,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery))
@console_ns.response(
200,
"Datasource OAuth authorization URL generated successfully",
"Authorization URL retrieved successfully",
console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__],
)
@setup_required
@@ -190,8 +140,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
redirect_uri=redirect_uri,
system_credentials=oauth_config,
)
# response-contract:ignore cookie-bearing Flask response
response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response))
response = make_response(jsonable_encoder(authorization_url_response))
response.set_cookie(
"context_id",
context_id,
@@ -205,8 +154,11 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/callback")
class DatasourceOAuthCallback(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthCallbackQuery))
# response-contract:ignore redirect response
@console_ns.response(302, "Redirect to OAuth callback page")
@console_ns.response(
302,
"Redirect to console OAuth callback page",
console_ns.models[RedirectResponse.__name__],
)
@setup_required
def get(self, provider_id: str):
context_id = request.cookies.get("context_id") or request.args.get("context_id")
@@ -265,9 +217,7 @@ class DatasourceOAuthCallback(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>")
class DatasourceAuth(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialPayload.__name__])
@console_ns.response(
200, "Datasource credential created successfully", console_ns.models[SimpleResultResponse.__name__]
)
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -288,16 +238,12 @@ class DatasourceAuth(Resource):
)
except CredentialsValidateFailedError as ex:
raise ValueError(str(ex))
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceCredentialListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):
@@ -310,7 +256,7 @@ class DatasourceAuth(Resource):
plugin_id=datasource_provider_id.plugin_id,
user=user,
)
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
return {"result": datasources}, 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/delete")
@@ -336,15 +282,13 @@ class DatasourceAuthDeleteApi(Resource):
provider=provider_name,
plugin_id=plugin_id,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update")
class DatasourceAuthUpdateApi(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialUpdatePayload.__name__])
@console_ns.response(
201, "Datasource credential updated successfully", console_ns.models[SimpleResultResponse.__name__]
)
@console_ns.response(201, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -364,16 +308,12 @@ class DatasourceAuthUpdateApi(Resource):
credentials=payload.credentials or {},
name=payload.name,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 201
return {"result": "success"}, 201
@console_ns.route("/auth/plugin/datasource/list")
class DatasourceAuthListApi(Resource):
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -381,16 +321,12 @@ class DatasourceAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_all_datasource_credentials(tenant_id=current_tenant_id)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
return {"result": jsonable_encoder(datasources)}, 200
@console_ns.route("/auth/plugin/datasource/default-list")
class DatasourceHardCodeAuthListApi(Resource):
@console_ns.response(
200,
"Default datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -398,15 +334,13 @@ class DatasourceHardCodeAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_hard_code_datasource_credentials(tenant_id=current_tenant_id)
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
return {"result": jsonable_encoder(datasources)}, 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/custom-client")
class DatasourceAuthOauthCustomClient(Resource):
@console_ns.expect(console_ns.models[DatasourceCustomClientPayload.__name__])
@console_ns.response(
200, "Datasource OAuth custom client saved successfully", console_ns.models[SimpleResultResponse.__name__]
)
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -423,7 +357,7 @@ class DatasourceAuthOauthCustomClient(Resource):
client_params=payload.client_params or {},
enabled=payload.enable_oauth_custom_client or False,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@setup_required
@login_required
@@ -437,7 +371,7 @@ class DatasourceAuthOauthCustomClient(Resource):
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/default")
@@ -459,7 +393,7 @@ class DatasourceAuthDefaultApi(Resource):
datasource_provider_id=datasource_provider_id,
credential_id=payload.id,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update-name")
@@ -482,4 +416,4 @@ class DatasourceUpdateProviderNameApi(Resource):
name=payload.name,
credential_id=payload.credential_id,
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
return {"result": "success"}, 200
@@ -3,9 +3,9 @@ from typing import Any
from flask_restx import ( # type: ignore
Resource, # type: ignore
)
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
@@ -21,13 +21,18 @@ class Parser(BaseModel):
credential_id: str | None = None
class DataSourceContentPreviewResponse(RootModel[Any]):
root: Any
register_schema_models(console_ns, Parser)
register_response_schema_models(console_ns, DataSourceContentPreviewResponse)
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/preview")
class DataSourceContentPreviewApi(Resource):
@console_ns.expect(console_ns.models[Parser.__name__])
@console_ns.response(200, "Success")
@console_ns.response(200, "Success", console_ns.models[DataSourceContentPreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
+5 -6
View File
@@ -13,6 +13,7 @@ import services
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
)
from controllers.common.fields import Parameters as ParametersResponse
@@ -390,6 +391,7 @@ register_response_schema_models(
ParametersResponse,
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
SiteResponse,
SuggestedQuestionsResponse,
@@ -404,7 +406,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@@ -432,7 +434,6 @@ class TrialAppWorkflowRunApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@@ -477,7 +478,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@trial_feature_enable
@with_current_user
@with_session
@@ -512,7 +513,6 @@ class TrialChatApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -680,7 +680,7 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@trial_feature_enable
@with_current_user
@with_session
@@ -710,7 +710,6 @@ class TrialCompletionApi(TrialAppResource):
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission
from models.account import TenantPluginPermission
def plugin_permission_required(
@@ -40,22 +40,22 @@ def plugin_permission_required(
if install_required:
match permission.install_permission:
case TenantPluginInstallPermission.NOBODY:
case TenantPluginPermission.InstallPermission.NOBODY:
raise Forbidden()
case TenantPluginInstallPermission.ADMINS:
case TenantPluginPermission.InstallPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginInstallPermission.EVERYONE:
case TenantPluginPermission.InstallPermission.EVERYONE:
pass
if debug_required:
match permission.debug_permission:
case TenantPluginDebugPermission.NOBODY:
case TenantPluginPermission.DebugPermission.NOBODY:
raise Forbidden()
case TenantPluginDebugPermission.ADMINS:
case TenantPluginPermission.DebugPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginDebugPermission.EVERYONE:
case TenantPluginPermission.DebugPermission.EVERYONE:
pass
return view(*args, **kwargs)
+82 -72
View File
@@ -1,13 +1,12 @@
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import Literal
from typing import Any, Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@@ -48,7 +47,7 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
from fields.member_fields import Account as AccountResponse
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
@@ -195,6 +194,10 @@ register_schema_models(
)
def _serialize_account(account) -> dict[str, Any]:
return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json")
class AccountIntegrateResponse(ResponseModel):
provider: str
created_at: int | None = None
@@ -233,15 +236,23 @@ class EducationAutocompleteResponse(ResponseModel):
has_next: bool | None = None
register_response_schema_models(
class EducationActivateResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(
console_ns,
AccountResponse,
AccountIntegrateResponse,
AccountIntegrateListResponse,
AvatarUrlResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
)
register_response_schema_models(
console_ns,
AccountResponse,
AvatarUrlResponse,
EducationActivateResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
@@ -251,7 +262,7 @@ register_response_schema_models(
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@with_current_user
@@ -291,7 +302,7 @@ class AccountInitApi(Resource):
account.initialized_at = naive_utc_now()
db.session.commit()
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
@console_ns.route("/account/profile")
@@ -299,11 +310,11 @@ class AccountProfileApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@enterprise_license_required
@with_current_user
def get(self, current_user: Account):
return dump_response(AccountResponse, current_user)
return _serialize_account(current_user)
@console_ns.route("/account/name")
@@ -312,14 +323,14 @@ class AccountNameApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session, name=args.name)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/avatar")
@@ -327,7 +338,7 @@ class AccountAvatarApi(Resource):
@console_ns.doc("get_account_avatar")
@console_ns.doc(description="Get account avatar url")
@console_ns.doc(params=query_params_from_model(AccountAvatarQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AvatarUrlResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AvatarUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -338,7 +349,7 @@ class AccountAvatarApi(Resource):
avatar = args.avatar
if avatar.startswith(("http://", "https://")):
return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json")
return dump_response(AvatarUrlResponse, {"avatar_url": avatar})
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
if upload_file is None:
@@ -351,13 +362,13 @@ class AccountAvatarApi(Resource):
raise NotFound("Avatar file not found")
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
return dump_response(AvatarUrlResponse, {"avatar_url": avatar_url})
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -365,7 +376,7 @@ class AccountAvatarApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/interface-language")
@@ -374,7 +385,7 @@ class AccountInterfaceLanguageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -384,7 +395,7 @@ class AccountInterfaceLanguageApi(Resource):
current_user, session=db.session, interface_language=args.interface_language
)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/interface-theme")
@@ -393,7 +404,7 @@ class AccountInterfaceThemeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -403,7 +414,7 @@ class AccountInterfaceThemeApi(Resource):
current_user, session=db.session, interface_theme=args.interface_theme
)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/timezone")
@@ -412,7 +423,7 @@ class AccountTimezoneApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -420,7 +431,7 @@ class AccountTimezoneApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/password")
@@ -429,7 +440,7 @@ class AccountPasswordApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -441,7 +452,7 @@ class AccountPasswordApi(Resource):
except ServiceCurrentPasswordIncorrectError:
raise CurrentPasswordIncorrectError()
return dump_response(AccountResponse, current_user)
return _serialize_account(current_user)
@console_ns.route("/account/integrates")
@@ -449,7 +460,7 @@ class AccountIntegrateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@with_current_user
def get(self, account: Account):
account_integrates = db.session.scalars(
@@ -460,29 +471,33 @@ class AccountIntegrateApi(Resource):
oauth_base_path = "/console/api/oauth/login"
providers = ["github", "google"]
integrate_data: list[AccountIntegrateResponse] = []
integrate_data = []
for provider in providers:
existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None)
if existing_integrate:
integrate_data.append(
AccountIntegrateResponse(
provider=provider,
created_at=to_timestamp(existing_integrate.created_at),
is_bound=True,
link=None,
)
{
"id": existing_integrate.id,
"provider": provider,
"created_at": existing_integrate.created_at,
"is_bound": True,
"link": None,
}
)
else:
integrate_data.append(
AccountIntegrateResponse(
provider=provider,
created_at=None,
is_bound=False,
link=f"{base_url}{oauth_base_path}/{provider}",
)
{
"id": None,
"provider": provider,
"created_at": None,
"is_bound": False,
"link": f"{base_url}{oauth_base_path}/{provider}",
}
)
return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json")
return AccountIntegrateListResponse(
data=[AccountIntegrateResponse.model_validate(item) for item in integrate_data]
).model_dump(mode="json")
@console_ns.route("/account/delete/verify")
@@ -490,19 +505,19 @@ class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@with_current_user
def get(self, account: Account):
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
return {"result": "success", "data": token}
@console_ns.route("/account/delete")
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -516,13 +531,13 @@ class AccountDeleteApi(Resource):
AccountService.delete_account(account)
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
@console_ns.route("/account/delete/feedback")
class AccountDeleteUpdateFeedbackApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@@ -530,7 +545,7 @@ class AccountDeleteUpdateFeedbackApi(Resource):
BillingService.update_account_deletion_feedback(args.email, args.feedback)
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
@console_ns.route("/account/education/verify")
@@ -540,19 +555,18 @@ class EducationVerifyApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
return dump_response(
EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {}
)
return EducationVerifyResponse.model_validate(
BillingService.EducationIdentity.verify(account.id, account.email) or {}
).model_dump(mode="json")
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
@console_ns.response(HTTPStatus.OK, "Success")
@console_ns.response(200, "Success", console_ns.models[EducationActivateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -563,22 +577,21 @@ class EducationApi(Resource):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
return result
return BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
res = BillingService.EducationIdentity.status(account.id) or {}
# convert expire_at to UTC timestamp from isoformat
if res and "expire_at" in res:
res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc)
return dump_response(EducationStatusResponse, res)
return EducationStatusResponse.model_validate(res).model_dump(mode="json")
@console_ns.route("/account/education/autocomplete")
@@ -589,21 +602,20 @@ class EducationAutoCompleteApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
payload = request.args.to_dict(flat=True)
args = EducationAutocompleteQuery.model_validate(payload)
return dump_response(
EducationAutocompleteResponse,
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {},
)
return EducationAutocompleteResponse.model_validate(
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {}
).model_dump(mode="json")
@console_ns.route("/account/change-email")
class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@enable_change_email
@setup_required
@login_required
@@ -657,13 +669,13 @@ class ChangeEmailSendEmailApi(Resource):
language=language,
phase=send_phase,
)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
return {"result": "success", "data": token}
@console_ns.route("/account/change-email/validity")
class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@enable_change_email
@setup_required
@login_required
@@ -704,9 +716,7 @@ class ChangeEmailCheckApi(Resource):
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump(
mode="json"
)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@console_ns.route("/account/change-email/reset")
@@ -716,7 +726,7 @@ class ChangeEmailResetApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@@ -758,13 +768,13 @@ class ChangeEmailResetApi(Resource):
email=normalized_new_email,
)
return dump_response(AccountResponse, updated_account)
return _serialize_account(updated_account)
@console_ns.route("/account/change-email/check-email-unique")
class CheckEmailUnique(Resource):
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@@ -774,4 +784,4 @@ class CheckEmailUnique(Resource):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session):
raise EmailAlreadyInUseError()
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
+133 -193
View File
@@ -6,17 +6,13 @@ verb-based aliases stay available as deprecated resources so OpenAPI metadata
marks only the legacy paths as deprecated.
"""
from datetime import datetime
from enum import StrEnum
from http import HTTPStatus
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.fields import SuccessResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
@@ -28,14 +24,8 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user_id,
)
from core.entities.parameter_entities import (
AppSelectorScope,
ModelSelectorScope,
ToolSelectorScope,
)
from core.entities.provider_entities import ProviderConfigType
from core.plugin.impl.exc import PluginPermissionDeniedError
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.login import login_required
from services.plugin.endpoint_service import EndpointService
@@ -50,17 +40,14 @@ class EndpointIdPayload(BaseModel):
endpoint_id: str
class EndpointSettingsPayload(BaseModel):
class EndpointUpdatePayload(BaseModel):
settings: dict[str, Any]
name: str = Field(min_length=1)
class EndpointUpdatePayload(EndpointSettingsPayload):
pass
class LegacyEndpointUpdatePayload(EndpointIdPayload, EndpointSettingsPayload):
pass
class LegacyEndpointUpdatePayload(EndpointIdPayload):
settings: dict[str, Any]
name: str = Field(min_length=1)
class EndpointListQuery(BaseModel):
@@ -72,158 +59,98 @@ class EndpointListForPluginQuery(EndpointListQuery):
plugin_id: str
class EndpointProviderConfigScope(StrEnum):
ALL = AppSelectorScope.ALL.value
CHAT = AppSelectorScope.CHAT.value
WORKFLOW = AppSelectorScope.WORKFLOW.value
COMPLETION = AppSelectorScope.COMPLETION.value
LLM = ModelSelectorScope.LLM.value
TEXT_EMBEDDING = ModelSelectorScope.TEXT_EMBEDDING.value
RERANK = ModelSelectorScope.RERANK.value
TTS = ModelSelectorScope.TTS.value
SPEECH2TEXT = ModelSelectorScope.SPEECH2TEXT.value
MODERATION = ModelSelectorScope.MODERATION.value
VISION = ModelSelectorScope.VISION.value
CUSTOM = ToolSelectorScope.CUSTOM.value
BUILTIN = ToolSelectorScope.BUILTIN.value
class EndpointCreateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderConfigI18nResponse(ResponseModel):
en_US: str
zh_Hans: str | None = None
pt_BR: str | None = None
ja_JP: str | None = None
class EndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigOptionResponse(ResponseModel):
value: str
label: EndpointProviderConfigI18nResponse
class PluginEndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigResponse(ResponseModel):
type: ProviderConfigType
name: str
scope: EndpointProviderConfigScope | None = None
required: bool = False
default: int | str | float | bool | None = None
options: list[EndpointProviderConfigOptionResponse] | None = None
multiple: bool = False
label: EndpointProviderConfigI18nResponse | None = None
help: EndpointProviderConfigI18nResponse | None = None
url: str | None = None
placeholder: EndpointProviderConfigI18nResponse | None = None
class EndpointDeleteResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointDeclarationResponse(ResponseModel):
path: str
method: str
hidden: bool = False
class EndpointUpdateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderDeclarationResponse(ResponseModel):
settings: list[EndpointProviderConfigResponse] = Field(default_factory=list)
endpoints: list[EndpointDeclarationResponse] | None = Field(default_factory=list)
class EndpointEnableResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointListItemResponse(ResponseModel):
id: str
created_at: datetime
updated_at: datetime
tenant_id: str
plugin_id: str
settings: dict[str, Any]
expired_at: datetime
declaration: EndpointProviderDeclarationResponse = Field(default_factory=EndpointProviderDeclarationResponse)
name: str
enabled: bool
url: str
hook_id: str
class EndpointListResponse(ResponseModel):
endpoints: list[EndpointListItemResponse] = Field(description="Endpoint information")
class EndpointDisableResponse(BaseModel):
success: bool = Field(description="Operation success")
register_schema_models(
console_ns,
EndpointCreatePayload,
EndpointIdPayload,
EndpointSettingsPayload,
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
EndpointListQuery,
EndpointListForPluginQuery,
)
register_response_schema_models(
console_ns,
SuccessResponse,
EndpointProviderConfigOptionResponse,
EndpointProviderConfigResponse,
EndpointDeclarationResponse,
EndpointProviderDeclarationResponse,
EndpointListItemResponse,
EndpointCreateResponse,
EndpointListResponse,
PluginEndpointListResponse,
EndpointDeleteResponse,
EndpointUpdateResponse,
EndpointEnableResponse,
EndpointDisableResponse,
)
def _create_endpoint(tenant_id: str, user_id: str) -> bool:
def _create_endpoint(tenant_id: str, user_id: str) -> dict[str, bool]:
"""Create a plugin endpoint for the injected workspace and user."""
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
"""Update a plugin endpoint identified by the canonical path parameter."""
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
}
def _legacy_update_endpoint(tenant_id: str, user_id: str) -> bool:
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=args.endpoint_id,
name=args.name,
settings=args.settings,
)
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
"""Delete a plugin endpoint identified by the canonical path parameter."""
return EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
def _delete_endpoint_from_payload(tenant_id: str, user_id: str) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
def _set_endpoint_enabled(tenant_id: str, user_id: str, *, enabled: bool) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint
return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
}
@console_ns.route("/workspaces/current/endpoints")
@@ -234,11 +161,11 @@ class EndpointCollectionApi(Resource):
@console_ns.doc(description="Create a new plugin endpoint")
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint created successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointCreateResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -247,7 +174,7 @@ class EndpointCollectionApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
@console_ns.route("/workspaces/current/endpoints/create")
@@ -263,11 +190,11 @@ class DeprecatedEndpointCreateApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint created successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointCreateResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -276,7 +203,7 @@ class DeprecatedEndpointCreateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
@console_ns.route("/workspaces/current/endpoints/list")
@@ -285,7 +212,7 @@ class EndpointListApi(Resource):
@console_ns.doc(description="List plugin endpoints with pagination")
@console_ns.doc(params=query_params_from_model(EndpointListQuery))
@console_ns.response(
HTTPStatus.OK,
200,
"Success",
console_ns.models[EndpointListResponse.__name__],
)
@@ -297,14 +224,19 @@ class EndpointListApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListQuery.model_validate(request.args.to_dict(flat=True))
endpoints = EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=args.page,
page_size=args.page_size,
)
page = args.page
page_size = args.page_size
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=page,
page_size=page_size,
)
}
)
@console_ns.route("/workspaces/current/endpoints/list/plugin")
@@ -313,9 +245,9 @@ class EndpointListForSinglePluginApi(Resource):
@console_ns.doc(description="List endpoints for a specific plugin")
@console_ns.doc(params=query_params_from_model(EndpointListForPluginQuery))
@console_ns.response(
HTTPStatus.OK,
200,
"Success",
console_ns.models[EndpointListResponse.__name__],
console_ns.models[PluginEndpointListResponse.__name__],
)
@setup_required
@login_required
@@ -325,15 +257,21 @@ class EndpointListForSinglePluginApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True))
endpoints = EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=args.plugin_id,
page=args.page,
page_size=args.page_size,
)
page = args.page
page_size = args.page_size
plugin_id = args.plugin_id
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=plugin_id,
page=page,
page_size=page_size,
)
}
)
@console_ns.route("/workspaces/current/endpoints/<string:id>")
@@ -344,11 +282,11 @@ class EndpointItemApi(Resource):
@console_ns.doc(description="Delete a plugin endpoint")
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint deleted successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointDeleteResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -357,20 +295,18 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def delete(self, tenant_id: str, user_id: str, id: str):
return SuccessResponse(
success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint updated successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointUpdateResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -379,9 +315,7 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def patch(self, tenant_id: str, user_id: str, id: str):
return SuccessResponse(
success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
@console_ns.route("/workspaces/current/endpoints/delete")
@@ -398,11 +332,11 @@ class DeprecatedEndpointDeleteApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint deleted successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointDeleteResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -411,9 +345,8 @@ class DeprecatedEndpointDeleteApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/update")
@@ -430,11 +363,11 @@ class DeprecatedEndpointUpdateApi(Resource):
)
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint updated successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointUpdateResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -443,9 +376,8 @@ class DeprecatedEndpointUpdateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints/enable")
@@ -454,11 +386,11 @@ class EndpointEnableApi(Resource):
@console_ns.doc(description="Enable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint enabled successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointEnableResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -467,9 +399,13 @@ class EndpointEnableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=True)
).model_dump(mode="json")
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.enable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
@console_ns.route("/workspaces/current/endpoints/disable")
@@ -478,11 +414,11 @@ class EndpointDisableApi(Resource):
@console_ns.doc(description="Disable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
HTTPStatus.OK,
200,
"Endpoint disabled successfully",
console_ns.models[SuccessResponse.__name__],
console_ns.models[EndpointDisableResponse.__name__],
)
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@console_ns.response(403, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@@ -491,6 +427,10 @@ class EndpointDisableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=False)
).model_dump(mode="json")
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.disable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
+72 -73
View File
@@ -1,12 +1,10 @@
from http import HTTPStatus
from urllib import parse
from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound
import services
from configs import dify_config
@@ -32,8 +30,8 @@ from controllers.console.wraps import (
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
from libs.helper import dump_response, extract_remote_ip
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
@@ -47,11 +45,6 @@ class MemberInvitePayload(BaseModel):
role: str
language: str | None = None
@field_validator("emails")
@classmethod
def normalize_emails(cls, emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
class MemberRoleUpdatePayload(BaseModel):
role: str
@@ -77,17 +70,17 @@ class MemberInviteResultResponse(ResponseModel):
message: str | None = None
class MemberActionResponse(ResponseModel):
result: str
tenant_id: str
class MemberInviteResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
class MemberActionTenantResponse(ResponseModel):
result: str
tenant_id: str
register_enum_models(console_ns, TenantAccountRole)
register_schema_models(
console_ns,
@@ -99,14 +92,13 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
AccountWithRoleResponse,
AccountWithRoleListResponse,
MemberActionResponse,
MemberInviteResponse,
MemberInviteResultResponse,
AccountWithRole,
AccountWithRoleList,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
MemberInviteResponse,
MemberActionTenantResponse,
)
@@ -132,6 +124,10 @@ def _normalize_enum_value(value: object) -> str:
return str(normalized) if normalized is not None else ""
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
@@ -183,7 +179,7 @@ class MemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@with_current_user
def get(self, current_user: Account | None = None):
if current_user is None:
@@ -220,7 +216,9 @@ class MemberListApi(Resource):
}
)
return dump_response(AccountWithRoleListResponse, {"accounts": serialized_members}), HTTPStatus.OK
member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
@console_ns.route("/workspaces/current/members/invite-email")
@@ -228,7 +226,7 @@ class MemberInviteEmailApi(Resource):
"""Invite a new member by email."""
@console_ns.expect(console_ns.models[MemberInvitePayload.__name__])
@console_ns.response(HTTPStatus.CREATED, "Success", console_ns.models[MemberInviteResponse.__name__])
@console_ns.response(201, "Success", console_ns.models[MemberInviteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -237,26 +235,26 @@ class MemberInviteEmailApi(Resource):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = args.emails
invitee_emails = _normalize_invitee_emails(args.emails)
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
return {"code": "invalid-role", "message": "Invalid role"}, 400
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
return {"code": "invalid-role", "message": "Invalid role"}, 400
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
return {"code": "invalid-role", "message": "Invalid role"}, 400
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(inviter.current_tenant.id)
invitation_results: list[MemberInviteResultResponse] = []
invitation_results = []
console_web_url = dify_config.CONSOLE_WEB_URL
tenant_id = inviter.current_tenant.id
@@ -279,65 +277,63 @@ class MemberInviteEmailApi(Resource):
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
MemberInviteResultResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
)
{
"status": "success",
"email": invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
MemberInviteResultResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
)
{
"status": "already_member",
"email": invitee_email,
"message": "Account already in workspace.",
}
)
except Exception as e:
invitation_results.append(
MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e))
)
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
return MemberInviteResponse(
result="success",
invitation_results=invitation_results,
tenant_id=inviter.current_tenant.id if inviter.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.CREATED
return {
"result": "success",
"invitation_results": invitation_results,
"tenant_id": str(inviter.current_tenant.id) if inviter.current_tenant else "",
}, 201
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@console_ns.response(200, "Success", console_ns.models[MemberActionTenantResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
@with_current_user
def delete(self, current_user: Account, member_id: UUID):
if not current_user.current_tenant:
raise ValueError("No current tenant")
member = db.session.get(Account, str(member_id))
if member is None:
abort(HTTPStatus.NOT_FOUND)
abort(404)
else:
try:
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
return {"code": "forbidden", "message": str(e)}, 403
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
return {"code": "member-not-found", "message": str(e)}, 404
except Exception as e:
raise ValueError(str(e))
return MemberActionResponse(
result="success",
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.OK
return {
"result": "success",
"tenant_id": str(current_user.current_tenant.id) if current_user.current_tenant else "",
}, 200
@console_ns.route("/workspaces/current/members/<uuid:member_id>/update-role")
@@ -345,7 +341,7 @@ class MemberUpdateRoleApi(Resource):
"""Update member role."""
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -356,14 +352,14 @@ class MemberUpdateRoleApi(Resource):
new_role = args.role
if not TenantAccountRole.is_valid_role(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
return {"code": "invalid-role", "message": "Invalid role"}, 400
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role, current_user.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
return {"code": "invalid-role", "message": "Invalid role"}, 400
member = db.session.get(Account, str(member_id))
if not member:
abort(HTTPStatus.NOT_FOUND)
abort(404)
try:
assert member is not None, "Member not found"
@@ -371,17 +367,17 @@ class MemberUpdateRoleApi(Resource):
current_user.current_tenant, member, new_role, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
return {"code": "forbidden", "message": str(e)}, 403
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
return {"code": "member-not-found", "message": str(e)}, 404
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
return {"code": "role-already-assigned", "message": str(e)}, 400
except Exception as e:
raise ValueError(str(e))
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
@console_ns.route("/workspaces/current/dataset-operators")
@@ -391,13 +387,15 @@ class DatasetOperatorMemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@with_current_user
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session)
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
@console_ns.route("/workspaces/current/members/send-owner-transfer-confirm-email")
@@ -405,7 +403,7 @@ class SendOwnerTransferEmailApi(Resource):
"""Send owner transfer email."""
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -437,13 +435,13 @@ class SendOwnerTransferEmailApi(Resource):
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
return {"result": "success", "data": token}
@console_ns.route("/workspaces/current/members/owner-transfer-check")
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -482,13 +480,13 @@ class OwnerTransferCheckApi(Resource):
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -518,7 +516,8 @@ class OwnerTransfer(Resource):
member = db.session.get(Account, str(member_id))
if not member:
raise NotFound()
abort(404)
return # Never reached, but helps type checker
if not current_user.current_tenant:
raise ValueError("No current tenant")
@@ -547,4 +546,4 @@ class OwnerTransfer(Resource):
except Exception as e:
raise ValueError(str(e))
return SimpleResultResponse(result="success").model_dump(mode="json")
return {"result": "success"}
+28 -34
View File
@@ -42,15 +42,7 @@ from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import (
Account,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
)
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
@@ -60,9 +52,9 @@ from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeMode
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
@@ -135,8 +127,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@@ -158,14 +150,16 @@ class ParserDynamicOptionsWithCredentials(BaseModel):
class PluginPermissionSettingsPayload(BaseModel):
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
class PluginAutoUpgradeSettingsPayload(BaseModel):
strategy_setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting = (
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
)
upgrade_time_of_day: int = 0
upgrade_mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exclude_plugins: list[str] = Field(default_factory=list)
include_plugins: list[str] = Field(default_factory=list)
@@ -176,15 +170,15 @@ class PluginAutoUpgradeChangeResponse(ResponseModel):
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeMode
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeCategory
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
@@ -215,19 +209,19 @@ class PluginDeclarationResponse(ResponseModel):
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeCategory
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeCategory
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeCategory
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@@ -345,8 +339,8 @@ class PluginTaskResponse(ResponseModel):
class PluginPermissionResponse(ResponseModel):
install_permission: TenantPluginInstallPermission
debug_permission: TenantPluginDebugPermission
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
class PluginDynamicOptionsResponse(ResponseModel):
@@ -414,22 +408,22 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginDebugPermission,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginInstallPermission,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeCategory,
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": TenantPluginAutoUpgradeMode.EXCLUDE,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
@@ -993,8 +987,8 @@ class PluginFetchPermissionApi(Resource):
if not permission:
return jsonable_encoder(
{
"install_permission": TenantPluginInstallPermission.EVERYONE,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
)
+14 -16
View File
@@ -4,7 +4,7 @@ from typing import Any
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource
from flask_restx import Resource, marshal
from pydantic import Field as PydanticField
from pydantic import field_validator
from sqlalchemy.orm import Session, sessionmaker
@@ -37,7 +37,8 @@ from controllers.console.wraps import (
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from fields.snippet_fields import snippet_fields, snippet_list_fields
from libs.helper import to_timestamp
from libs.login import login_required
from models import Account
from models.snippet import SnippetType
@@ -197,16 +198,13 @@ class CustomizedSnippetsApi(Resource):
tag_ids=query.tag_ids,
)
return dump_response(
SnippetPaginationResponse,
{
"data": snippets,
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
},
), 200
return {
"data": marshal(snippets, snippet_list_fields),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
}, 200
@console_ns.doc("create_customized_snippet")
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
@@ -247,7 +245,7 @@ class CustomizedSnippetsApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return dump_response(SnippetResponse, snippet), 201
return marshal(snippet, snippet_fields), 201
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
@@ -270,7 +268,7 @@ class CustomizedSnippetDetailApi(Resource):
if not snippet:
raise NotFound("Snippet not found")
return dump_response(SnippetResponse, snippet), 200
return marshal(snippet, snippet_fields), 200
@console_ns.doc("update_customized_snippet")
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
@@ -319,7 +317,7 @@ class CustomizedSnippetDetailApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return dump_response(SnippetResponse, snippet), 200
return marshal(snippet, snippet_fields), 200
@console_ns.doc("delete_customized_snippet")
@console_ns.response(204, "Snippet deleted successfully")
@@ -535,4 +533,4 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
session.commit()
session.refresh(snippet)
return SnippetUseCountResponse(result="success", use_count=snippet.use_count).model_dump(mode="json"), 200
return {"result": "success", "use_count": snippet.use_count}, 200
+72 -58
View File
@@ -1,9 +1,8 @@
import logging
from datetime import datetime
from http import HTTPStatus
from flask import request
from flask_restx import Resource
from flask_restx import Resource, fields, marshal
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Unauthorized
@@ -17,12 +16,7 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
@@ -37,7 +31,7 @@ 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 dump_response, to_timestamp
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
@@ -139,7 +133,7 @@ class WorkspaceListItemResponse(ResponseModel):
@field_validator("status", mode="before")
@classmethod
def _normalize_enum_like(cls, value):
def _normalize_status(cls, value):
if value is None:
return None
if isinstance(value, str):
@@ -152,7 +146,7 @@ class WorkspaceListItemResponse(ResponseModel):
return to_timestamp(value)
class WorkspacePaginationResponse(ResponseModel):
class WorkspaceListResponse(ResponseModel):
data: list[WorkspaceListItemResponse]
has_more: bool
limit: int
@@ -165,7 +159,7 @@ class SwitchWorkspaceResponse(ResponseModel):
new_tenant: TenantInfoResponse
class WorkspaceTenantResultResponse(ResponseModel):
class WorkspaceMutationResponse(ResponseModel):
result: str
tenant: TenantInfoResponse
@@ -180,16 +174,6 @@ class WorkspacePermissionResponse(ResponseModel):
allow_owner_transfer: bool
WORKSPACE_LOGO_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Workspace web app logo file. Only SVG and PNG files are supported.",
}
}
register_schema_models(
console_ns,
WorkspaceListQuery,
@@ -200,21 +184,53 @@ register_schema_models(
register_response_schema_models(
console_ns,
TenantInfoResponse,
TenantListItemResponse,
TenantListResponse,
WorkspaceCustomConfigResponse,
WorkspaceListItemResponse,
WorkspacePaginationResponse,
WorkspaceListResponse,
SwitchWorkspaceResponse,
WorkspaceTenantResultResponse,
WorkspaceMutationResponse,
WorkspaceLogoUploadResponse,
WorkspaceCustomConfigResponse,
WorkspacePermissionResponse,
)
provider_fields = {
"provider_name": fields.String,
"provider_type": fields.String,
"is_valid": fields.Boolean,
"token_is_set": fields.Boolean,
}
tenant_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"role": fields.String,
"in_trial": fields.Boolean,
"trial_end_reason": fields.String,
"custom_config": fields.Raw(attribute="custom_config"),
"trial_credits": fields.Integer,
"trial_credits_used": fields.Integer,
"next_credit_reset_date": fields.Integer,
}
tenants_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
workspace_fields = {"id": fields.String, "name": fields.String, "status": fields.String, "created_at": TimestampField}
@console_ns.route("/workspaces")
class TenantListApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[TenantListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -265,17 +281,18 @@ class TenantListApi(Resource):
tenant_dicts.append(tenant_dict)
return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), HTTPStatus.OK
return {"workspaces": marshal(tenant_dicts, tenants_fields)}, 200
@console_ns.route("/all-workspaces")
class WorkspaceListApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceListQuery))
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePaginationResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceListResponse.__name__])
@setup_required
@admin_required
def get(self):
args = query_params_from_request(WorkspaceListQuery)
payload = request.args.to_dict(flat=True)
args = WorkspaceListQuery.model_validate(payload)
stmt = select(Tenant).order_by(Tenant.created_at.desc())
tenants = paginate_query(stmt, page=args.page, per_page=args.limit)
@@ -284,9 +301,13 @@ class WorkspaceListApi(Resource):
if tenants.has_next:
has_more = True
return WorkspacePaginationResponse(
data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0
).model_dump(mode="json"), HTTPStatus.OK
return {
"data": marshal(tenants.items, workspace_fields),
"has_more": has_more,
"limit": args.limit,
"page": args.page,
"total": tenants.total,
}, 200
@console_ns.route("/workspaces/current", endpoint="workspaces_current")
@@ -295,7 +316,7 @@ class TenantApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantInfoResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[TenantInfoResponse.__name__])
@with_current_user
def post(self, current_user: Account):
if request.path == "/info":
@@ -315,13 +336,13 @@ class TenantApi(Resource):
else:
raise Unauthorized("workspace is archived")
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), HTTPStatus.OK
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), 200
@console_ns.route("/workspaces/switch")
class SwitchWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -330,7 +351,7 @@ class SwitchWorkspaceApi(Resource):
payload = console_ns.payload or {}
args = SwitchWorkspacePayload.model_validate(payload)
# Check whether the tenant_id belongs to the current account.
# check if tenant_id is valid, 403 if not
try:
TenantService.switch_tenant(current_user, args.tenant_id, session=db.session)
except Exception:
@@ -340,15 +361,13 @@ class SwitchWorkspaceApi(Resource):
if new_tenant is None:
raise ValueError("Tenant not found")
return SwitchWorkspaceResponse(
result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant)
).model_dump(mode="json")
return {"result": "success", "new_tenant": marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)}
@console_ns.route("/workspaces/custom-config")
class CustomConfigWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -371,15 +390,12 @@ class CustomConfigWorkspaceApi(Resource):
tenant.custom_config_dict = custom_config_dict
db.session.commit()
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
@console_ns.route("/workspaces/custom-config/webapp-logo/upload")
class WebappLogoWorkspaceApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS)
@console_ns.response(HTTPStatus.CREATED, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@console_ns.response(201, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -415,13 +431,13 @@ class WebappLogoWorkspaceApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), HTTPStatus.CREATED
return {"id": upload_file.id}, 201
@console_ns.route("/workspaces/info")
class WorkspaceInfoApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -437,16 +453,14 @@ class WorkspaceInfoApi(Resource):
tenant.name = args.name
db.session.commit()
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
@console_ns.route("/workspaces/current/permission")
class WorkspacePermissionApi(Resource):
"""Get workspace permissions for the current workspace."""
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@@ -463,8 +477,8 @@ class WorkspacePermissionApi(Resource):
# Get workspace permissions from enterprise service
permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id)
return WorkspacePermissionResponse(
workspace_id=permission.workspace_id,
allow_member_invite=permission.allow_member_invite,
allow_owner_transfer=permission.allow_owner_transfer,
).model_dump(mode="json"), HTTPStatus.OK
return {
"workspace_id": permission.workspace_id,
"allow_member_invite": permission.allow_member_invite,
"allow_owner_transfer": permission.allow_owner_transfer,
}, 200
-1
View File
@@ -49,7 +49,6 @@ class OpenApiErrorCode(StrEnum):
# domain codes (must match the error_code attribute of the exception
# classes raised on the openapi surface)
APP_UNAVAILABLE = "app_unavailable"
AGENT_NOT_PUBLISHED = "agent_not_published"
CONVERSATION_COMPLETED = "conversation_completed"
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
+36 -8
View File
@@ -2,16 +2,19 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@@ -32,13 +35,38 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
try:
return get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
@@ -15,7 +15,6 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@@ -32,7 +31,6 @@ from controllers.service_api.schema import (
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -250,8 +248,6 @@ class CompletionApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -407,8 +403,6 @@ class ChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
-6
View File
@@ -7,12 +7,6 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the API."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."
@@ -1,10 +1,9 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from uuid import UUID
from flask import request
from pydantic import BaseModel, Field, RootModel, field_validator
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
@@ -14,20 +13,24 @@ from controllers.common.errors import FilenameNotExistsError, NoFileUploadedErro
from controllers.common.fields import GeneratedAppResponse
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_model,
register_schema_models,
)
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import PipelineRunError
from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
from controllers.service_api.schema import (
event_stream_response,
json_or_event_stream_response,
multipart_file_params,
)
from controllers.service_api.wraps import DatasetApiResource
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import current_user
from models import Account
from models.dataset import Dataset, Pipeline
@@ -81,7 +84,7 @@ class DatasourcePluginResponse(ResponseModel):
datasource_type: str | None = None
title: str | None = None
user_input_variables: list[dict[str, Any]] = Field(default_factory=list)
credentials: list[DatasourceCredentialInfoResponse] = Field(default_factory=list)
credentials: list[DatasourceCredentialInfoResponse]
class DatasourcePluginListResponse(RootModel[list[DatasourcePluginResponse]]):
@@ -97,22 +100,14 @@ class PipelineUploadFileResponse(ResponseModel):
created_by: str
created_at: str | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | str | None) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return value
register_schema_model(service_api_ns, DatasourceNodeRunPayload)
register_schema_model(service_api_ns, DatasourcePluginsQuery)
register_schema_model(service_api_ns, PipelineRunApiEntity)
register_schema_models(service_api_ns, DatasourcePluginsQuery)
register_response_schema_models(
service_api_ns,
DatasourceCredentialInfoResponse,
DatasourcePluginResponse,
DatasourcePluginListResponse,
GeneratedAppResponse,
PipelineUploadFileResponse,
)
@@ -124,8 +119,8 @@ class DatasourcePluginsApi(DatasetApiResource):
@service_api_ns.doc(
summary="List Datasource Plugins",
description=(
"List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses "
"plus the metadata needed to run it."
"List the datasource nodes configured in the knowledge pipeline. Each node includes the "
"plugin it uses plus the metadata needed to run it."
),
tags=["Knowledge Pipeline"],
responses={
@@ -157,13 +152,14 @@ class DatasourcePluginsApi(DatasetApiResource):
if not dataset:
raise NotFound("Dataset not found.")
query = query_params_from_request(DatasourcePluginsQuery)
# Get query parameter to determine published or draft
is_published: bool = request.args.get("is_published", default=True, type=bool)
rag_pipeline_service: RagPipelineService = RagPipelineService()
datasource_plugins: list[dict[Any, Any]] = rag_pipeline_service.get_datasource_plugins(
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=query.is_published
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=is_published
)
return dump_response(DatasourcePluginListResponse, datasource_plugins), 200
return datasource_plugins, 200
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
@@ -173,8 +169,8 @@ class DatasourceNodeRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Datasource Node",
description=(
"Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the "
"node execution results."
"Execute a single datasource node within the knowledge pipeline. Returns a streaming "
"response with the node execution results."
),
tags=["Knowledge Pipeline"],
responses={
@@ -193,6 +189,11 @@ class DatasourceNodeRunApi(DatasetApiResource):
}
)
@service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__])
@service_api_ns.response(
200,
"Datasource node run successfully",
service_api_ns.models[GeneratedAppResponse.__name__],
)
def post(self, tenant_id: str, dataset_id: UUID, node_id: str):
"""Resource for getting datasource plugins."""
dataset_id_str = str(dataset_id)
@@ -209,11 +210,10 @@ class DatasourceNodeRunApi(DatasetApiResource):
datasource_node_run_api_entity = DatasourceNodeRunApiEntity.model_validate(
{
**payload.model_dump(exclude_none=True),
"pipeline_id": pipeline.id,
"pipeline_id": str(pipeline.id),
"node_id": node_id,
}
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(
PipelineGenerator.convert_to_event_stream(
rag_pipeline_service.run_datasource_workflow_node(
@@ -236,8 +236,8 @@ class PipelineRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Pipeline",
description=(
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response "
"modes."
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and "
"blocking response modes."
),
tags=["Knowledge Pipeline"],
responses={
@@ -293,7 +293,6 @@ class PipelineRunApi(DatasetApiResource):
streaming=payload.response_mode == "streaming",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except Exception as ex:
raise PipelineRunError(description=str(ex))
@@ -369,4 +368,4 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return dump_response(PipelineUploadFileResponse, upload_file), 201
return serialize_upload_file(upload_file), 201
@@ -0,0 +1,32 @@
"""
Serialization helpers for Service API knowledge pipeline endpoints.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from models.model import UploadFile
class UploadFileDict(TypedDict):
id: str
name: str
size: int
extension: str
mime_type: str | None
created_by: str
created_at: str | None
def serialize_upload_file(upload_file: UploadFile) -> UploadFileDict:
return {
"id": upload_file.id,
"name": upload_file.name,
"size": upload_file.size,
"extension": upload_file.extension,
"mime_type": upload_file.mime_type,
"created_by": upload_file.created_by,
"created_at": upload_file.created_at.isoformat() if upload_file.created_at else None,
}
+3 -14
View File
@@ -8,10 +8,8 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, AppMode, EndUser
@@ -21,7 +19,7 @@ from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService
from . import web_ns
from .error import AgentNotPublishedError, AppUnavailableError
from .error import AppUnavailableError
from .wraps import WebApiResource
logger = logging.getLogger(__name__)
@@ -76,21 +74,12 @@ class AppParameterApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app parameters."""
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
try:
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
raise AppUnavailableError()
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict = workflow.features_dict
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
-6
View File
@@ -11,7 +11,6 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@@ -23,7 +22,6 @@ from controllers.web.error import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from controllers.web.wraps import WebApiResource
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -140,8 +138,6 @@ class CompletionApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -239,8 +235,6 @@ class ChatApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
-6
View File
@@ -7,12 +7,6 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the web app."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."
+18 -16
View File
@@ -9,7 +9,6 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from controllers.common.fields import (
AccessTokenData,
AccessTokenResultResponse,
LoginStatusResponse,
SimpleResultDataResponse,
@@ -116,10 +115,9 @@ class LoginApi(Resource):
raise AuthenticationFailedError()
token = WebAppAuthService.login(account=account)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
return response
# this api helps frontend to check whether user is authenticated
@@ -138,12 +136,14 @@ class LoginStatusApi(Resource):
)
@web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__])
def get(self):
query = LoginStatusQuery.model_validate(request.args.to_dict(flat=True))
app_code = query.app_code
user_id = query.user_id
app_code = request.args.get("app_code")
user_id = request.args.get("user_id")
token = extract_webapp_access_token(request)
if not app_code:
return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json")
return {
"logged_in": bool(token),
"app_logged_in": False,
}
app_id = AppService.get_app_id_by_code(app_code)
is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check(
app_id=app_id
@@ -165,7 +165,10 @@ class LoginStatusApi(Resource):
except Exception:
app_logged_in = False
return LoginStatusResponse(logged_in=user_logged_in, app_logged_in=app_logged_in).model_dump(mode="json")
return {
"logged_in": user_logged_in,
"app_logged_in": app_logged_in,
}
@web_ns.route("/logout")
@@ -180,8 +183,7 @@ class LogoutApi(Resource):
)
@web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__])
def post(self):
# response-contract:ignore hand-crafted response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
response = make_response({"result": "success"})
# enterprise SSO sets same site to None in https deployment
# so we need to logout by calling api
clear_webapp_access_token_from_cookie(response, samesite="None")
@@ -214,8 +216,9 @@ class EmailCodeLoginSendEmailApi(Resource):
account = WebAppAuthService.get_user_through_email(payload.email)
if account is None:
raise AuthenticationFailedError()
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
else:
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
@web_ns.route("/email-code-login/validity")
@@ -274,10 +277,9 @@ class EmailCodeLoginApi(Resource):
token = WebAppAuthService.login(account=account)
AccountService.reset_login_error_rate_limit(user_email)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
return response
def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None:
+24 -19
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from flask import request
from flask import make_response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import func, select
@@ -10,12 +10,11 @@ from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from constants import HEADER_NAME_APP_CODE
from controllers.common.fields import AccessTokenData
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_access_token
from models.enums import EndUserType
@@ -29,13 +28,7 @@ class PassportQuery(BaseModel):
register_schema_models(web_ns, PassportQuery)
class PassportAccessTokenResponse(ResponseModel):
access_token: str
register_response_schema_models(web_ns, PassportAccessTokenResponse)
register_response_schema_models(web_ns, AccessTokenData)
@web_ns.route("/passport")
@@ -52,7 +45,7 @@ class PassportResource(Resource):
404: "Application or user not found",
}
)
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__])
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[AccessTokenData.__name__])
def get(self):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
@@ -66,11 +59,8 @@ class PassportResource(Resource):
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return dump_response(
PassportAccessTokenResponse,
exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
),
return exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
)
# get site from db and check if it is normal
@@ -120,7 +110,12 @@ class PassportResource(Resource):
tk = PassportService().issue(payload)
return dump_response(PassportAccessTokenResponse, {"access_token": tk})
response = make_response(
{
"access_token": tk,
}
)
return response
def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None:
@@ -211,7 +206,12 @@ def exchange_token_for_existing_web_user(
"exp": exp,
}
token: str = PassportService().issue(payload)
return {"access_token": token}
resp = make_response(
{
"access_token": token,
}
)
return resp
def _exchange_for_public_app_token(app_model, site, token_decoded):
@@ -244,7 +244,12 @@ def _exchange_for_public_app_token(app_model, site, token_decoded):
tk = PassportService().issue(payload)
return {"access_token": tk}
resp = make_response(
{
"access_token": tk,
}
)
return resp
def generate_session_id():
-1
View File
@@ -133,7 +133,6 @@ class CotAgentRunner(BaseAgentRunner, ABC):
stop=app_generate_entity.model_conf.stop,
stream=True,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
)
usage_dict: dict[str, LLMUsage | None] = {}
-1
View File
@@ -101,7 +101,6 @@ class FunctionCallAgentRunner(BaseAgentRunner):
stop=app_generate_entity.model_conf.stop,
stream=self.stream_tool_call,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
)
tool_calls: list[tuple[str, str, dict[str, Any]]] = []
@@ -61,17 +61,10 @@ _sensitive_word_avoidance_adapter: TypeAdapter[SensitiveWordAvoidanceConfig] = T
def _normalize_raw(raw: Any) -> Any:
if isinstance(raw, dict):
enabled = raw.get("enabled")
if enabled is None:
if raw.get("enabled") is None:
raw = {**raw, "enabled": False}
elif enabled is True:
if raw.get("config") is None:
raw = {**raw, "config": {}}
else:
# enabled is False or any falsy value —
# drop extra fields (type, config) so they don't
# violate SensitiveWordAvoidanceDisabledConfig.extra="forbid"
raw = {"enabled": False}
elif raw.get("enabled") is True and raw.get("config") is None:
raw = {**raw, "config": {}}
return raw
@@ -3,9 +3,9 @@
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
prompt) plus app-level feature flags from Agent Soul, while preserving any
legacy ``app_model_config`` feature flags when present. Then we reuse the same
sub-managers the chat app type uses.
prompt) plus any app-level feature flags (opening statement, follow-up, …)
stored on ``app_model_config`` when present, then reuse the same sub-managers
the chat app type uses.
"""
from typing import Any, cast
@@ -21,7 +21,6 @@ from core.app.app_config.entities import (
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
@@ -80,11 +79,12 @@ class AgentAppConfigManager(BaseAppConfigManager):
) -> dict[str, Any]:
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
Feature flags come from Agent Soul and fill gaps in the legacy
``app_model_config`` when one exists; model + prompt always come from
Feature flags (opening statement / follow-up / tts / stt / citations /
moderation / annotation) come from ``app_model_config`` when present
(Q3: stored there), otherwise defaults; model + prompt always come from
the Agent Soul (the single source of truth for those).
"""
base = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
base: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
model = agent_soul.model
if model is not None:
@@ -1,23 +0,0 @@
from typing import Any
from models.agent_config_entities import AgentSoulConfig
def merge_agent_app_features(
*,
agent_soul: AgentSoulConfig,
app_model_config: Any | None,
) -> dict[str, Any]:
"""Project public Agent App features from legacy config plus Agent Soul.
The hidden backing app may still carry legacy presentation fields such as
opening statements. Agent Soul is the source of truth for Agent-owned
features like file upload, so Soul fields override same-named legacy keys.
"""
features: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True)
features.update(soul_features)
return features
__all__ = ["merge_agent_app_features"]
+5 -4
View File
@@ -32,7 +32,6 @@ from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
@@ -65,6 +64,10 @@ from services.conversation_service import ConversationService
logger = logging.getLogger(__name__)
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str:
"""Append raw request file references to the backend user prompt."""
if not prompt_file_mappings:
@@ -611,8 +614,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
)
return agent, draft.id, config_version_kind, agent_soul
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
raise AgentAppNotPublishedError("Agent has not been published")
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
@@ -708,4 +709,4 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return agent, draft, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError", "AgentAppNotPublishedError"]
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
+5 -5
View File
@@ -372,14 +372,14 @@ class _AgentProcessRecorder:
row = MessageAgentThought(
message_id=self._message_id,
message_chain_id=None,
thought=thought or "",
tool=tool or "",
thought=thought,
tool=tool,
tool_labels_str=_tool_labels(tool),
tool_meta_str="{}",
tool_input=tool_input or "",
observation="",
tool_input=tool_input,
observation=None,
tool_process_data=None,
message="",
message=None,
message_token=0,
message_unit_price=Decimal(0),
message_price_unit=Decimal("0.001"),
-6
View File
@@ -1,6 +0,0 @@
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
class AgentAppNotPublishedError(AgentAppGeneratorError):
"""Raised when a public Agent App runtime is requested before publish."""
+4 -7
View File
@@ -5,8 +5,6 @@ from collections.abc import Generator, Mapping, Sequence
from mimetypes import guess_extension
from typing import TYPE_CHECKING, Any, Union
from sqlalchemy.orm import sessionmaker
from core.app.app_config.entities import ExternalDataVariableEntity, PromptTemplateEntity
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
@@ -425,9 +423,7 @@ class AppRunner:
_logger.exception("Failed to save image file")
return
# Create MessageFile record.
# Use an independent session so this side-effect write does not
# commit or close the caller's request-scoped session.
# Create MessageFile record
message_file = MessageFile(
message_id=message_id,
type=FileType.IMAGE,
@@ -441,8 +437,9 @@ class AppRunner:
created_by=user_id,
)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(message_file)
db.session.add(message_file)
db.session.commit()
db.session.refresh(message_file)
# Publish QueueMessageFileEvent
queue_manager.publish(
-1
View File
@@ -232,7 +232,6 @@ class ChatAppRunner(AppRunner):
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
)
# handle invoke result
@@ -193,7 +193,6 @@ class CompletionAppRunner(AppRunner):
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
)
# handle invoke result
+2 -2
View File
@@ -122,7 +122,7 @@ class MessageStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE
id: str
answer: str
from_variable_selector: list[str] = Field(default_factory=list)
from_variable_selector: list[str] | None = None
class MessageAudioStreamResponse(StreamResponse):
@@ -151,7 +151,7 @@ class MessageEndStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE_END
id: str
metadata: Mapping[str, object] = Field(default_factory=dict)
files: Sequence[Mapping[str, Any]] = Field(default_factory=list)
files: Sequence[Mapping[str, Any]] | None = None
class MessageFileStreamResponse(StreamResponse):
@@ -1,6 +1,6 @@
import logging
import time
from collections.abc import Generator, Mapping, Sequence
from collections.abc import Generator
from threading import Thread
from typing import Any, cast
@@ -44,7 +44,7 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.app.task_pipeline.message_file_utils import MessageFileInfoDict, prepare_file_dict
from core.app.task_pipeline.message_file_utils import prepare_file_dict
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.model_manager import ModelInstance
from core.ops.entities.trace_entity import TraceTaskName
@@ -466,10 +466,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
:return:
"""
self._task_state.metadata.usage = self._task_state.llm_result.usage
metadata_dict = self._task_state.metadata.model_dump(exclude_none=True)
metadata_dict = self._task_state.metadata.model_dump()
# Fetch files associated with this message
files: list[MessageFileInfoDict] = []
files = None
with Session(db.engine, expire_on_commit=False) as session:
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
@@ -492,13 +492,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
file_dict = prepare_file_dict(message_file, upload_files_map)
files_list.append(file_dict)
files = files_list
files = files_list or None
return MessageEndStreamResponse(
task_id=self._application_generate_entity.task_id,
id=self._message_id,
metadata=metadata_dict,
files=cast(Sequence[Mapping[str, Any]], files),
files=files,
)
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
@@ -528,11 +528,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
task_id=self._application_generate_entity.task_id,
id=agent_thought.id,
position=agent_thought.position,
thought=agent_thought.thought or "",
observation=agent_thought.observation or "",
tool=agent_thought.tool or "",
thought=agent_thought.thought,
observation=agent_thought.observation,
tool=agent_thought.tool,
tool_labels=agent_thought.tool_labels,
tool_input=agent_thought.tool_input or "",
tool_input=agent_thought.tool_input,
message_files=agent_thought.files,
)
@@ -257,7 +257,7 @@ class MessageCycleManager:
task_id=self._application_generate_entity.task_id,
id=message_id,
answer=answer,
from_variable_selector=from_variable_selector or [],
from_variable_selector=from_variable_selector,
event=event_type or StreamEvent.MESSAGE,
)
@@ -2,7 +2,7 @@ import logging
from collections.abc import Sequence
from sqlalchemy import select, update
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from sqlalchemy.orm import scoped_session
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -10,7 +10,6 @@ from core.app.entities.queue_entities import QueueRetrieverResourcesEvent
from core.rag.entities import RetrievalSourceMetadata
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.models.document import Document
from extensions.ext_database import db
from models.dataset import ChildChunk, DatasetQuery, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DatasetQuerySource
@@ -47,52 +46,47 @@ class DatasetIndexToolCallbackHandler:
created_by=self._user_id,
)
# Use an independent session so this audit-log side effect does
# not commit or close the caller's request-scoped session.
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as independent_session:
independent_session.add(dataset_query)
session.add(dataset_query)
session.commit()
def on_tool_end(self, documents: list[Document], session: scoped_session):
"""Handle tool end."""
# Use an independent session so hit-count updates do not
# interfere with the caller's request-scoped session.
with Session(db.engine, expire_on_commit=False) as independent_session:
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = independent_session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = session.scalar(child_chunk_stmt)
if child_chunk:
session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
)
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = independent_session.scalar(child_chunk_stmt)
if child_chunk:
independent_session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
# add hit count to document segment
independent_session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
# add hit count to document segment
session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
independent_session.commit()
session.commit()
# TODO(-LAN-): Improve type check
def return_retriever_resource_info(self, resource: Sequence[RetrievalSourceMetadata]):
@@ -3,7 +3,7 @@ from typing import Any
from core.datasource.__base.datasource_plugin import DatasourcePlugin
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin, DatasourceProviderType
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.entities.provider_entities import ProviderConfig
from core.plugin.impl.tool import PluginToolManager
from core.tools.errors import ToolProviderCredentialValidationError
@@ -78,11 +78,11 @@ class DatasourcePluginProviderController(ABC):
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfigType.SELECT:
elif credential_schema.type == ProviderConfig.Type.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@@ -107,9 +107,9 @@ class DatasourcePluginProviderController(ABC):
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
}:
default_value = str(default_value)
+2 -2
View File
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from pydantic import BaseModel
from configs import dify_config
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.entities.provider_entities import BasicProviderConfig
from core.helper import encrypter
from core.helper.provider_cache import NoOpProviderCredentialCache
from core.mcp.types import OAuthClientInformation, OAuthClientMetadata, OAuthTokens
@@ -315,7 +315,7 @@ class MCPProviderEntity(BaseModel):
return data
# Create dynamic config only for encrypted fields
config = [BasicProviderConfig(type=ProviderConfigType.SECRET_INPUT, name=key) for key in encrypted_fields]
config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in encrypted_fields]
encrypter_instance, _ = create_provider_encrypter(
tenant_id=self.tenant_id,
+23 -24
View File
@@ -165,35 +165,34 @@ class ModelSettings(BaseModel):
model_config = ConfigDict(protected_namespaces=())
class ProviderConfigType(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfigType:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
class BasicProviderConfig(BaseModel):
"""
Base model class for common provider settings like credentials
"""
type: ProviderConfigType = Field(..., description="The type of the credentials")
class Type(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfig.Type:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
type: Type = Field(..., description="The type of the credentials")
name: str = Field(..., description="The name of the credentials")
+4 -4
View File
@@ -3,7 +3,7 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any, Protocol
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.entities.provider_entities import BasicProviderConfig
from core.helper import encrypter
@@ -60,7 +60,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == ProviderConfigType.SECRET_INPUT:
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field_name in data:
encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "")
data[field_name] = encrypted
@@ -81,7 +81,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == ProviderConfigType.SECRET_INPUT:
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field_name in data:
if len(data[field_name]) > 6:
data[field_name] = (
@@ -112,7 +112,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == ProviderConfigType.SECRET_INPUT:
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field_name in data:
with contextlib.suppress(Exception):
# if the value is None or empty string, skip decrypt
-6
View File
@@ -124,7 +124,6 @@ class ModelInstance:
stop: list[str] | None = None,
stream: Literal[True] = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Generator: ...
@overload
@@ -136,7 +135,6 @@ class ModelInstance:
stop: list[str] | None = None,
stream: Literal[False] = False,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult: ...
@overload
@@ -148,7 +146,6 @@ class ModelInstance:
stop: list[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Union[LLMResult, Generator]: ...
def invoke_llm(
@@ -159,7 +156,6 @@ class ModelInstance:
stop: Sequence[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Union[LLMResult, Generator]:
"""
Invoke large language model
@@ -170,7 +166,6 @@ class ModelInstance:
:param stop: stop words
:param stream: is stream response
:param callbacks: callbacks
:param request_metadata: optional request metadata
:return: full response or stream response chunk generator result
"""
if not isinstance(self.model_type_instance, LargeLanguageModel):
@@ -187,7 +182,6 @@ class ModelInstance:
stop=list(stop) if stop else None,
stream=stream,
callbacks=callbacks,
request_metadata=request_metadata,
),
)
+1 -12
View File
@@ -78,11 +78,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
if not user_id:
user = EndUserService.get_or_create_end_user(app)
else:
try:
user = cls._get_user(user_id, app)
except ValueError:
# Plugins such as WeCom Bot pass external sender IDs rather than EndUser UUIDs.
user = EndUserService.get_or_create_end_user(app, user_id=user_id)
user = cls._get_user(user_id, app)
conversation_id = conversation_id or ""
@@ -236,13 +232,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
EndUser.app_id == app.id,
)
user = session.scalar(stmt)
if not user:
stmt = select(EndUser).where(
EndUser.session_id == user_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.id,
)
user = session.scalar(stmt)
if not user:
stmt = select(Account).where(
Account.id == user_id,
+6 -7
View File
@@ -5,13 +5,12 @@ from pydantic import BaseModel
from core.plugin.entities.plugin import PluginDeclaration, PluginInstallationSource
class PluginBundleDependencyType(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class PluginBundleDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class Github(BaseModel):
repo_address: str
repo: str
@@ -27,5 +26,5 @@ class PluginBundleDependency(BaseModel):
unique_identifier: str
manifest: PluginDeclaration
type: PluginBundleDependencyType
type: Type
value: Github | Marketplace | Package
-1
View File
@@ -32,7 +32,6 @@ class EndpointEntity(BasePluginEntity):
entity of an endpoint
"""
# TODO: Confirm daemon masks secret-input settings before endpoint list responses expose them.
settings: dict[str, Any]
tenant_id: str
plugin_id: str
+4 -5
View File
@@ -57,12 +57,11 @@ class MCPServerParameterType(StrEnum):
OBJECT = auto()
class PluginParameterAutoGenerateType(StrEnum):
PROMPT_INSTRUCTION = auto()
class PluginParameterAutoGenerate(BaseModel):
type: PluginParameterAutoGenerateType
class Type(StrEnum):
PROMPT_INSTRUCTION = auto()
type: Type
class PluginParameterTemplate(BaseModel):
+6 -7
View File
@@ -166,13 +166,12 @@ class PluginEntity(PluginInstallation):
return self
class PluginDependencyType(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class PluginDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class Github(BaseModel):
repo: str
version: str
@@ -195,7 +194,7 @@ class PluginDependency(BaseModel):
plugin_unique_identifier: str
version: str | None = None
type: PluginDependencyType
type: Type
value: Github | Marketplace | Package
current_identifier: str | None = None
+1 -5
View File
@@ -27,12 +27,10 @@ _POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling"
class PluginModelClient(BasePluginClient):
@staticmethod
def _dispatch_payload(*, user_id: str | None, data: dict[str, Any], app_id: str | None = None) -> dict[str, Any]:
def _dispatch_payload(*, user_id: str | None, data: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {"data": data}
if user_id is not None:
payload["user_id"] = user_id
if app_id is not None:
payload["app_id"] = app_id
return payload
def fetch_model_providers(self, tenant_id: str) -> Sequence[PluginModelProviderEntity]:
@@ -168,7 +166,6 @@ class PluginModelClient(BasePluginClient):
tools: list[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: bool = True,
app_id: str | None = None,
) -> Generator[LLMResultChunk, None, None]:
"""
Invoke llm
@@ -191,7 +188,6 @@ class PluginModelClient(BasePluginClient):
"stop": stop,
"stream": stream,
},
app_id=app_id,
)
),
headers={
+14 -32
View File
@@ -317,39 +317,21 @@ class PluginModelRuntime(ModelRuntime):
stream: bool,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator[LLMResultChunk, None, None]:
app_id = request_metadata.get("app_id") if request_metadata else None
if not isinstance(app_id, str):
app_id = None
del request_metadata
plugin_id, provider_name = self._split_provider(provider)
if app_id is None:
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
)
else:
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
app_id=app_id,
)
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
)
if stream:
return result
+6 -6
View File
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Any
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.entities.provider_entities import ProviderConfig
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import (
ToolProviderEntity,
@@ -71,11 +71,11 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfigType.SELECT:
elif credential_schema.type == ProviderConfig.Type.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@@ -100,9 +100,9 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
}:
default_value = str(default_value)
+7 -7
View File
@@ -5,7 +5,7 @@ from typing import override
from pydantic import Field
from sqlalchemy import select
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.entities.provider_entities import ProviderConfig
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.custom_tool.tool import ApiTool
@@ -41,7 +41,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
ProviderConfig(
name="auth_type",
required=True,
type=ProviderConfigType.SELECT,
type=ProviderConfig.Type.SELECT,
options=[
ProviderConfig.Option(value="none", label=I18nObject(en_US="None", zh_Hans="")),
ProviderConfig.Option(value="api_key_header", label=I18nObject(en_US="Header", zh_Hans="请求头")),
@@ -60,20 +60,20 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
name="api_key_header",
required=False,
default="Authorization",
type=ProviderConfigType.TEXT_INPUT,
type=ProviderConfig.Type.TEXT_INPUT,
help=I18nObject(en_US="The header name of the api key", zh_Hans="携带 api key 的 header 名称"),
),
ProviderConfig(
name="api_key_value",
required=True,
type=ProviderConfigType.SECRET_INPUT,
type=ProviderConfig.Type.SECRET_INPUT,
help=I18nObject(en_US="The api key", zh_Hans="api key 的值"),
),
ProviderConfig(
name="api_key_header_prefix",
required=False,
default="basic",
type=ProviderConfigType.SELECT,
type=ProviderConfig.Type.SELECT,
help=I18nObject(en_US="The prefix of the api key header", zh_Hans="api key header 的前缀"),
options=[
ProviderConfig.Option(value="basic", label=I18nObject(en_US="Basic", zh_Hans="Basic")),
@@ -89,7 +89,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
name="api_key_query_param",
required=False,
default="key",
type=ProviderConfigType.TEXT_INPUT,
type=ProviderConfig.Type.TEXT_INPUT,
help=I18nObject(
en_US="The query parameter name of the api key", zh_Hans="携带 api key 的查询参数名称"
),
@@ -97,7 +97,7 @@ class ApiToolProviderController(ToolProviderController[ToolProviderEntity, ApiTo
ProviderConfig(
name="api_key_value",
required=True,
type=ProviderConfigType.SECRET_INPUT,
type=ProviderConfig.Type.SECRET_INPUT,
help=I18nObject(en_US="The api key", zh_Hans="api key 的值"),
),
]
+2 -2
View File
@@ -1,7 +1,7 @@
from collections.abc import Mapping
from typing import Union, override
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.entities.provider_entities import BasicProviderConfig, ProviderConfig
from core.helper.provider_cache import ProviderCredentialsCache
from core.helper.provider_encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter
from core.plugin.entities.plugin_daemon import CredentialType
@@ -142,7 +142,7 @@ def masked_credentials(
if not config:
masked_credentials[key] = value
continue
if config.type == ProviderConfigType.SECRET_INPUT:
if config.type == BasicProviderConfig.Type.SECRET_INPUT:
if len(value) <= 4:
masked_credentials[key] = "*" * len(value)
else:
+3 -8
View File
@@ -549,11 +549,7 @@ class DifyNodeFactory(NodeFactory):
"credentials_provider": self._llm_credentials_provider,
"model_factory": self._llm_model_factory,
"model_instance": (
self._wrap_model_instance_for_node(
node_data=validated_node_data,
model_instance=model_instance,
request_metadata={"app_id": self._dify_context.app_id},
)
self._wrap_model_instance_for_node(node_data=validated_node_data, model_instance=model_instance)
if wrap_model_instance
else model_instance
),
@@ -585,14 +581,13 @@ class DifyNodeFactory(NodeFactory):
*,
node_data: LLMCompatibleNodeData,
model_instance: ModelInstance,
request_metadata: Mapping[str, object] | None = None,
) -> DifyPreparedLLM:
# Only graphon's LLM node consumes the polling protocol. Keep classifier
# and extractor nodes on the existing wrapper even if the same model
# advertises polling support.
if node_data.type == BuiltinNodeTypes.LLM and DifyNodeFactory._supports_plugin_llm_polling(model_instance):
return DifyPreparedPollingLLM(model_instance, request_metadata=request_metadata)
return DifyPreparedLLM(model_instance, request_metadata=request_metadata)
return DifyPreparedPollingLLM(model_instance)
return DifyPreparedLLM(model_instance)
@staticmethod
def _supports_plugin_llm_polling(model_instance: ModelInstance) -> bool:
+3 -5
View File
@@ -150,9 +150,8 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol):
class DifyPreparedLLM(LLMProtocol):
"""Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes."""
def __init__(self, model_instance: ModelInstance, request_metadata: Mapping[str, object] | None = None) -> None:
def __init__(self, model_instance: ModelInstance) -> None:
self._model_instance = model_instance
self._request_metadata = request_metadata
@property
@override
@@ -231,7 +230,6 @@ class DifyPreparedLLM(LLMProtocol):
tools=list(tools or []),
stop=list(stop or []),
stream=stream,
request_metadata=self._request_metadata,
)
@overload
@@ -285,10 +283,10 @@ class DifyPreparedLLM(LLMProtocol):
class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol):
"""Prepared workflow LLM adapter that exposes Graphon's polling protocol."""
def __init__(self, model_instance: ModelInstance, request_metadata: Mapping[str, object] | None = None) -> None:
def __init__(self, model_instance: ModelInstance) -> None:
from core.plugin.impl.model_runtime import PluginModelRuntime
super().__init__(model_instance, request_metadata=request_metadata)
super().__init__(model_instance)
model_type_instance = model_instance.model_type_instance
if not isinstance(model_type_instance, LargeLanguageModel):
raise TypeError("Polling wrapper requires a large-language-model instance.")
@@ -48,7 +48,7 @@ from clients.agent_backend import (
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
from core.workflow.system_variables import SystemVariableKey, get_system_text
from graphon.file import File, FileTransferMethod
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
@@ -354,22 +354,17 @@ class WorkflowAgentRuntimeRequestBuilder:
) -> str:
lines: list[str] = []
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
uploaded_files = self._summarize_uploaded_workflow_files(context.variable_pool)
resolved_outputs = self._resolve_previous_node_outputs(
context.variable_pool,
node_job.previous_node_output_refs,
)
if not query and uploaded_files is None and not resolved_outputs:
if not query and not resolved_outputs:
return ""
lines.append("Workflow context loaded for this run:")
if query:
lines.append(f"- User query: {query}")
if uploaded_files is not None:
lines.append("- Uploaded workflow files:")
lines.append(f" - sys.files: {uploaded_files}")
if resolved_outputs:
lines.append("- Previous node outputs:")
for item in resolved_outputs:
@@ -378,14 +373,6 @@ class WorkflowAgentRuntimeRequestBuilder:
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
return "\n".join(lines)
def _summarize_uploaded_workflow_files(self, variable_pool: VariablePoolReader) -> str | None:
files = get_system_value(variable_pool, SystemVariableKey.FILES)
if files is None:
return None
if isinstance(files, list | tuple) and not files:
return None
return self._summarize_value(files)
def _build_workflow_task_prompt(
self,
context: WorkflowAgentRuntimeBuildContext,
+271
View File
@@ -0,0 +1,271 @@
import json
from typing import override
from flask_restx import fields
from fields.workflow_fields import workflow_partial_fields
from libs.helper import AppIconUrlField, TimestampField
class JsonStringField(fields.Raw):
@override
def format(self, value):
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
return value
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class StringListRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "array", "items": {"type": "string"}}
class ObjectListRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "array", "items": {"type": "object"}}
app_detail_kernel_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
}
related_app_list = {
"data": fields.List(fields.Nested(app_detail_kernel_fields)),
"total": fields.Integer,
}
model_config_fields = {
"opening_statement": fields.String,
"suggested_questions": StringListRawField(attribute="suggested_questions_list"),
"suggested_questions_after_answer": OpaqueRawField(attribute="suggested_questions_after_answer_dict"),
"speech_to_text": OpaqueRawField(attribute="speech_to_text_dict"),
"text_to_speech": OpaqueRawField(attribute="text_to_speech_dict"),
"retriever_resource": OpaqueRawField(attribute="retriever_resource_dict"),
"annotation_reply": OpaqueRawField(attribute="annotation_reply_dict"),
"more_like_this": OpaqueRawField(attribute="more_like_this_dict"),
"sensitive_word_avoidance": OpaqueRawField(attribute="sensitive_word_avoidance_dict"),
"external_data_tools": ObjectListRawField(attribute="external_data_tools_list"),
"model": OpaqueRawField(attribute="model_dict"),
"user_input_form": ObjectListRawField(attribute="user_input_form_list"),
"dataset_query_variable": fields.String,
"pre_prompt": fields.String,
"agent_mode": OpaqueRawField(attribute="agent_mode_dict"),
"prompt_type": fields.String,
"chat_prompt_config": OpaqueRawField(attribute="chat_prompt_config_dict"),
"completion_prompt_config": OpaqueRawField(attribute="completion_prompt_config_dict"),
"dataset_configs": OpaqueRawField(attribute="dataset_configs_dict"),
"file_upload": OpaqueRawField(attribute="file_upload_dict"),
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
app_detail_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon": fields.String,
"icon_background": fields.String,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"tracing": OpaqueRawField,
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String()),
}
prompt_config_fields = {
"prompt_template": fields.String,
}
model_config_partial_fields = {
"model": OpaqueRawField(attribute="model_dict"),
"pre_prompt": fields.String,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
app_partial_fields = {
"id": fields.String,
"name": fields.String,
"max_active_requests": OpaqueRawField(),
"description": fields.String(attribute="desc_or_prompt"),
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"model_config": fields.Nested(model_config_partial_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_fields)),
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
"has_draft_trigger": fields.Boolean,
"permission_keys": fields.List(fields.String()),
}
app_pagination_fields = {
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(app_partial_fields), attribute="items"),
}
template_fields = {
"name": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"description": fields.String,
"mode": fields.String,
"model_config": fields.Nested(model_config_fields),
}
template_list_fields = {
"data": fields.List(fields.Nested(template_fields)),
}
site_fields = {
"access_token": fields.String(attribute="code"),
"code": fields.String,
"title": fields.String,
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"description": fields.String,
"default_language": fields.String,
"chat_color_theme": fields.String,
"chat_color_theme_inverted": fields.Boolean,
"customize_domain": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"custom_disclaimer": fields.String,
"customize_token_strategy": fields.String,
"prompt_public": fields.Boolean,
"app_base_url": fields.String,
"show_workflow_steps": fields.Boolean,
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
deleted_tool_fields = {
"type": fields.String,
"tool_name": fields.String,
"provider_id": fields.String,
}
app_detail_fields_with_site = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_fields, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_fields, allow_null=True),
"api_base_url": fields.String,
"use_icon_as_answer_icon": fields.Boolean,
"max_active_requests": fields.Integer,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String()),
"site": fields.Nested(site_fields),
}
app_site_fields = {
"app_id": fields.String,
"access_token": fields.String(attribute="code"),
"code": fields.String,
"title": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"description": fields.String,
"default_language": fields.String,
"customize_domain": fields.String,
"copyright": fields.String,
"privacy_policy": fields.String,
"custom_disclaimer": fields.String,
"customize_token_strategy": fields.String,
"prompt_public": fields.Boolean,
"show_workflow_steps": fields.Boolean,
"use_icon_as_answer_icon": fields.Boolean,
}
leaked_dependency_fields = {"type": fields.String, "value": OpaqueRawField, "current_identifier": fields.String}
app_import_fields = {
"id": fields.String,
"status": fields.String,
"app_id": fields.String,
"app_mode": fields.String,
"current_dsl_version": fields.String,
"imported_dsl_version": fields.String,
"error": fields.String,
}
app_import_check_dependencies_fields = {
"leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)),
}
app_server_fields = {
"id": fields.String,
"name": fields.String,
"server_code": fields.String,
"description": fields.String,
"status": fields.String,
"parameters": JsonStringField,
"created_at": TimestampField,
"updated_at": TimestampField,
}
+7
View File
@@ -2,11 +2,18 @@ from __future__ import annotations
from datetime import datetime
from flask_restx import fields
from pydantic import Field, computed_field, field_validator
from fields.base import ResponseModel
from libs.helper import build_avatar_url, to_timestamp
simple_account_fields = {
"id": fields.String,
"name": fields.String,
"email": fields.String,
}
class SimpleAccountResponse(ResponseModel):
id: str
+52 -65
View File
@@ -1,74 +1,61 @@
from datetime import datetime
from typing import Any
from typing import override
from pydantic import Field, field_validator
from flask_restx import fields
from fields.base import ResponseModel
from fields.member_fields import SimpleAccountResponse
from libs.helper import to_timestamp
from models.snippet import SnippetType
from fields.member_fields import simple_account_fields
from libs.helper import TimestampField
class SnippetTagResponse(ResponseModel):
id: str
name: str
type: str
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class SnippetListItemResponse(ResponseModel):
id: str
name: str
description: str | None
type: SnippetType
version: int
use_count: int
is_published: bool
icon_info: dict[str, Any] | None
tags: list[SnippetTagResponse]
created_by: str | None
author_name: str | None
created_at: int
updated_by: str | None
updated_at: int
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int:
timestamp = to_timestamp(value)
if timestamp is None:
raise ValueError("timestamp is required")
return timestamp
# Snippet list item fields (lightweight for list display)
snippet_list_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": OpaqueRawField,
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.String,
"author_name": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
# Full snippet fields (includes creator info and graph data)
snippet_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"type": fields.String,
"version": fields.Integer,
"use_count": fields.Integer,
"is_published": fields.Boolean,
"icon_info": OpaqueRawField,
"graph": OpaqueRawField(attribute="graph_dict"),
"input_fields": OpaqueRawField(attribute="input_fields_list"),
"tags": fields.List(fields.Nested(tag_fields)),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
"updated_at": TimestampField,
}
class SnippetResponse(ResponseModel):
id: str
name: str
description: str | None
type: SnippetType
version: int
use_count: int
is_published: bool
icon_info: dict[str, Any] | None
graph: dict[str, Any] = Field(validation_alias="graph_dict")
input_fields: list[dict[str, Any]] = Field(validation_alias="input_fields_list")
tags: list[SnippetTagResponse]
created_by: SimpleAccountResponse | None = Field(validation_alias="created_by_account")
created_at: int
updated_by: SimpleAccountResponse | None = Field(validation_alias="updated_by_account")
updated_at: int
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int:
timestamp = to_timestamp(value)
if timestamp is None:
raise ValueError("timestamp is required")
return timestamp
class SnippetPaginationResponse(ResponseModel):
data: list[SnippetListItemResponse]
page: int
limit: int
total: int
has_more: bool
# Pagination response fields
snippet_pagination_fields = {
"data": fields.List(fields.Nested(snippet_list_fields)),
"page": fields.Integer,
"limit": fields.Integer,
"total": fields.Integer,
"has_more": fields.Boolean,
}
+129
View File
@@ -0,0 +1,129 @@
from typing import override
from flask_restx import fields
from core.helper import encrypter
from fields.member_fields import simple_account_fields
from graphon.variables import SecretVariable, SegmentType, VariableBase
from libs.helper import TimestampField
from ._value_type_serializer import serialize_value_type
ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER, SegmentType.SECRET)
class OpaqueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
class JsonValueRawField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {
"anyOf": [
{"type": "string"},
{"type": "integer"},
{"type": "number"},
{"type": "boolean"},
{"type": "object", "additionalProperties": True},
{"type": "array", "items": {}},
{"type": "null"},
]
}
class EnvironmentVariableField(fields.Raw):
@override
def schema(self) -> dict[str, object]:
return {"type": "object"}
@override
def format(self, value):
# Mask secret variables values in environment_variables
if isinstance(value, SecretVariable):
return {
"id": value.id,
"name": value.name,
"value": encrypter.full_mask_token(),
"value_type": value.value_type.value,
"description": value.description,
}
if isinstance(value, VariableBase):
return {
"id": value.id,
"name": value.name,
"value": value.value,
"value_type": str(value.value_type.exposed_type()),
"description": value.description,
}
if isinstance(value, dict):
value_type_str = value.get("value_type")
if not isinstance(value_type_str, str):
raise TypeError(
f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}"
)
value_type = SegmentType(value_type_str).exposed_type()
if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES:
raise ValueError(f"Unsupported environment variable value type: {value_type}")
return value
conversation_variable_fields = {
"id": fields.String,
"name": fields.String,
"value_type": fields.String(attribute=serialize_value_type),
"value": JsonValueRawField,
"description": fields.String,
}
pipeline_variable_fields = {
"label": fields.String,
"variable": fields.String,
"type": fields.String,
"belong_to_node_id": fields.String,
"max_length": fields.Integer,
"required": fields.Boolean,
"unit": fields.String,
"default_value": JsonValueRawField,
"options": fields.List(fields.String),
"placeholder": fields.String,
"tooltips": fields.String,
"allowed_file_types": fields.List(fields.String),
"allow_file_extension": fields.List(fields.String),
"allow_file_upload_methods": fields.List(fields.String),
}
workflow_fields = {
"id": fields.String,
"graph": OpaqueRawField(attribute="graph_dict"),
"features": OpaqueRawField(attribute="features_dict"),
"hash": fields.String(attribute="unique_hash"),
"version": fields.String,
"marked_name": fields.String,
"marked_comment": fields.String,
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
"updated_at": TimestampField,
"tool_published": fields.Boolean,
"environment_variables": fields.List(EnvironmentVariableField()),
"conversation_variables": fields.List(fields.Nested(conversation_variable_fields)),
"rag_pipeline_variables": fields.List(fields.Nested(pipeline_variable_fields)),
}
workflow_partial_fields = {
"id": fields.String,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
}
workflow_pagination_fields = {
"items": fields.List(fields.Nested(workflow_fields), attribute="items"),
"page": fields.Integer,
"limit": fields.Integer(attribute="limit"),
"has_more": fields.Boolean(attribute="has_more"),
}
+41 -52
View File
@@ -369,19 +369,17 @@ class InvitationCode(TypeBase):
)
class TenantPluginInstallPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class TenantPluginDebugPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class TenantPluginPermission(TypeBase):
class InstallPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
class DebugPermission(enum.StrEnum):
EVERYONE = "everyone"
ADMINS = "admins"
NOBODY = "noone"
__tablename__ = "account_plugin_permissions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="account_plugin_permission_pkey"),
@@ -392,42 +390,36 @@ class TenantPluginPermission(TypeBase):
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
install_permission: Mapped[TenantPluginInstallPermission] = mapped_column(
EnumText(TenantPluginInstallPermission, length=16),
install_permission: Mapped[InstallPermission] = mapped_column(
EnumText(InstallPermission, length=16),
nullable=False,
server_default="everyone",
default=TenantPluginInstallPermission.EVERYONE,
default=InstallPermission.EVERYONE,
)
debug_permission: Mapped[TenantPluginDebugPermission] = mapped_column(
EnumText(TenantPluginDebugPermission, length=16),
nullable=False,
server_default="noone",
default=TenantPluginDebugPermission.NOBODY,
debug_permission: Mapped[DebugPermission] = mapped_column(
EnumText(DebugPermission, length=16), nullable=False, server_default="noone", default=DebugPermission.NOBODY
)
class TenantPluginAutoUpgradeCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class TenantPluginAutoUpgradeStrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
LATEST = "latest"
class TenantPluginAutoUpgradeMode(enum.StrEnum):
ALL = "all"
PARTIAL = "partial"
EXCLUDE = "exclude"
class TenantPluginAutoUpgradeStrategy(TypeBase):
class PluginCategory(enum.StrEnum):
TOOL = "tool"
MODEL = "model"
EXTENSION = "extension"
AGENT_STRATEGY = "agent-strategy"
DATASOURCE = "datasource"
TRIGGER = "trigger"
class StrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
LATEST = "latest"
class UpgradeMode(enum.StrEnum):
ALL = "all"
PARTIAL = "partial"
EXCLUDE = "exclude"
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
@@ -439,23 +431,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase):
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
category: Mapped[TenantPluginAutoUpgradeCategory] = mapped_column(
EnumText(TenantPluginAutoUpgradeCategory, length=32),
category: Mapped[PluginCategory] = mapped_column(
EnumText(PluginCategory, length=32),
nullable=False,
server_default="tool",
default=TenantPluginAutoUpgradeCategory.TOOL,
default=PluginCategory.TOOL,
)
strategy_setting: Mapped[TenantPluginAutoUpgradeStrategySetting] = mapped_column(
EnumText(TenantPluginAutoUpgradeStrategySetting, length=16),
strategy_setting: Mapped[StrategySetting] = mapped_column(
EnumText(StrategySetting, length=16),
nullable=False,
server_default="fix_only",
default=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY,
default=StrategySetting.FIX_ONLY,
)
upgrade_mode: Mapped[TenantPluginAutoUpgradeMode] = mapped_column(
EnumText(TenantPluginAutoUpgradeMode, length=16),
nullable=False,
server_default="exclude",
default=TenantPluginAutoUpgradeMode.EXCLUDE,
upgrade_mode: Mapped[UpgradeMode] = mapped_column(
EnumText(UpgradeMode, length=16), nullable=False, server_default="exclude", default=UpgradeMode.EXCLUDE
)
exclude_plugins: Mapped[list[str]] = mapped_column(sa.JSON, nullable=False, default_factory=list)
include_plugins: Mapped[list[str]] = mapped_column(sa.JSON, nullable=False, default_factory=list)
+27 -27
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validat
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod, FileType
from graphon.file import FileTransferMethod
class AgentKnowledgeQueryMode(StrEnum):
@@ -314,9 +314,8 @@ class AgentKnowledgeQueryConfig(BaseModel):
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy. Mode-dependent completeness, such as
requiring ``value`` for ``user_query``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
"""
model_config = ConfigDict(extra="forbid")
@@ -324,6 +323,12 @@ class AgentKnowledgeQueryConfig(BaseModel):
mode: AgentKnowledgeQueryMode
value: str | None = None
@model_validator(mode="after")
def validate_query(self) -> Self:
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
raise ValueError("knowledge query.value is required for user_query mode")
return self
class AgentKnowledgeModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -351,9 +356,8 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
``multiple`` or a model for ``single``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
"""
model_config = ConfigDict(extra="forbid")
@@ -367,6 +371,14 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
weights: AgentKnowledgeWeightedScoreConfig | None = None
model: AgentKnowledgeModelConfig | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "multiple" and self.top_k is None:
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
if self.mode == "single" and self.model is None:
raise ValueError("knowledge retrieval.model is required for single mode")
return self
class AgentKnowledgeMetadataCondition(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -389,8 +401,6 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
Mode-dependent completeness is enforced by composer publish validation so
draft saves can persist partially configured metadata filters.
"""
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -400,6 +410,14 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
conditions: AgentKnowledgeMetadataConditions | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "automatic" and self.metadata_model_config is None:
raise ValueError("metadata_filtering.model_config is required for automatic mode")
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
raise ValueError("metadata_filtering.conditions is required for manual mode")
return self
class AgentKnowledgeSetConfig(BaseModel):
"""One explicit knowledge set in Agent v2.
@@ -529,23 +547,6 @@ class AgentSensitiveWordAvoidanceFeatureConfig(AgentFeatureToggleConfig):
config: AgentModerationProviderConfig | None = None
class AgentFileUploadImageFeatureConfig(AgentFeatureToggleConfig):
enabled: bool = True
class AgentFileUploadFeatureConfig(AgentFeatureToggleConfig):
enabled: bool = True
allowed_file_extensions: list[str] = Field(default_factory=lambda: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"])
allowed_file_types: list[FileType] = Field(
default_factory=lambda: [FileType.DOCUMENT, FileType.IMAGE, FileType.AUDIO, FileType.VIDEO]
)
allowed_file_upload_methods: list[FileTransferMethod] = Field(
default_factory=lambda: [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]
)
image: AgentFileUploadImageFeatureConfig = Field(default_factory=AgentFileUploadImageFeatureConfig)
number_limits: int = 3
class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
opening_statement: str | None = None
suggested_questions: list[str] | None = None
@@ -554,7 +555,6 @@ class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
text_to_speech: AgentTextToSpeechFeatureConfig | None = None
retriever_resource: AgentFeatureToggleConfig | None = None
sensitive_word_avoidance: AgentSensitiveWordAvoidanceFeatureConfig | None = None
file_upload: AgentFileUploadFeatureConfig = Field(default_factory=AgentFileUploadFeatureConfig)
class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig):
+181 -281
View File
@@ -141,9 +141,9 @@ Get account avatar url
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [EducationActivateResponse](#educationactivateresponse)<br> |
### [GET] /account/education/autocomplete
#### Parameters
@@ -1268,7 +1268,7 @@ Read a text/binary preview file in an Agent App conversation sandbox
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /agent/{agent_id}/sandbox/files/upload
Upload one Agent App sandbox file and return a signed download URL
Upload one Agent App sandbox file as a Dify ToolFile mapping
#### Parameters
@@ -1411,7 +1411,7 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspacePaginationResponse](#workspacepaginationresponse)<br> |
| 200 | Success | **application/json**: [WorkspaceListResponse](#workspacelistresponse)<br> |
### [GET] /api-based-extension
Get all API-based extensions for current tenant
@@ -3777,7 +3777,7 @@ Read a text/binary preview file in a workflow Agent node sandbox
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload
Upload one workflow Agent sandbox file and return a signed download URL
Upload one workflow Agent sandbox file as a Dify ToolFile mapping
#### Parameters
@@ -5106,14 +5106,14 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Default datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)<br> |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
### [GET] /auth/plugin/datasource/list
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceProviderAuthListResponse](#datasourceproviderauthlistresponse)<br> |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
### [GET] /auth/plugin/datasource/{provider_id}
#### Parameters
@@ -5126,7 +5126,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Datasource credentials retrieved successfully | **application/json**: [DatasourceCredentialListResponse](#datasourcecredentiallistresponse)<br> |
| 200 | Success | **application/json**: [DatasourceCredentialsResponse](#datasourcecredentialsresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}
#### Parameters
@@ -5145,7 +5145,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Datasource credential created successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [DELETE] /auth/plugin/datasource/{provider_id}/custom-client
#### Parameters
@@ -5177,7 +5177,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Datasource OAuth custom client saved successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}/default
#### Parameters
@@ -5234,7 +5234,7 @@ Refresh MCP server configuration and regenerate server code
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Datasource credential updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 201 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /auth/plugin/datasource/{provider_id}/update-name
#### Parameters
@@ -7638,9 +7638,9 @@ Initiate OAuth login process
#### Responses
| Code | Description |
| ---- | ----------- |
| 302 | Redirect to OAuth callback page |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 302 | Redirect to console OAuth callback page | **application/json**: [RedirectResponse](#redirectresponse)<br> |
### [GET] /oauth/plugin/{provider_id}/datasource/get-authorization-url
#### Parameters
@@ -7654,7 +7654,7 @@ Initiate OAuth login process
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Datasource OAuth authorization URL generated successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)<br> |
| 200 | Authorization URL retrieved successfully | **application/json**: [PluginOAuthAuthorizationUrlResponse](#pluginoauthauthorizationurlresponse)<br> |
### [GET] /oauth/plugin/{provider}/tool/authorization-url
#### Parameters
@@ -8477,9 +8477,9 @@ Initiate OAuth login process
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [DataSourceContentPreviewResponse](#datasourcecontentpreviewresponse)<br> |
### [POST] /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run
**Run rag pipeline datasource**
@@ -8617,7 +8617,6 @@ Initiate OAuth login process
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 401 | Unauthorized | **application/json**: [SimpleResultMessageResponse](#simpleresultmessageresponse)<br> |
### [POST] /remote-files/upload
#### Request Body
@@ -9433,9 +9432,9 @@ Bedrock retrieval test (internal use only)
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
### [POST] /trial-apps/{app_id}/completion-messages
#### Parameters
@@ -9452,9 +9451,9 @@ Bedrock retrieval test (internal use only)
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
### [GET] /trial-apps/{app_id}/datasets
#### Parameters
@@ -9569,9 +9568,9 @@ Returns the site configuration for the application including theme, icons, and t
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br> |
### [POST] /trial-apps/{app_id}/workflows/tasks/{task_id}/stop
**Stop workflow task**
@@ -9984,7 +9983,7 @@ Create a new plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)<br> |
| 403 | Admin privileges required | |
### ~~[POST] /workspaces/current/endpoints/create~~
@@ -10003,7 +10002,7 @@ Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/cur
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint created successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint created successfully | **application/json**: [EndpointCreateResponse](#endpointcreateresponse)<br> |
| 403 | Admin privileges required | |
### ~~[POST] /workspaces/current/endpoints/delete~~
@@ -10022,7 +10021,7 @@ Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/c
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)<br> |
| 403 | Admin privileges required | |
### [POST] /workspaces/current/endpoints/disable
@@ -10038,7 +10037,7 @@ Disable a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint disabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint disabled successfully | **application/json**: [EndpointDisableResponse](#endpointdisableresponse)<br> |
| 403 | Admin privileges required | |
### [POST] /workspaces/current/endpoints/enable
@@ -10054,7 +10053,7 @@ Enable a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint enabled successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint enabled successfully | **application/json**: [EndpointEnableResponse](#endpointenableresponse)<br> |
| 403 | Admin privileges required | |
### [GET] /workspaces/current/endpoints/list
@@ -10088,7 +10087,7 @@ List endpoints for a specific plugin
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [EndpointListResponse](#endpointlistresponse)<br> |
| 200 | Success | **application/json**: [PluginEndpointListResponse](#pluginendpointlistresponse)<br> |
### ~~[POST] /workspaces/current/endpoints/update~~
@@ -10106,7 +10105,7 @@ Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/cu
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)<br> |
| 403 | Admin privileges required | |
### [DELETE] /workspaces/current/endpoints/{id}
@@ -10122,7 +10121,7 @@ Delete a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint deleted successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint deleted successfully | **application/json**: [EndpointDeleteResponse](#endpointdeleteresponse)<br> |
| 403 | Admin privileges required | |
### [PATCH] /workspaces/current/endpoints/{id}
@@ -10144,7 +10143,7 @@ Update a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Endpoint updated successfully | **application/json**: [SuccessResponse](#successresponse)<br> |
| 200 | Endpoint updated successfully | **application/json**: [EndpointUpdateResponse](#endpointupdateresponse)<br> |
| 403 | Admin privileges required | |
### [GET] /workspaces/current/members
@@ -10204,7 +10203,7 @@ Update a plugin endpoint
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [MemberActionResponse](#memberactionresponse)<br> |
| 200 | Success | **application/json**: [MemberActionTenantResponse](#memberactiontenantresponse)<br> |
### [POST] /workspaces/current/members/{member_id}/owner-transfer
#### Parameters
@@ -12472,15 +12471,9 @@ Returns permission flags that control workspace features like member invitations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)<br> |
| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)<br> |
### [POST] /workspaces/custom-config/webapp-logo/upload
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
@@ -12498,7 +12491,7 @@ Returns permission flags that control workspace features like member invitations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceTenantResultResponse](#workspacetenantresultresponse)<br> |
| 200 | Success | **application/json**: [WorkspaceMutationResponse](#workspacemutationresponse)<br> |
### [POST] /workspaces/switch
#### Request Body
@@ -13754,23 +13747,6 @@ Stable Agent Soul reference to one normalized skill archive.
| upload_file_id | string | | No |
| url | string | | No |
#### AgentFileUploadFeatureConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed_file_extensions | [ string ] | | No |
| allowed_file_types | [ [FileType](#filetype) ] | | No |
| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No |
| enabled | boolean, <br>**Default:** true | | No |
| image | [AgentFileUploadImageFeatureConfig](#agentfileuploadimagefeatureconfig) | | No |
| number_limits | integer, <br>**Default:** 3 | | No |
#### AgentFileUploadImageFeatureConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean, <br>**Default:** true | | No |
#### AgentHumanContactConfig
| Name | Type | Description | Required |
@@ -13914,8 +13890,6 @@ Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
Mode-dependent completeness is enforced by composer publish validation so
draft saves can persist partially configured metadata filters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -13938,9 +13912,8 @@ Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy. Mode-dependent completeness, such as
requiring ``value`` for ``user_query``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -13965,9 +13938,8 @@ validation so draft saves can persist partially configured knowledge sets.
Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
``multiple`` or a model for ``single``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -14366,7 +14338,6 @@ Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_upload | [AgentFileUploadFeatureConfig](#agentfileuploadfeatureconfig) | | No |
| opening_statement | string | | No |
| retriever_resource | [AgentFeatureToggleConfig](#agentfeaturetoggleconfig) | | No |
| sensitive_word_avoidance | [AgentSensitiveWordAvoidanceFeatureConfig](#agentsensitivewordavoidancefeatureconfig) | | No |
@@ -16132,6 +16103,12 @@ Model class for provider custom model configuration.
| ---- | ---- | ----------- | -------- |
| info_list | [InfoList](#infolist) | | Yes |
#### DataSourceContentPreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DataSourceContentPreviewResponse | | | |
#### DataSourceIntegrateIconResponse
| Name | Type | Description | Required |
@@ -16652,43 +16629,32 @@ Model class for provider custom model configuration.
| ---- | ---- | ----------- | -------- |
| credential_id | string | | Yes |
#### DatasourceCredentialListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes |
#### DatasourceCredentialPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | Yes |
| credentials | object | | Yes |
| name | string | | No |
#### DatasourceCredentialResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| avatar_url | string | | Yes |
| credential | object | Obfuscated plugin-defined credential parameters from the datasource provider. | Yes |
| id | string | | Yes |
| is_default | boolean | | Yes |
| name | string | | Yes |
| type | string | | Yes |
#### DatasourceCredentialUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credential_id | string | | Yes |
| credentials | object | Plugin-defined credential parameters. The schema is declared by the datasource provider. | No |
| credentials | object | | No |
| name | string | | No |
#### DatasourceCredentialsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | | | Yes |
#### DatasourceCustomClientPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| client_params | object | Plugin-defined OAuth client parameters. The schema is declared by the datasource provider. | No |
| client_params | object | | No |
| enable_oauth_custom_client | boolean | | No |
#### DatasourceDefaultPayload
@@ -16720,39 +16686,6 @@ Model class for provider custom model configuration.
| error | string | Error message from OAuth provider | No |
| state | string | OAuth state parameter | No |
#### DatasourceOAuthSchemaResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| client_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| credentials_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| is_oauth_custom_client_enabled | boolean | | Yes |
| is_system_oauth_params_exists | boolean | | Yes |
| oauth_custom_client_params | object | Masked plugin-defined OAuth client parameters, when configured for the tenant. | Yes |
| redirect_uri | string | | Yes |
#### DatasourceProviderAuthListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | [ [DatasourceProviderAuthResponse](#datasourceproviderauthresponse) ] | | Yes |
#### DatasourceProviderAuthResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | Yes |
| credential_schema | [ [ProviderConfig](#providerconfig) ] | | Yes |
| credentials_list | [ [DatasourceCredentialResponse](#datasourcecredentialresponse) ] | | Yes |
| description | [I18nObject](#i18nobject) | | Yes |
| icon | string | | Yes |
| label | [I18nObject](#i18nobject) | | Yes |
| name | string | | Yes |
| oauth_schema | [DatasourceOAuthSchemaResponse](#datasourceoauthschemaresponse) | | Yes |
| plugin_id | string | | Yes |
| plugin_unique_identifier | string | | Yes |
| provider | string | | Yes |
#### DatasourceUpdateNamePayload
| Name | Type | Description | Required |
@@ -16769,6 +16702,12 @@ Model class for provider custom model configuration.
| start_node_id | string | | Yes |
| start_node_title | string | | Yes |
#### DebugPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DebugPermission | string | | |
#### DeclaredArrayItem
Per-item shape for an ``array``-typed declared output.
@@ -17095,6 +17034,12 @@ Request payload for bulk downloading documents as a zip archive.
| role | string | | Yes |
| token | string | | Yes |
#### EducationActivateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| EducationActivateResponse | object | | |
#### EducationAutocompleteQuery
| Name | Type | Description | Required |
@@ -17204,13 +17149,29 @@ Request payload for bulk downloading documents as a zip archive.
| plugin_unique_identifier | string | | Yes |
| settings | object | | Yes |
#### EndpointDeclarationResponse
#### EndpointCreateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| hidden | boolean | | No |
| method | string | | Yes |
| path | string | | Yes |
| success | boolean | Operation success | Yes |
#### EndpointDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointDisableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointEnableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EndpointIdPayload
@@ -17226,23 +17187,6 @@ Request payload for bulk downloading documents as a zip archive.
| page_size | integer | | Yes |
| plugin_id | string | | Yes |
#### EndpointListItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | dateTime | | Yes |
| declaration | [EndpointProviderDeclarationResponse](#endpointproviderdeclarationresponse) | | No |
| enabled | boolean | | Yes |
| expired_at | dateTime | | Yes |
| hook_id | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| plugin_id | string | | Yes |
| settings | object | | Yes |
| tenant_id | string | | Yes |
| updated_at | dateTime | | Yes |
| url | string | | Yes |
#### EndpointListQuery
| Name | Type | Description | Required |
@@ -17254,59 +17198,7 @@ Request payload for bulk downloading documents as a zip archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ [EndpointListItemResponse](#endpointlistitemresponse) ] | Endpoint information | Yes |
#### EndpointProviderConfigI18nResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| en_US | string | | Yes |
| ja_JP | string | | No |
| pt_BR | string | | No |
| zh_Hans | string | | No |
#### EndpointProviderConfigOptionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| label | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | Yes |
| value | string | | Yes |
#### EndpointProviderConfigResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| default | integer<br>string<br>number<br>boolean | | No |
| help | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| label | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| multiple | boolean | | No |
| name | string | | Yes |
| options | [ [EndpointProviderConfigOptionResponse](#endpointproviderconfigoptionresponse) ] | | No |
| placeholder | [EndpointProviderConfigI18nResponse](#endpointproviderconfigi18nresponse) | | No |
| required | boolean | | No |
| scope | [EndpointProviderConfigScope](#endpointproviderconfigscope) | | No |
| type | [ProviderConfigType](#providerconfigtype) | | Yes |
| url | string | | No |
#### EndpointProviderConfigScope
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| EndpointProviderConfigScope | string | | |
#### EndpointProviderDeclarationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ [EndpointDeclarationResponse](#endpointdeclarationresponse) ] | | No |
| settings | [ [EndpointProviderConfigResponse](#endpointproviderconfigresponse) ] | | No |
#### EndpointSettingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
| settings | object | | Yes |
| endpoints | [ object ] | Endpoint information | Yes |
#### EndpointUpdatePayload
@@ -17315,6 +17207,12 @@ Request payload for bulk downloading documents as a zip archive.
| name | string | | Yes |
| settings | object | | Yes |
#### EndpointUpdateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| success | boolean | Operation success | Yes |
#### EnvSuggestion
| Name | Type | Description | Required |
@@ -18112,6 +18010,12 @@ Input field definition for snippet parameters.
| required | boolean | | No |
| type | string | | No |
#### InstallPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| InstallPermission | string | | |
#### InstalledAppCreatePayload
| Name | Type | Description | Required |
@@ -18417,7 +18321,7 @@ Enum class for large language model mode.
| marketplace_plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### MemberActionResponse
#### MemberActionTenantResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -19188,13 +19092,13 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
#### ParserAutoUpgradeFetch
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
#### ParserCreateCredential
@@ -19292,7 +19196,7 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
| plugin_id | string | | Yes |
#### ParserGetCredentials
@@ -19380,8 +19284,8 @@ Enum class for parameter type.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | No |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | No |
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
#### ParserPluginIdentifierQuery
@@ -19590,7 +19494,7 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| auto_upgrade | [PluginAutoUpgradeSettingsResponseModel](#pluginautoupgradesettingsresponsemodel) | | Yes |
| category | [TenantPluginAutoUpgradeCategory](#tenantpluginautoupgradecategory) | | Yes |
| category | [PluginCategory](#plugincategory) | | Yes |
#### PluginAutoUpgradeSettingsPayload
@@ -19598,8 +19502,8 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| exclude_plugins | [ string ] | | No |
| include_plugins | [ string ] | | No |
| strategy_setting | [TenantPluginAutoUpgradeStrategySetting](#tenantpluginautoupgradestrategysetting) | | No |
| upgrade_mode | [TenantPluginAutoUpgradeMode](#tenantpluginautoupgrademode) | | No |
| strategy_setting | [StrategySetting](#strategysetting) | | No |
| upgrade_mode | [UpgradeMode](#upgrademode) | | No |
| upgrade_time_of_day | integer | | No |
#### PluginAutoUpgradeSettingsResponseModel
@@ -19608,8 +19512,8 @@ Shared permission levels for resources (datasets, credentials, etc.)
| ---- | ---- | ----------- | -------- |
| exclude_plugins | [ string ] | | Yes |
| include_plugins | [ string ] | | Yes |
| strategy_setting | [TenantPluginAutoUpgradeStrategySetting](#tenantpluginautoupgradestrategysetting) | | Yes |
| upgrade_mode | [TenantPluginAutoUpgradeMode](#tenantpluginautoupgrademode) | | Yes |
| strategy_setting | [StrategySetting](#strategysetting) | | Yes |
| upgrade_mode | [UpgradeMode](#upgrademode) | | Yes |
| upgrade_time_of_day | integer | | Yes |
#### PluginCategory
@@ -19731,21 +19635,21 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [PluginDependencyType](#plugindependencytype) | | Yes |
| type | [Type](#type) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### PluginDependencyType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginDependencyType | string | | |
#### PluginDynamicOptionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| options | | | Yes |
#### PluginEndpointListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| endpoints | [ object ] | Endpoint information | Yes |
#### PluginInstallationItemResponse
| Name | Type | Description | Required |
@@ -19826,13 +19730,7 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| type | [PluginParameterAutoGenerateType](#pluginparameterautogeneratetype) | | Yes |
#### PluginParameterAutoGenerateType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginParameterAutoGenerateType | string | | |
| type | [core__plugin__entities__parameters__PluginParameterAutoGenerate__Type](#core__plugin__entities__parameters__pluginparameterautogenerate__type) | | Yes |
#### PluginParameterOption
@@ -19852,15 +19750,15 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | Yes |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | Yes |
| debug_permission | [DebugPermission](#debugpermission) | | Yes |
| install_permission | [InstallPermission](#installpermission) | | Yes |
#### PluginPermissionSettingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| debug_permission | [TenantPluginDebugPermission](#tenantplugindebugpermission) | | No |
| install_permission | [TenantPluginInstallPermission](#tenantplugininstallpermission) | | No |
| debug_permission | [DebugPermission](#debugpermission) | | No |
| install_permission | [InstallPermission](#installpermission) | | No |
#### PluginReadmeResponse
@@ -19942,15 +19840,9 @@ Model class for common provider settings like credentials
| placeholder | [I18nObject](#i18nobject) | | No |
| required | boolean | | No |
| scope | [AppSelectorScope](#appselectorscope)<br>[ModelSelectorScope](#modelselectorscope)<br>[ToolSelectorScope](#toolselectorscope) | | No |
| type | [ProviderConfigType](#providerconfigtype) | The type of the credentials | Yes |
| type | [core__entities__provider_entities__BasicProviderConfig__Type](#core__entities__provider_entities__basicproviderconfig__type) | The type of the credentials | Yes |
| url | string | | No |
#### ProviderConfigType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ProviderConfigType | string | | |
#### ProviderCredentialResponse
| Name | Type | Description | Required |
@@ -20545,11 +20437,19 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| text | string | | No |
| truncated | boolean | | Yes |
#### SandboxToolFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| reference | string | | Yes |
| transfer_method | string, <br>**Default:** tool_file | | No |
#### SandboxUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| url | string | | Yes |
| file | [SandboxToolFileResponse](#sandboxtoolfileresponse) | | Yes |
| path | string | | Yes |
#### SavedMessageCreatePayload
@@ -21098,6 +20998,12 @@ Query parameters for listing snippet published workflows.
| paused | integer | | Yes |
| success | integer | | Yes |
#### StrategySetting
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| StrategySetting | string | | |
#### StringListSource
| Name | Type | Description | Required |
@@ -21352,36 +21258,6 @@ Tag type
| ---- | ---- | ----------- | -------- |
| workspaces | [ [TenantListItemResponse](#tenantlistitemresponse) ] | | Yes |
#### TenantPluginAutoUpgradeCategory
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeCategory | string | | |
#### TenantPluginAutoUpgradeMode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeMode | string | | |
#### TenantPluginAutoUpgradeStrategySetting
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginAutoUpgradeStrategySetting | string | | |
#### TenantPluginDebugPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginDebugPermission | string | | |
#### TenantPluginInstallPermission
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| TenantPluginInstallPermission | string | | |
#### TextContentResponse
| Name | Type | Description | Required |
@@ -21888,6 +21764,12 @@ Enum class for tool provider
| ---- | ---- | ----------- | -------- |
| TriggerSubscriptionListResponse | array | | |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UnaddedModelConfiguration
Model class for provider unadded model configuration.
@@ -21928,6 +21810,12 @@ Payload for updating a snippet.
| icon_info | [IconInfo](#iconinfo) | | No |
| name | string | | No |
#### UpgradeMode
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| UpgradeMode | string | | |
#### UploadConfig
| Name | Type | Description | Required |
@@ -23058,13 +22946,7 @@ Workflow tool configuration
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
#### WorkspaceLogoUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
#### WorkspacePaginationResponse
#### WorkspaceListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -23074,6 +22956,19 @@ Workflow tool configuration
| page | integer | | Yes |
| total | integer | | Yes |
#### WorkspaceLogoUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
#### WorkspaceMutationResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes |
#### WorkspacePermissionResponse
| Name | Type | Description | Required |
@@ -23088,13 +22983,6 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| permission_keys | [ string ] | | No |
#### WorkspaceTenantResultResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
| tenant | [TenantInfoResponse](#tenantinforesponse) | | Yes |
#### _AccessControlLanguageQuery
| Name | Type | Description | Required |
@@ -23163,6 +23051,18 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| scope | [RBACResourceWhitelistScope](#rbacresourcewhitelistscope) | | Yes |
#### core__entities__provider_entities__BasicProviderConfig__Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| core__entities__provider_entities__BasicProviderConfig__Type | string | | |
#### core__plugin__entities__parameters__PluginParameterAutoGenerate__Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| core__plugin__entities__parameters__PluginParameterAutoGenerate__Type | string | | |
#### core__tools__entities__common_entities__I18nObject
Model class for i18n object.
+7 -7
View File
@@ -941,15 +941,9 @@ Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [PluginDependencyType](#plugindependencytype) | | Yes |
| type | [Type](#type) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### PluginDependencyType
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PluginDependencyType | string | | |
#### RevokeResponse
| Name | Type | Description | Required |
@@ -1030,6 +1024,12 @@ types it as a required `'success'` rather than an optional field.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UsageInfo
| Name | Type | Description | Required |
+7 -7
View File
@@ -1046,12 +1046,12 @@ Execute a single datasource node within the knowledge pipeline. Returns a stream
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Streaming response with node execution events. |
| 401 | Unauthorized - invalid API token |
| 403 | Forbidden - dataset API access or workspace access denied |
| 404 | `not_found` : Dataset not found. |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Streaming response with node execution events. | **text/event-stream**: [GeneratedAppResponse](#generatedappresponse)<br> |
| 401 | Unauthorized - invalid API token | |
| 403 | Forbidden - dataset API access or workspace access denied | |
| 404 | `not_found` : Dataset not found. | |
### [POST] /datasets/{dataset_id}/pipeline/run
**Run Pipeline**
@@ -2960,7 +2960,7 @@ Enum class for custom configuration status.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | No |
| credentials | [ [DatasourceCredentialInfoResponse](#datasourcecredentialinforesponse) ] | | Yes |
| datasource_type | string | | No |
| node_id | string | | No |
| plugin_id | string | | No |
+1 -7
View File
@@ -600,7 +600,7 @@ Get authentication passport for web application access
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Passport retrieved successfully | **application/json**: [PassportAccessTokenResponse](#passportaccesstokenresponse)<br> |
| 200 | Passport retrieved successfully | **application/json**: [AccessTokenData](#accesstokendata)<br> |
| 401 | Unauthorized - missing app code or invalid authentication | |
| 404 | Application or user not found | |
@@ -1430,12 +1430,6 @@ Form input definition.
| text_to_speech | [JSONObject](#jsonobject) | | Yes |
| user_input_form | [ [JSONObject](#jsonobject) ] | | Yes |
#### PassportAccessTokenResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_token | string | | Yes |
#### PassportQuery
| Name | Type | Description | Required |
+3 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy import select
import app
from core.helper.marketplace import fetch_global_plugin_manifest
from extensions.ext_database import db
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginAutoUpgradeStrategySetting
from models.account import TenantPluginAutoUpgradeStrategy
from tasks import process_tenant_plugin_autoupgrade_check_task as check_task
logger = logging.getLogger(__name__)
@@ -34,7 +34,8 @@ def check_upgradable_plugin_task():
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day >= now_seconds_of_day,
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day
< now_seconds_of_day + AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL,
TenantPluginAutoUpgradeStrategy.strategy_setting != TenantPluginAutoUpgradeStrategySetting.DISABLED,
TenantPluginAutoUpgradeStrategy.strategy_setting
!= TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
)
).all()
+2 -4
View File
@@ -38,8 +38,6 @@ from models.account import (
Tenant,
TenantAccountJoin,
TenantAccountRole,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantStatus,
)
@@ -1259,13 +1257,13 @@ class TenantService:
session.add(tenant)
session.commit()
for category in TenantPluginAutoUpgradeCategory:
for category in TenantPluginAutoUpgradeStrategy.PluginCategory:
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
category=category,
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
+4 -5
View File
@@ -33,7 +33,6 @@ from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import (
AgentModelNotConfiguredError,
AgentNameConflictError,
AgentNotFoundError,
AgentVersionConflictError,
@@ -169,8 +168,7 @@ class AgentComposerService:
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES:
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@@ -359,6 +357,7 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if not agent:
@@ -402,6 +401,7 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
@@ -511,8 +511,6 @@ class AgentComposerService:
version_note=version_note,
)
)
if not agent_soul_has_model(agent_soul):
raise AgentModelNotConfiguredError()
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
version = cls._create_config_version(
tenant_id=tenant_id,
@@ -593,6 +591,7 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_draft_save_payload(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._save_agent_draft(
tenant_id=tenant_id,
-32
View File
@@ -3,7 +3,6 @@ from typing import Any
from pydantic import ValidationError
from models.agent_config_entities import AgentKnowledgeQueryMode
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.agent.prompt_mentions import (
MAX_MENTIONS_PER_PROMPT,
@@ -229,40 +228,9 @@ class ComposerConfigValidator:
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
dumped = agent_soul.model_dump(mode="json")
cls._validate_knowledge_runtime_config(agent_soul)
cls._reject_plaintext_secrets(dumped, path="agent_soul")
cls._validate_shell_config(dumped)
@classmethod
def _validate_knowledge_runtime_config(cls, agent_soul: AgentSoulConfig) -> None:
"""Validate knowledge settings that are required only for publish/run.
Draft composer saves must be able to persist partially configured
knowledge sets while a user is still editing the panel. These checks
stay in the publish validator so invalid runtime configs are still
blocked before a version can be published or executed.
"""
for knowledge_set in agent_soul.knowledge.sets:
if (
knowledge_set.query.mode == AgentKnowledgeQueryMode.USER_QUERY
and not (knowledge_set.query.value or "").strip()
):
raise InvalidComposerConfigError("knowledge query.value is required for user_query mode")
retrieval = knowledge_set.retrieval
if retrieval.mode == "multiple" and retrieval.top_k is None:
raise InvalidComposerConfigError("knowledge retrieval.top_k is required for multiple mode")
if retrieval.mode == "single" and retrieval.model is None:
raise InvalidComposerConfigError("knowledge retrieval.model is required for single mode")
metadata_filtering = knowledge_set.metadata_filtering
if metadata_filtering.mode == "automatic" and metadata_filtering.metadata_model_config is None:
raise InvalidComposerConfigError("metadata_filtering.model_config is required for automatic mode")
if metadata_filtering.mode == "manual" and (
metadata_filtering.conditions is None or not metadata_filtering.conditions.conditions
):
raise InvalidComposerConfigError("metadata_filtering.conditions is required for manual mode")
@classmethod
def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None:
cls._reject_plaintext_secrets(node_job.model_dump(mode="json"), path="node_job")
-8
View File
@@ -1,7 +1,5 @@
from werkzeug.exceptions import BadRequest, Conflict, NotFound
from libs.exception import BaseHTTPException
class AgentNotFoundError(NotFound):
description = "Agent not found."
@@ -23,12 +21,6 @@ class AgentVersionConflictError(Conflict):
description = "Agent config version changed. Please reload and try again."
class AgentModelNotConfiguredError(BaseHTTPException):
error_code = "agent_model_not_configured"
description = "Agent App requires the Agent Soul model to be configured."
code = 400
class AgentSoulLockedError(BadRequest):
description = "Agent Soul is locked for this workflow node."
+6 -70
View File
@@ -3,16 +3,12 @@
These services keep product-facing locators (conversation, workflow run, node)
on the API boundary and translate them into the agent backend's
``SandboxLocator`` using persisted non-sensitive runtime layer specs plus the
saved Agenton session snapshot. Upload responses stay console-facing here: the
agent backend still returns a canonical ToolFile mapping, while this API layer
re-resolves that mapping into a signed browser download URL.
saved Agenton session snapshot.
"""
from __future__ import annotations
import urllib.parse
from collections.abc import Callable
from typing import Any
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.client import Client
@@ -22,10 +18,7 @@ from sqlalchemy import select
from configs import dify_config
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
from core.db.session_factory import session_factory
from factories import file_factory
from models.agent import AgentRuntimeSessionOwnerType, WorkflowAgentRuntimeSession, WorkflowAgentRuntimeSessionStatus
_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec])
@@ -52,12 +45,6 @@ class AgentSandboxInfo(BaseModel):
workspace_cwd: str
class AgentSandboxUploadDownload(BaseModel):
"""Signed browser download URL for one sandbox upload result."""
url: str
class AgentAppSandboxService:
"""Inspect and proxy file access for an Agent App conversation sandbox."""
@@ -90,15 +77,9 @@ class AgentAppSandboxService:
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
return self._client_factory().read_sandbox_file_sync(locator, path)
def upload_file(
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
) -> AgentSandboxUploadDownload:
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str):
locator = self._resolve_locator(tenant_id=tenant_id, app_id=app_id, conversation_id=conversation_id)
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
return _upload_download_response(
tenant_id=tenant_id,
file_mapping=uploaded.file.model_dump(mode="python"),
)
return self._client_factory().upload_sandbox_file_sync(locator, path)
def _resolve_locator(self, *, tenant_id: str, app_id: str, conversation_id: str) -> SandboxLocator:
stored = self._session_store.load_active_session_for_conversation(
@@ -172,7 +153,7 @@ class WorkflowAgentSandboxService:
node_id: str,
node_execution_id: str | None,
path: str,
) -> AgentSandboxUploadDownload:
):
locator = self._resolve_locator(
tenant_id=tenant_id,
app_id=app_id,
@@ -180,11 +161,7 @@ class WorkflowAgentSandboxService:
node_id=node_id,
node_execution_id=node_execution_id,
)
uploaded = self._client_factory().upload_sandbox_file_sync(locator, path)
return _upload_download_response(
tenant_id=tenant_id,
file_mapping=uploaded.file.model_dump(mode="python"),
)
return self._client_factory().upload_sandbox_file_sync(locator, path)
def _resolve_locator(
self,
@@ -269,41 +246,6 @@ def _deserialize_runtime_layer_specs(value: str | None) -> list[RuntimeLayerSpec
return _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(value)
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
"""Resolve one uploaded ToolFile mapping into a signed external download URL."""
controller = DatabaseFileAccessController()
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
try:
file = file_factory.build_from_mapping(
mapping=file_mapping,
tenant_id=tenant_id,
access_controller=controller,
)
url = runtime.resolve_file_url(file=file, for_external=True)
except ValueError as exc:
raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable",
"uploaded sandbox file could not be converted to a download URL",
status_code=502,
) from exc
if not url:
raise AgentSandboxInspectorError(
"sandbox_upload_download_unavailable",
"uploaded sandbox file does not support download URL generation",
status_code=502,
)
return AgentSandboxUploadDownload(url=_with_as_attachment(url))
def _with_as_attachment(url: str) -> str:
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query.append(("as_attachment", "true"))
return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
def _default_client_factory() -> Client:
base_url = dify_config.AGENT_BACKEND_BASE_URL
if not base_url:
@@ -315,10 +257,4 @@ def _default_client_factory() -> Client:
return Client(base_url=base_url)
__all__ = [
"AgentAppSandboxService",
"AgentSandboxInfo",
"AgentSandboxInspectorError",
"AgentSandboxUploadDownload",
"WorkflowAgentSandboxService",
]
__all__ = ["AgentAppSandboxService", "AgentSandboxInfo", "AgentSandboxInspectorError", "WorkflowAgentSandboxService"]
+8 -1
View File
@@ -3872,7 +3872,14 @@ class SegmentService:
session.add(document)
# Delete database records
session.execute(delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)))
session.execute(
delete(DocumentSegment).where(
DocumentSegment.id.in_(segment_db_ids),
DocumentSegment.dataset_id == dataset.id,
DocumentSegment.document_id == document.id,
DocumentSegment.tenant_id == current_user.current_tenant_id,
)
)
session.commit()
@classmethod

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