Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7a770ab | ||
|
|
738de4e359 | ||
|
|
bf90ab66d3 | ||
|
|
335b54dedc | ||
|
|
f67df741ee | ||
|
|
215132c11f | ||
|
|
fc92b64475 | ||
|
|
96feddd23e | ||
|
|
432db250f6 | ||
|
|
8ded8ed728 |
@@ -29,13 +29,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -88,13 +88,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -139,13 +139,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
@@ -84,12 +84,12 @@ jobs:
|
||||
dify-agent/pyproject.toml
|
||||
dify-agent/uv.lock
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -233,7 +233,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@@ -63,13 +63,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
outputs:
|
||||
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
id: changes
|
||||
with:
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183
|
||||
uses: anthropics/claude-code-action@af0559ee4f514d1ef21826982bed13f7edc3c35e # v1.0.178
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -729,6 +729,7 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_METRIC_EXPORT_INTERVAL=60000
|
||||
OTEL_BATCH_EXPORT_TIMEOUT=10000
|
||||
OTEL_METRIC_EXPORT_TIMEOUT=30000
|
||||
|
||||
# Prevent Clickjacking
|
||||
ALLOW_EMBED=false
|
||||
|
||||
|
||||
@@ -816,41 +816,6 @@ class UpdateConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class CommunityTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for anonymous self-hosted community telemetry.
|
||||
"""
|
||||
|
||||
DISABLE_TELEMETRY: bool = Field(
|
||||
description="Disable anonymous community telemetry",
|
||||
default=False,
|
||||
)
|
||||
DO_NOT_TRACK: bool = Field(
|
||||
description="Respect the standard do-not-track opt-out signal for telemetry",
|
||||
default=False,
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
description="Endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.ai/v1/events",
|
||||
)
|
||||
TELEMETRY_FALLBACK_ENDPOINT: str = Field(
|
||||
description="Fallback endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.cn/v1/events",
|
||||
)
|
||||
TELEMETRY_TIMEOUT_SECONDS: PositiveInt = Field(
|
||||
description="HTTP timeout in seconds for anonymous community telemetry requests",
|
||||
default=3,
|
||||
)
|
||||
TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: PositiveInt = Field(
|
||||
description="Celery beat interval in minutes for checking whether heartbeat telemetry is due",
|
||||
default=30,
|
||||
)
|
||||
CI: bool = Field(
|
||||
description="Whether the process is running in CI; telemetry is skipped when true",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowVariableTruncationConfig(BaseSettings):
|
||||
WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field(
|
||||
# 1000 KiB
|
||||
@@ -1634,7 +1599,6 @@ class FeatureConfig(
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
UpdateConfig,
|
||||
CommunityTelemetryConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
|
||||
@@ -257,6 +257,7 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
debug_conversation_has_messages: bool = False
|
||||
debug_conversation_message_count: int = 0
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
|
||||
|
||||
class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
@@ -409,6 +410,10 @@ def _serialize_agent_app_detail(
|
||||
payload["debug_conversation_has_messages"] = message_count > 0
|
||||
payload["debug_conversation_message_count"] = message_count
|
||||
payload["role"] = agent.role or ""
|
||||
payload["active_config_is_published"] = roster_service.active_config_is_published(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -26,11 +26,7 @@ from libs.helper import dump_response, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.model_provider_entities import (
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ProviderResponse,
|
||||
)
|
||||
from services.entities.model_provider_entities import ProviderResponse
|
||||
from services.model_provider_service import ModelProviderService
|
||||
|
||||
|
||||
@@ -95,11 +91,6 @@ class ModelProviderListResponse(ResponseModel):
|
||||
data: list[ProviderResponse]
|
||||
|
||||
|
||||
class ModelProviderSummaryListResponse(ResponseModel):
|
||||
data: list[ModelProviderSummaryResponse]
|
||||
plugins: dict[str, ModelProviderPluginSummaryResponse]
|
||||
|
||||
|
||||
class ProviderCredentialsResponse(ResponseModel):
|
||||
credentials: dict[str, Any] | None = None
|
||||
|
||||
@@ -123,7 +114,6 @@ register_response_schema_models(
|
||||
console_ns,
|
||||
SimpleResultResponse,
|
||||
ModelProviderListResponse,
|
||||
ModelProviderSummaryListResponse,
|
||||
ProviderCredentialsResponse,
|
||||
ValidationResultResponse,
|
||||
ModelProviderPaymentCheckoutUrlResponse,
|
||||
@@ -150,25 +140,6 @@ class ModelProviderListApi(Resource):
|
||||
return ModelProviderListResponse(data=provider_list).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/summary")
|
||||
class ModelProviderSummaryListApi(Resource):
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model provider summaries retrieved successfully",
|
||||
console_ns.models[ModelProviderSummaryListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
providers, plugins = ModelProviderService().get_provider_summary_list(tenant_id=tenant_id)
|
||||
return dump_response(
|
||||
ModelProviderSummaryListResponse,
|
||||
{"data": providers, "plugins": plugins},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/<path:provider>/credentials")
|
||||
class ModelProviderCredentialApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ParserCredentialId))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import io
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
@@ -43,7 +43,6 @@ from core.plugin.entities.plugin_daemon import PluginDecodeResponse, PluginInsta
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
from core.tools.entities.api_entities import ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.tool_manager import ToolManager
|
||||
@@ -91,21 +90,9 @@ class ParserList(BaseModel):
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
|
||||
|
||||
type PluginCategoryListLanguage = Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"]
|
||||
|
||||
|
||||
class PluginCategoryListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
query: str = Field(default="", max_length=256, description="Case-insensitive search query")
|
||||
tags: list[str] = Field(default_factory=list, max_length=128, description="Match any plugin tag")
|
||||
language: Literal["en_US", "zh_Hans", "ja_JP", "pt_BR"] = Field(
|
||||
default="en_US", description="Language used for localized label and description search"
|
||||
)
|
||||
|
||||
|
||||
class PluginInstalledIdsQuery(BaseModel):
|
||||
category: PluginCategory = Field(description="Plugin category to include")
|
||||
|
||||
|
||||
class ParserLatest(BaseModel):
|
||||
@@ -338,10 +325,6 @@ class PluginListResponse(ResponseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsResponse(ResponseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginVersionsResponse(ResponseModel):
|
||||
versions: Mapping[str, PluginService.LatestPluginCache | None]
|
||||
|
||||
@@ -401,7 +384,6 @@ register_schema_models(
|
||||
console_ns,
|
||||
ParserList,
|
||||
PluginCategoryListQuery,
|
||||
PluginInstalledIdsQuery,
|
||||
PluginAutoUpgradeSettingsPayload,
|
||||
PluginPermissionSettingsPayload,
|
||||
ParserLatest,
|
||||
@@ -438,7 +420,6 @@ register_response_schema_models(
|
||||
PluginDebuggingKeyResponse,
|
||||
PluginDynamicOptionsResponse,
|
||||
PluginInstallationsResponse,
|
||||
PluginInstalledIdsResponse,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
PluginManifestResponse,
|
||||
@@ -496,39 +477,7 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
|
||||
return content
|
||||
|
||||
|
||||
def _localized_builtin_tool_text(value: I18nObject, language: PluginCategoryListLanguage) -> str:
|
||||
return value.to_dict()[language] or value.en_US
|
||||
|
||||
|
||||
def _builtin_tool_provider_matches_filters(
|
||||
provider: ToolProviderApiEntity,
|
||||
*,
|
||||
query: str,
|
||||
tags: Sequence[str],
|
||||
language: PluginCategoryListLanguage,
|
||||
) -> bool:
|
||||
if tags and not any(tag in provider.labels for tag in tags):
|
||||
return False
|
||||
if not query:
|
||||
return True
|
||||
|
||||
lower_query = query.lower()
|
||||
candidates = (
|
||||
provider.name,
|
||||
_localized_builtin_tool_text(provider.label, language),
|
||||
_localized_builtin_tool_text(provider.description, language),
|
||||
)
|
||||
return any(lower_query in candidate.lower() for candidate in candidates)
|
||||
|
||||
|
||||
def _list_hardcoded_builtin_tool_providers(
|
||||
tenant_id: str,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: PluginCategoryListLanguage = "en_US",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List builtin providers using the same search and tag semantics as category plugins."""
|
||||
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
|
||||
db_builtin_providers = {
|
||||
str(ToolProviderID(provider.provider)): provider
|
||||
for provider in ToolManager.list_default_builtin_providers(tenant_id)
|
||||
@@ -549,13 +498,6 @@ def _list_hardcoded_builtin_tool_providers(
|
||||
db_provider=db_builtin_providers.get(provider.entity.identity.name),
|
||||
decrypt_credentials=False,
|
||||
)
|
||||
if not _builtin_tool_provider_matches_filters(
|
||||
user_provider,
|
||||
query=query,
|
||||
tags=tags,
|
||||
language=language,
|
||||
):
|
||||
continue
|
||||
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
|
||||
builtin_providers.append(user_provider)
|
||||
|
||||
@@ -610,9 +552,7 @@ class PluginCategoryListApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, category: str):
|
||||
args = PluginCategoryListQuery.model_validate(
|
||||
{**request.args.to_dict(flat=True), "tags": request.args.getlist("tags")}
|
||||
)
|
||||
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
try:
|
||||
plugin_category = PluginCategory(category)
|
||||
@@ -620,26 +560,13 @@ class PluginCategoryListApi(Resource):
|
||||
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
|
||||
|
||||
try:
|
||||
plugins = PluginService.list_by_category(
|
||||
tenant_id,
|
||||
plugin_category,
|
||||
args.page,
|
||||
args.page_size,
|
||||
query=args.query,
|
||||
tags=args.tags,
|
||||
language=args.language,
|
||||
)
|
||||
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
builtin_tools = []
|
||||
if plugin_category == PluginCategory.Tool:
|
||||
builtin_tools = _list_hardcoded_builtin_tool_providers(
|
||||
tenant_id,
|
||||
query=args.query,
|
||||
tags=args.tags,
|
||||
language=args.language,
|
||||
)
|
||||
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
|
||||
|
||||
return dump_response(
|
||||
PluginCategoryListResponse,
|
||||
@@ -651,24 +578,6 @@ class PluginCategoryListApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/installed-ids")
|
||||
class PluginInstalledIdsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(PluginInstalledIdsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginInstalledIdsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
args = PluginInstalledIdsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
try:
|
||||
plugin_ids = PluginService.list_installed_plugin_ids(tenant_id, args.category)
|
||||
except PluginDaemonClientSideError as e:
|
||||
return {"code": "plugin_error", "message": e.description}, 400
|
||||
|
||||
return dump_response(PluginInstalledIdsResponse, {"plugin_ids": plugin_ids})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
|
||||
class PluginListLatestVersionsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserLatest.__name__])
|
||||
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,7 +35,6 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -24,7 +24,7 @@ from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import RateLimiter, dump_response, extract_remote_ip, to_timestamp
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, AppMode, Site
|
||||
from models.model import App, Site
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.feature_service import FeatureService
|
||||
from services.human_input_file_upload_service import HumanInputFileUploadService
|
||||
@@ -207,7 +207,6 @@ class HumanInputFormApi(Resource):
|
||||
site=WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.value_of(app_model.mode),
|
||||
site=site,
|
||||
end_user_id=None,
|
||||
features=features,
|
||||
|
||||
@@ -14,7 +14,7 @@ from extensions.storage.storage_type import StorageType
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import build_icon_url
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.model import App, AppMode, EndUser, IconType, Site
|
||||
from models.model import App, EndUser, IconType, Site
|
||||
from services.feature_service import FeatureModel, FeatureService
|
||||
from services.file_service import FileService
|
||||
|
||||
@@ -67,7 +67,6 @@ class WebAppCustomConfigResponse(ResponseModel):
|
||||
|
||||
class WebAppSiteResponse(ResponseModel):
|
||||
app_id: str
|
||||
mode: AppMode
|
||||
end_user_id: str | None = None
|
||||
enable_site: bool
|
||||
site: WebSiteResponse
|
||||
@@ -84,7 +83,6 @@ class WebAppSiteResponse(ResponseModel):
|
||||
*,
|
||||
tenant: Tenant,
|
||||
app_model: App,
|
||||
mode: AppMode,
|
||||
site: Site,
|
||||
end_user_id: str | None,
|
||||
features: FeatureModel,
|
||||
@@ -111,7 +109,6 @@ class WebAppSiteResponse(ResponseModel):
|
||||
|
||||
return cls(
|
||||
app_id=app_model.id,
|
||||
mode=mode,
|
||||
end_user_id=end_user_id,
|
||||
enable_site=app_model.enable_site,
|
||||
site=site_response,
|
||||
@@ -170,7 +167,6 @@ class AppSiteApi(WebApiResource):
|
||||
return WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())),
|
||||
site=site,
|
||||
end_user_id=end_user.id,
|
||||
features=features,
|
||||
|
||||
@@ -616,34 +616,23 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
message_snapshot = MessageSnapshot.from_message(message)
|
||||
session.close()
|
||||
|
||||
try:
|
||||
response = self._handle_advanced_chat_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow_snapshot,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation_snapshot,
|
||||
message=message_snapshot,
|
||||
user=user,
|
||||
stream=stream,
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
account=user,
|
||||
tenant_id=application_generate_entity.app_config.tenant_id,
|
||||
),
|
||||
)
|
||||
converted_response = AdvancedChatAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
# return response or stream generator
|
||||
response = self._handle_advanced_chat_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow_snapshot,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation_snapshot,
|
||||
message=message_snapshot,
|
||||
user=user,
|
||||
stream=stream,
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
account=user,
|
||||
tenant_id=application_generate_entity.app_config.tenant_id,
|
||||
),
|
||||
)
|
||||
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
def _generate_worker(
|
||||
self,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import TYPE_CHECKING, Any, Union, final
|
||||
@@ -25,10 +23,6 @@ from services.workflow_draft_variable_service import DraftVariableSaver as Draft
|
||||
if TYPE_CHECKING:
|
||||
from graphon.variables.input_entities import VariableEntity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
@final
|
||||
class _DebuggerDraftVariableSaver:
|
||||
@@ -70,29 +64,6 @@ class _DebuggerDraftVariableSaver:
|
||||
class BaseAppGenerator:
|
||||
_file_access_controller: DatabaseFileAccessController = DatabaseFileAccessController()
|
||||
|
||||
@staticmethod
|
||||
def _join_worker_thread(worker_thread: threading.Thread) -> None:
|
||||
# Bound the wait so a leaked app worker cannot occupy an execution slot indefinitely.
|
||||
worker_thread.join(timeout=_WORKER_THREAD_JOIN_TIMEOUT_SECONDS)
|
||||
if worker_thread.is_alive():
|
||||
logger.warning(
|
||||
"Possible app worker thread leak: thread_name=%s timeout_seconds=%s; "
|
||||
"continuing without waiting further to avoid occupying an execution slot indefinitely",
|
||||
worker_thread.name,
|
||||
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wrap_stream_with_worker_thread_join[ResponseT](
|
||||
response_stream: Generator[ResponseT, None, None],
|
||||
worker_thread: threading.Thread,
|
||||
) -> Generator[ResponseT, None, None]:
|
||||
"""Keep the producer owned by the response stream until both finish."""
|
||||
try:
|
||||
yield from response_stream
|
||||
finally:
|
||||
BaseAppGenerator._join_worker_thread(worker_thread)
|
||||
|
||||
@staticmethod
|
||||
def _bind_file_access_scope(
|
||||
*,
|
||||
|
||||
@@ -351,28 +351,17 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
user,
|
||||
tenant_id=pipeline.tenant_id,
|
||||
)
|
||||
try:
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
)
|
||||
converted_response = WorkflowAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
)
|
||||
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
def single_iteration_generate(
|
||||
self,
|
||||
|
||||
@@ -405,28 +405,17 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
tenant_id=app_model.tenant_id,
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
stream=streaming,
|
||||
)
|
||||
converted_response = WorkflowAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
stream=streaming,
|
||||
)
|
||||
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
def single_iteration_generate(
|
||||
self,
|
||||
|
||||
@@ -39,16 +39,15 @@ class Jinja2TemplateTransformer(TemplateTransformer):
|
||||
@override
|
||||
def get_runner_script(cls) -> str:
|
||||
runner_script = dedent(f"""
|
||||
import jinja2
|
||||
import json
|
||||
from base64 import b64decode
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
# declare main function
|
||||
def main(**inputs):
|
||||
# Decode base64-encoded template to handle special characters safely
|
||||
template_code = b64decode('{cls._template_b64_placeholder}').decode('utf-8')
|
||||
env = SandboxedEnvironment()
|
||||
template = env.from_string(template_code)
|
||||
template = jinja2.Template(template_code)
|
||||
return template.render(**inputs)
|
||||
|
||||
# decode and prepare input dict
|
||||
@@ -68,13 +67,12 @@ class Jinja2TemplateTransformer(TemplateTransformer):
|
||||
@override
|
||||
def get_preload_script(cls) -> str:
|
||||
preload_script = dedent("""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
import jinja2
|
||||
from base64 import b64decode
|
||||
|
||||
def _jinja2_preload_():
|
||||
# prepare jinja2 sandboxed environment, load template and render
|
||||
env = SandboxedEnvironment()
|
||||
template = env.from_string('{{s}}')
|
||||
# prepare jinja2 environment, load template and render before to avoid sandbox issue
|
||||
template = jinja2.Template('{{s}}')
|
||||
template.render(s='a')
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -519,7 +519,7 @@ class IndexingRunner:
|
||||
def filter_string(text):
|
||||
text = re.sub(r"<\|", "<", text)
|
||||
text = re.sub(r"\|>", ">", text)
|
||||
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text)
|
||||
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xEF\xBF\xBE]", "", text)
|
||||
# Unicode U+FFFE
|
||||
text = re.sub("\ufffe", "", text)
|
||||
return text
|
||||
|
||||
@@ -12,7 +12,7 @@ from core.agent.plugin_entities import AgentProviderEntityWithPlugin
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin
|
||||
from core.plugin.entities.base import BasePluginEntity
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallationSource
|
||||
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin
|
||||
from core.trigger.entities.entities import TriggerProviderEntity
|
||||
@@ -94,18 +94,6 @@ class PluginModelProviderEntity(BaseModel):
|
||||
declaration: ProviderEntity = Field(description="The declaration of the model provider.")
|
||||
|
||||
|
||||
class PluginModelProviderBinding(BaseModel):
|
||||
"""Lightweight installation metadata for one model provider."""
|
||||
|
||||
provider: str
|
||||
installation_id: str
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
runtime_type: str
|
||||
source: PluginInstallationSource
|
||||
version: str
|
||||
|
||||
|
||||
class PluginTextEmbeddingNumTokensResponse(BaseModel):
|
||||
"""
|
||||
Response for number of tokens.
|
||||
@@ -219,10 +207,6 @@ class PluginListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class PluginInstalledIdsDaemonResponse(BaseModel):
|
||||
plugin_ids: list[str]
|
||||
|
||||
|
||||
class PluginListWithoutTotalResponse(BaseModel):
|
||||
list: list[PluginEntity]
|
||||
has_more: bool
|
||||
|
||||
@@ -6,7 +6,6 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginBasicBooleanResponse,
|
||||
PluginDaemonInnerError,
|
||||
PluginLLMNumTokensResponse,
|
||||
PluginModelProviderBinding,
|
||||
PluginModelProviderEntity,
|
||||
PluginModelSchemaEntity,
|
||||
PluginStringResultResponse,
|
||||
@@ -48,14 +47,6 @@ class PluginModelClient(BasePluginClient):
|
||||
)
|
||||
return response
|
||||
|
||||
def fetch_model_provider_bindings(self, tenant_id: str) -> Sequence[PluginModelProviderBinding]:
|
||||
"""Fetch only model-provider installation identities from the daemon."""
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/models/bindings",
|
||||
list[PluginModelProviderBinding],
|
||||
)
|
||||
|
||||
def get_model_schema(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@@ -14,7 +14,6 @@ from core.plugin.entities.plugin import (
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginListResponse,
|
||||
@@ -69,16 +68,6 @@ class PluginInstaller(BasePluginClient):
|
||||
)
|
||||
return result.list
|
||||
|
||||
def list_installed_plugin_ids(self, tenant_id: str, category: PluginCategory) -> list[str]:
|
||||
"""List all currently installed plugin IDs in one category."""
|
||||
result = self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/installation/ids",
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
params={"category": category.value},
|
||||
)
|
||||
return result.plugin_ids
|
||||
|
||||
def list_plugins_with_total(self, tenant_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
@@ -88,28 +77,13 @@ class PluginInstaller(BasePluginClient):
|
||||
)
|
||||
|
||||
def list_plugins_by_category(
|
||||
self,
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
page: int,
|
||||
page_size: int,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: str = "en_US",
|
||||
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
"GET",
|
||||
f"plugin/{tenant_id}/management/{category.value}/list",
|
||||
PluginListWithoutTotalResponse,
|
||||
params={
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"response_type": "paged",
|
||||
"query": query,
|
||||
"tags": list(tags),
|
||||
"language": language,
|
||||
},
|
||||
params={"page": page, "page_size": page_size, "response_type": "paged"},
|
||||
)
|
||||
|
||||
def upload_pkg(
|
||||
|
||||
@@ -48,7 +48,6 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginInstallTaskStatus,
|
||||
PluginListResponse,
|
||||
PluginListWithoutTotalResponse,
|
||||
PluginModelProviderBinding,
|
||||
PluginModelProviderEntity,
|
||||
PluginVerification,
|
||||
)
|
||||
@@ -79,12 +78,6 @@ class _RedisLock(Protocol):
|
||||
def release(self) -> None: ...
|
||||
|
||||
|
||||
class _ModelPluginIdentity(Protocol):
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
source: PluginInstallationSource
|
||||
|
||||
|
||||
class PluginService:
|
||||
class LatestPluginCache(BaseModel):
|
||||
plugin_id: str
|
||||
@@ -272,7 +265,7 @@ class PluginService:
|
||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[_ModelPluginIdentity]) -> str | None:
|
||||
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
|
||||
remote_model_plugins = sorted(
|
||||
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
|
||||
for plugin in plugins
|
||||
@@ -357,7 +350,7 @@ class PluginService:
|
||||
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
plugins: Sequence[_ModelPluginIdentity],
|
||||
plugins: Sequence[PluginEntity],
|
||||
) -> bool:
|
||||
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
|
||||
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
|
||||
@@ -663,26 +656,6 @@ class PluginService:
|
||||
plugins = manager.list_plugins(tenant_id)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def list_installed_plugin_ids(tenant_id: str, category: PluginCategory) -> Sequence[str]:
|
||||
"""List all currently installed plugin IDs in one category through the daemon's lightweight query."""
|
||||
manager = PluginInstaller()
|
||||
return manager.list_installed_plugin_ids(tenant_id, category)
|
||||
|
||||
@staticmethod
|
||||
def list_model_provider_bindings(
|
||||
tenant_id: str, *, client: PluginModelClient | None = None
|
||||
) -> Sequence[PluginModelProviderBinding]:
|
||||
"""Return fresh model bindings and reconcile remote-debug provider metadata before it is read."""
|
||||
model_client = client or PluginModelClient()
|
||||
bindings = model_client.fetch_model_provider_bindings(tenant_id)
|
||||
if PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(tenant_id, bindings):
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
marker = PluginService._get_remote_model_plugin_cache_marker(bindings)
|
||||
PluginService._store_cached_remote_model_plugin_marker(tenant_id, marker)
|
||||
return bindings
|
||||
|
||||
@staticmethod
|
||||
def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse:
|
||||
"""List tenant plugins with endpoint counts reconciled from live records.
|
||||
@@ -699,33 +672,17 @@ class PluginService:
|
||||
|
||||
@staticmethod
|
||||
def list_by_category(
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
page: int,
|
||||
page_size: int,
|
||||
*,
|
||||
query: str = "",
|
||||
tags: Sequence[str] = (),
|
||||
language: str = "en_US",
|
||||
tenant_id: str, category: PluginCategory, page: int, page_size: int
|
||||
) -> PluginListWithoutTotalResponse:
|
||||
"""
|
||||
List plugins in one category with a has-more cursor signal and without calculating total.
|
||||
|
||||
The daemon applies category, search, and tag filters before pagination, then stops once it finds one extra
|
||||
match. Only a complete, unfiltered first page may reconcile the model-provider cache; the unpaginated model
|
||||
binding read is the authoritative marker source for larger result sets.
|
||||
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
|
||||
This keeps pagination usable before category is persisted on installation rows.
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
plugins = manager.list_plugins_by_category(
|
||||
tenant_id,
|
||||
category,
|
||||
page,
|
||||
page_size,
|
||||
query=query,
|
||||
tags=tags,
|
||||
language=language,
|
||||
)
|
||||
if category == PluginCategory.Model and page == 1 and not plugins.has_more and not query and not tags:
|
||||
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
|
||||
if category == PluginCategory.Model:
|
||||
should_invalidate_model_provider_cache = (
|
||||
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||
tenant_id,
|
||||
|
||||
@@ -9,7 +9,7 @@ class CleanProcessor:
|
||||
# remove invalid symbol
|
||||
text = re.sub(r"<\|", "<", text)
|
||||
text = re.sub(r"\|>", ">", text)
|
||||
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text)
|
||||
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xEF\xBF\xBE]", "", text)
|
||||
# Unicode U+FFFE
|
||||
text = re.sub("\ufffe", "", text)
|
||||
|
||||
|
||||
@@ -91,8 +91,8 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
splits = re.split(r" +", text)
|
||||
else:
|
||||
splits = text.split(separator)
|
||||
if self._keep_separator:
|
||||
splits = [s + separator for s in splits[:-1]] + splits[-1:]
|
||||
if self._keep_separator:
|
||||
splits = [s + separator for s in splits[:-1]] + splits[-1:]
|
||||
else:
|
||||
splits = list(text)
|
||||
if separator == "\n":
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from celery import Celery, Task
|
||||
from celery.schedules import crontab
|
||||
from celery.signals import beat_init
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
@@ -37,19 +36,6 @@ class CeleryBeatScheduleEntry(TypedDict):
|
||||
schedule: crontab | timedelta
|
||||
|
||||
|
||||
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
|
||||
task_name = "community_telemetry.send_heartbeat"
|
||||
if "community_telemetry_heartbeat" not in sender.app.conf.beat_schedule:
|
||||
return
|
||||
|
||||
task = sender.app.tasks.get(task_name)
|
||||
if task is not None:
|
||||
task.apply_async()
|
||||
|
||||
|
||||
beat_init.connect(_enqueue_initial_community_telemetry_heartbeat, weak=False)
|
||||
|
||||
|
||||
def get_celery_ssl_options() -> CelerySSLOptionsDict | None:
|
||||
"""Get SSL configuration for Celery broker/backend connections."""
|
||||
# Only apply SSL if we're using Redis as broker/backend
|
||||
@@ -274,19 +260,6 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL),
|
||||
}
|
||||
|
||||
if (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
):
|
||||
imports.append("tasks.community_telemetry_task")
|
||||
beat_schedule["community_telemetry_heartbeat"] = {
|
||||
"task": "community_telemetry.send_heartbeat",
|
||||
"schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES),
|
||||
}
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
|
||||
imports.append("tasks.enterprise_telemetry_task")
|
||||
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
|
||||
|
||||
@@ -383,7 +383,6 @@ class AgentAppComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.AGENT_APP]
|
||||
agent: AgentComposerAgentResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
active_config_is_published: bool
|
||||
draft: AgentConfigDraftSummaryResponse | None = None
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
"""add telemetry fields to dify_setups
|
||||
|
||||
Revision ID: 6f5a9c2d8e1b
|
||||
Revises: d2825e7b9c10
|
||||
Create Date: 2026-07-23 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6f5a9c2d8e1b"
|
||||
down_revision = "d2825e7b9c10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("instance_id", sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column("install_reported_at", sa.DateTime(), nullable=True))
|
||||
batch_op.add_column(sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_heartbeat_at")
|
||||
batch_op.drop_column("install_reported_at")
|
||||
batch_op.drop_column("instance_id")
|
||||
@@ -362,9 +362,6 @@ class DifySetup(TypeBase):
|
||||
__table_args__ = (sa.PrimaryKeyConstraint("version", name="dify_setup_pkey"),)
|
||||
|
||||
version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
instance_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
install_reported_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
setup_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
@@ -10567,13 +10567,6 @@ Update a plugin endpoint
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model providers retrieved successfully | **application/json**: [ModelProviderListResponse](#modelproviderlistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/summary
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model provider summaries retrieved successfully | **application/json**: [ModelProviderSummaryListResponse](#modelprovidersummarylistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/{provider}/checkout-url
|
||||
#### Parameters
|
||||
|
||||
@@ -11119,19 +11112,6 @@ Returns permission flags that control workspace features like member invitations
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [PluginInstallTaskStartResponse](#plugininstalltaskstartresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/plugin/installed-ids
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| category | query | Plugin category to include | Yes | string, <br>**Available values:** "agent-strategy", "datasource", "extension", "model", "tool", "trigger" |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [PluginInstalledIdsResponse](#plugininstalledidsresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/plugin/list
|
||||
#### Parameters
|
||||
|
||||
@@ -11390,11 +11370,8 @@ Returns permission flags that control workspace features like member invitations
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| language | query | Language used for localized label and description search | No | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US |
|
||||
| page | query | Page number | No | integer, <br>**Default:** 1 |
|
||||
| page_size | query | Page size (1-256) | No | integer, <br>**Default:** 256 |
|
||||
| query | query | Case-insensitive search query | No | string |
|
||||
| tags | query | Match any plugin tag | No | [ string ] |
|
||||
| category | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
@@ -13266,7 +13243,6 @@ Model class for AI model.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_is_published | boolean | | Yes |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
@@ -13306,6 +13282,7 @@ Model class for AI model.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| access_mode | string | | No |
|
||||
| active_config_is_published | boolean | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
@@ -19330,16 +19307,6 @@ Enum class for model property key.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| ModelPropertyKey | string | Enum class for model property key. | |
|
||||
|
||||
#### ModelProviderCustomConfigurationSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| available_credentials | [ [CredentialConfiguration](#credentialconfiguration) ] | | Yes |
|
||||
| current_credential_id | string | | No |
|
||||
| current_credential_name | string | | No |
|
||||
| current_credential_usable | boolean | | Yes |
|
||||
| status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes |
|
||||
|
||||
#### ModelProviderListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -19352,49 +19319,6 @@ Enum class for model property key.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| payment_link | string | | Yes |
|
||||
|
||||
#### ModelProviderPluginSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| installation_id | string | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| plugin_unique_identifier | string | | Yes |
|
||||
| runtime_type | string | | Yes |
|
||||
| source | [PluginInstallationSource](#plugininstallationsource) | | Yes |
|
||||
| version | string | | Yes |
|
||||
|
||||
#### ModelProviderSummaryListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [ModelProviderSummaryResponse](#modelprovidersummaryresponse) ] | | Yes |
|
||||
| plugins | object | | Yes |
|
||||
|
||||
#### ModelProviderSummaryResponse
|
||||
|
||||
Fields required to render the collapsed model-provider list.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| configurate_methods | [ [ConfigurateMethod](#configuratemethod) ] | | Yes |
|
||||
| custom_configuration | [ModelProviderCustomConfigurationSummaryResponse](#modelprovidercustomconfigurationsummaryresponse) | | Yes |
|
||||
| description | [I18nObject](#i18nobject) | | No |
|
||||
| icon_small | [I18nObject](#i18nobject) | | No |
|
||||
| icon_small_dark | [I18nObject](#i18nobject) | | No |
|
||||
| is_configured | boolean | | Yes |
|
||||
| label | [I18nObject](#i18nobject) | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
| preferred_provider_type | [ProviderType](#providertype) | | Yes |
|
||||
| provider | string | | Yes |
|
||||
| supported_model_types | [ [ModelType](#modeltype) ] | | Yes |
|
||||
| system_configuration | [ModelProviderSystemConfigurationSummaryResponse](#modelprovidersystemconfigurationsummaryresponse) | | Yes |
|
||||
|
||||
#### ModelProviderSystemConfigurationSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| enabled | boolean | | Yes |
|
||||
|
||||
#### ModelSelectorScope
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -20383,11 +20307,8 @@ Shared permission levels for resources (datasets, credentials, etc.)
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string, <br>**Available values:** "en_US", "ja_JP", "pt_BR", "zh_Hans", <br>**Default:** en_US | Language used for localized label and description search<br>*Enum:* `"en_US"`, `"ja_JP"`, `"pt_BR"`, `"zh_Hans"` | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number | No |
|
||||
| page_size | integer, <br>**Default:** 256 | Page size (1-256) | No |
|
||||
| query | string | Case-insensitive search query | No |
|
||||
| tags | [ string ] | Match any plugin tag | No |
|
||||
|
||||
#### PluginCategoryListResponse
|
||||
|
||||
@@ -20588,18 +20509,6 @@ Shared permission levels for resources (datasets, credentials, etc.)
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| plugins | [ [PluginInstallationItemResponse](#plugininstallationitemresponse) ] | | Yes |
|
||||
|
||||
#### PluginInstalledIdsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| category | [PluginCategory](#plugincategory) | Plugin category to include | Yes |
|
||||
|
||||
#### PluginInstalledIdsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| plugin_ids | [ string ] | | Yes |
|
||||
|
||||
#### PluginListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -965,12 +965,6 @@ Returns Server-Sent Events stream.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| tool_icons | object | Tool icon metadata keyed by tool name | No |
|
||||
|
||||
#### AppMode
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AppMode | string | | |
|
||||
|
||||
#### AppPermissionQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1652,7 +1646,6 @@ in form definition, or a variable while the workflow is running.
|
||||
| custom_config | [WebAppCustomConfigResponse](#webappcustomconfigresponse) | | No |
|
||||
| enable_site | boolean | | Yes |
|
||||
| end_user_id | string | | No |
|
||||
| mode | [AppMode](#appmode) | | Yes |
|
||||
| model_config | [WebModelConfigResponse](#webmodelconfigresponse) | | No |
|
||||
| plan | string | | Yes |
|
||||
| site | [WebSiteResponse](#websiteresponse) | | Yes |
|
||||
|
||||
@@ -75,7 +75,6 @@ from services.errors.account import (
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
from tasks.mail_change_mail_task import (
|
||||
@@ -1954,7 +1953,7 @@ class RegisterService:
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session)
|
||||
|
||||
dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4()))
|
||||
dify_setup = DifySetup(version=dify_config.project.version)
|
||||
session.add(dify_setup)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
@@ -1967,11 +1966,6 @@ class RegisterService:
|
||||
logger.exception("Setup account failed, email: %s, name: %s", email, name)
|
||||
raise ValueError(f"Setup failed: {e}")
|
||||
|
||||
try:
|
||||
CommunityTelemetryService.report_install(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to report install telemetry", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
|
||||
@@ -405,7 +405,6 @@ class AgentComposerService:
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"agent": cls._serialize_agent(agent),
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"active_config_is_published": bool(agent.active_config_snapshot_id and agent.active_config_is_published),
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
|
||||
|
||||
@@ -17,7 +17,6 @@ from core.entities.provider_entities import (
|
||||
QuotaConfiguration,
|
||||
UnaddedModelConfiguration,
|
||||
)
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import (
|
||||
FetchFrom,
|
||||
@@ -70,64 +69,6 @@ class SystemConfigurationResponse(BaseModel):
|
||||
quota_configurations: list[QuotaConfiguration] = []
|
||||
|
||||
|
||||
class ModelProviderCustomConfigurationSummaryResponse(BaseModel):
|
||||
status: CustomConfigurationStatus
|
||||
available_credentials: list[CredentialConfiguration]
|
||||
current_credential_id: str | None = None
|
||||
current_credential_name: str | None = None
|
||||
current_credential_usable: bool
|
||||
|
||||
|
||||
class ModelProviderSystemConfigurationSummaryResponse(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ModelProviderPluginSummaryResponse(BaseModel):
|
||||
installation_id: str
|
||||
plugin_id: str
|
||||
plugin_unique_identifier: str
|
||||
runtime_type: str
|
||||
source: PluginInstallationSource
|
||||
version: str
|
||||
|
||||
|
||||
class ModelProviderSummaryResponse(BaseModel):
|
||||
"""Fields required to render the collapsed model-provider list."""
|
||||
|
||||
tenant_id: str = Field(exclude=True)
|
||||
provider: str
|
||||
plugin_id: str
|
||||
label: I18nObject
|
||||
description: I18nObject | None = None
|
||||
icon_small: I18nObject | None = None
|
||||
icon_small_dark: I18nObject | None = None
|
||||
supported_model_types: Sequence[ModelType]
|
||||
configurate_methods: list[ConfigurateMethod]
|
||||
preferred_provider_type: ProviderType
|
||||
is_configured: bool
|
||||
custom_configuration: ModelProviderCustomConfigurationSummaryResponse
|
||||
system_configuration: ModelProviderSystemConfigurationSummaryResponse
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
@model_validator(mode="after")
|
||||
def build_icon_urls(self):
|
||||
url_prefix = (
|
||||
dify_config.CONSOLE_API_URL + f"/console/api/workspaces/{self.tenant_id}/model-providers/{self.provider}"
|
||||
)
|
||||
if self.icon_small is not None:
|
||||
self.icon_small = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small/zh_Hans",
|
||||
)
|
||||
if self.icon_small_dark is not None:
|
||||
self.icon_small_dark = I18nObject(
|
||||
en_US=f"{url_prefix}/icon_small_dark/en_US",
|
||||
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
"""
|
||||
Model class for provider response.
|
||||
|
||||
@@ -1,42 +1,18 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.account import Account
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.model_entities import ModelWithProviderEntity, ProviderModelWithStatusEntity
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.provider_manager import ProviderManager
|
||||
from extensions import ext_hosting_provider
|
||||
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
|
||||
from models.provider import (
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModel,
|
||||
ProviderModelCredential,
|
||||
ProviderType,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from models.provider_ids import ModelProviderID
|
||||
from models.provider import ProviderType
|
||||
from services.entities.model_provider_entities import (
|
||||
CustomConfigurationResponse,
|
||||
CustomConfigurationStatus,
|
||||
DefaultModelResponse,
|
||||
ModelProviderCustomConfigurationSummaryResponse,
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ModelProviderSystemConfigurationSummaryResponse,
|
||||
ModelWithProviderEntityResponse,
|
||||
ProviderResponse,
|
||||
ProviderWithModelsResponse,
|
||||
@@ -48,17 +24,6 @@ from services.errors.app_model_config import ProviderNotFoundError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ProviderSummaryState:
|
||||
has_custom_provider: bool = False
|
||||
available_credentials: list[CredentialConfiguration] = field(default_factory=list)
|
||||
has_custom_models: bool = False
|
||||
current_credential_id: str | None = None
|
||||
current_credential_name: str | None = None
|
||||
current_credential_usable: bool = False
|
||||
preferred_provider_type: ProviderType | None = None
|
||||
|
||||
|
||||
class ModelProviderService:
|
||||
"""
|
||||
Model Provider Service
|
||||
@@ -167,242 +132,6 @@ class ModelProviderService:
|
||||
|
||||
return provider_responses
|
||||
|
||||
@staticmethod
|
||||
def _load_provider_summary_states(tenant_id: str) -> dict[str, _ProviderSummaryState]:
|
||||
"""Load only the workspace columns required by the collapsed provider list."""
|
||||
with session_factory.create_session() as session:
|
||||
custom_provider_rows = session.execute(
|
||||
select(
|
||||
Provider.provider_name,
|
||||
Provider.credential_id,
|
||||
ProviderCredential.provider_name.label("credential_provider_name"),
|
||||
ProviderCredential.credential_name,
|
||||
)
|
||||
.outerjoin(
|
||||
ProviderCredential,
|
||||
and_(
|
||||
ProviderCredential.id == Provider.credential_id,
|
||||
ProviderCredential.tenant_id == tenant_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_type == ProviderType.CUSTOM,
|
||||
Provider.is_valid.is_(True),
|
||||
)
|
||||
).all()
|
||||
credential_rows = session.execute(
|
||||
select(
|
||||
ProviderCredential.id,
|
||||
ProviderCredential.provider_name,
|
||||
ProviderCredential.credential_name,
|
||||
)
|
||||
.where(ProviderCredential.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ProviderCredential.created_at.desc(),
|
||||
ProviderCredential.id.desc(),
|
||||
)
|
||||
).all()
|
||||
custom_model_rows = session.execute(
|
||||
select(ProviderModel.provider_name.label("provider_name"))
|
||||
.where(
|
||||
ProviderModel.tenant_id == tenant_id,
|
||||
ProviderModel.is_valid.is_(True),
|
||||
)
|
||||
.union(
|
||||
select(ProviderModelCredential.provider_name.label("provider_name")).where(
|
||||
ProviderModelCredential.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
preferred_provider_rows = session.execute(
|
||||
select(
|
||||
TenantPreferredModelProvider.provider_name,
|
||||
TenantPreferredModelProvider.preferred_provider_type,
|
||||
).where(TenantPreferredModelProvider.tenant_id == tenant_id)
|
||||
).all()
|
||||
|
||||
states: defaultdict[str, _ProviderSummaryState] = defaultdict(_ProviderSummaryState)
|
||||
for credential in credential_rows:
|
||||
provider_name = str(ModelProviderID(credential.provider_name))
|
||||
states[provider_name].available_credentials.append(
|
||||
CredentialConfiguration(
|
||||
credential_id=credential.id,
|
||||
credential_name=credential.credential_name,
|
||||
)
|
||||
)
|
||||
|
||||
selected_provider_priorities: dict[str, bool] = {}
|
||||
for provider in custom_provider_rows:
|
||||
provider_name = str(ModelProviderID(provider.provider_name))
|
||||
state = states[provider_name]
|
||||
state.has_custom_provider = True
|
||||
|
||||
is_canonical_row = provider.provider_name == provider_name
|
||||
if provider_name in selected_provider_priorities and not is_canonical_row:
|
||||
continue
|
||||
selected_provider_priorities[provider_name] = is_canonical_row
|
||||
state.current_credential_id = provider.credential_id
|
||||
if (
|
||||
provider.credential_provider_name is not None
|
||||
and str(ModelProviderID(provider.credential_provider_name)) == provider_name
|
||||
):
|
||||
state.current_credential_name = provider.credential_name
|
||||
state.current_credential_usable = True
|
||||
else:
|
||||
state.current_credential_name = None
|
||||
state.current_credential_usable = False
|
||||
|
||||
for model in custom_model_rows:
|
||||
states[str(ModelProviderID(model.provider_name))].has_custom_models = True
|
||||
|
||||
preferred_provider_priorities: dict[str, bool] = {}
|
||||
for preferred_provider in preferred_provider_rows:
|
||||
provider_name = str(ModelProviderID(preferred_provider.provider_name))
|
||||
is_canonical_row = preferred_provider.provider_name == provider_name
|
||||
if provider_name in preferred_provider_priorities and not is_canonical_row:
|
||||
continue
|
||||
preferred_provider_priorities[provider_name] = is_canonical_row
|
||||
states[provider_name].preferred_provider_type = preferred_provider.preferred_provider_type
|
||||
|
||||
return dict(states)
|
||||
|
||||
@staticmethod
|
||||
def _is_system_provider_enabled(provider: str) -> bool:
|
||||
configuration = ext_hosting_provider.hosting_configuration.provider_map.get(provider)
|
||||
return bool(configuration and configuration.enabled and configuration.quotas)
|
||||
|
||||
@staticmethod
|
||||
def _select_binding(
|
||||
current_binding: PluginModelProviderBinding | None,
|
||||
candidate_binding: PluginModelProviderBinding,
|
||||
) -> PluginModelProviderBinding:
|
||||
"""Prefer a remote-debug runtime when one shadows an installed plugin."""
|
||||
if current_binding is None:
|
||||
return candidate_binding
|
||||
if (
|
||||
candidate_binding.source == PluginInstallationSource.Remote
|
||||
and current_binding.source != PluginInstallationSource.Remote
|
||||
):
|
||||
return candidate_binding
|
||||
return current_binding
|
||||
|
||||
@staticmethod
|
||||
def _get_preferred_provider_type(
|
||||
state: _ProviderSummaryState,
|
||||
*,
|
||||
custom_present: bool,
|
||||
system_enabled: bool,
|
||||
) -> ProviderType:
|
||||
if state.preferred_provider_type is not None:
|
||||
return state.preferred_provider_type
|
||||
if dify_config.EDITION == "CLOUD" and system_enabled:
|
||||
return ProviderType.SYSTEM
|
||||
if custom_present:
|
||||
return ProviderType.CUSTOM
|
||||
if system_enabled:
|
||||
return ProviderType.SYSTEM
|
||||
return ProviderType.CUSTOM
|
||||
|
||||
def get_provider_summary_list(
|
||||
self, tenant_id: str
|
||||
) -> tuple[list[ModelProviderSummaryResponse], dict[str, ModelProviderPluginSummaryResponse]]:
|
||||
"""Build the complete first-screen provider projection without assembling provider configurations."""
|
||||
# Read bindings first: remote-debug identity changes invalidate provider metadata
|
||||
# before the provider cache is consulted.
|
||||
bindings = PluginService.list_model_provider_bindings(tenant_id)
|
||||
provider_entities = PluginService.fetch_plugin_model_providers(tenant_id=tenant_id)
|
||||
states = self._load_provider_summary_states(tenant_id)
|
||||
|
||||
bindings_by_provider: dict[str, PluginModelProviderBinding] = {}
|
||||
for binding in bindings:
|
||||
provider_name = (
|
||||
str(ModelProviderID(binding.provider))
|
||||
if binding.provider.count("/") == 2
|
||||
else str(ModelProviderID(f"{binding.plugin_id}/{binding.provider}"))
|
||||
)
|
||||
bindings_by_provider[provider_name] = self._select_binding(
|
||||
bindings_by_provider.get(provider_name),
|
||||
binding,
|
||||
)
|
||||
|
||||
provider_summaries: list[ModelProviderSummaryResponse] = []
|
||||
emitted_provider_names: set[str] = set()
|
||||
for provider_entity in provider_entities:
|
||||
if is_filtered(
|
||||
include_set=dify_config.POSITION_PROVIDER_INCLUDES_SET,
|
||||
exclude_set=dify_config.POSITION_PROVIDER_EXCLUDES_SET,
|
||||
data=provider_entity,
|
||||
name_func=lambda provider: provider.provider,
|
||||
):
|
||||
continue
|
||||
|
||||
provider_id = ModelProviderID(provider_entity.provider)
|
||||
provider_name = str(provider_id)
|
||||
if provider_name in emitted_provider_names:
|
||||
continue
|
||||
emitted_provider_names.add(provider_name)
|
||||
|
||||
state = states.get(provider_name, _ProviderSummaryState())
|
||||
custom_configured = (
|
||||
state.has_custom_provider and bool(state.available_credentials)
|
||||
) or state.has_custom_models
|
||||
custom_present = state.has_custom_provider or state.has_custom_models
|
||||
system_enabled = self._is_system_provider_enabled(provider_name)
|
||||
preferred_provider_type = self._get_preferred_provider_type(
|
||||
state,
|
||||
custom_present=custom_present,
|
||||
system_enabled=system_enabled,
|
||||
)
|
||||
|
||||
provider_summaries.append(
|
||||
ModelProviderSummaryResponse(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_name,
|
||||
plugin_id=provider_id.plugin_id,
|
||||
label=provider_entity.label,
|
||||
description=provider_entity.description,
|
||||
icon_small=provider_entity.icon_small,
|
||||
icon_small_dark=provider_entity.icon_small_dark,
|
||||
supported_model_types=provider_entity.supported_model_types,
|
||||
configurate_methods=provider_entity.configurate_methods,
|
||||
preferred_provider_type=preferred_provider_type,
|
||||
is_configured=custom_configured or system_enabled,
|
||||
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
|
||||
status=CustomConfigurationStatus.ACTIVE
|
||||
if custom_configured
|
||||
else CustomConfigurationStatus.NO_CONFIGURE,
|
||||
available_credentials=state.available_credentials,
|
||||
current_credential_id=state.current_credential_id,
|
||||
current_credential_name=state.current_credential_name,
|
||||
current_credential_usable=state.current_credential_usable,
|
||||
),
|
||||
system_configuration=ModelProviderSystemConfigurationSummaryResponse(
|
||||
enabled=system_enabled,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
plugin_bindings: dict[str, PluginModelProviderBinding] = {}
|
||||
for binding in bindings_by_provider.values():
|
||||
plugin_bindings[binding.plugin_id] = self._select_binding(
|
||||
plugin_bindings.get(binding.plugin_id),
|
||||
binding,
|
||||
)
|
||||
|
||||
plugin_summaries = {
|
||||
plugin_id: ModelProviderPluginSummaryResponse(
|
||||
installation_id=binding.installation_id,
|
||||
plugin_id=binding.plugin_id,
|
||||
plugin_unique_identifier=binding.plugin_unique_identifier,
|
||||
runtime_type=binding.runtime_type,
|
||||
source=binding.source,
|
||||
version=binding.version,
|
||||
)
|
||||
for plugin_id, binding in plugin_bindings.items()
|
||||
}
|
||||
return provider_summaries, plugin_summaries
|
||||
|
||||
def get_models_by_provider(self, tenant_id: str, provider: str) -> list[ModelWithProviderEntityResponse]:
|
||||
"""
|
||||
get provider models.
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import DifySetup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TelemetryEvent = Literal["install", "heartbeat"]
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class CommunityTelemetryService:
|
||||
@classmethod
|
||||
def report_install(cls, *, session: Session) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
payload = cls._build_payload(setup, "install")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.install_reported_at = naive_utc_now()
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def report_heartbeat(cls, *, session: Session, now: datetime | None = None) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
now = now or naive_utc_now()
|
||||
if not cls._is_heartbeat_due(setup, now):
|
||||
return False
|
||||
|
||||
if setup.install_reported_at is None:
|
||||
cls.report_install(session=session)
|
||||
|
||||
payload = cls._build_payload(setup, "heartbeat")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.last_heartbeat_at = now
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _get_setup(cls, session: Session) -> DifySetup | None:
|
||||
return session.scalar(select(DifySetup).order_by(DifySetup.setup_at.asc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _is_enabled(cls) -> bool:
|
||||
return (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
and bool(dify_config.TELEMETRY_ENDPOINT)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_payload(cls, setup: DifySetup, event: TelemetryEvent) -> dict[str, str | int]:
|
||||
payload: dict[str, str | int] = {
|
||||
"event": event,
|
||||
"instance_id": setup.instance_id or "",
|
||||
"version": setup.version if event == "install" else dify_config.project.version,
|
||||
"edition": dify_config.EDITION,
|
||||
"deployment_type": "unknown",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"os": cls._normalize_os(platform.system()),
|
||||
"arch": cls._normalize_arch(platform.machine()),
|
||||
"sent_at": cls._format_datetime(naive_utc_now()),
|
||||
}
|
||||
|
||||
if event == "install":
|
||||
payload["installed_at"] = cls._format_datetime(setup.setup_at)
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _send_event(cls, payload: dict[str, str | int]) -> bool:
|
||||
if not cls._is_enabled():
|
||||
return False
|
||||
|
||||
endpoints = [dify_config.TELEMETRY_ENDPOINT]
|
||||
if dify_config.TELEMETRY_FALLBACK_ENDPOINT not in endpoints:
|
||||
endpoints.append(dify_config.TELEMETRY_FALLBACK_ENDPOINT)
|
||||
|
||||
for endpoint in endpoints:
|
||||
if not endpoint:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
timeout=dify_config.TELEMETRY_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
logger.debug("Failed to send community telemetry event to %s", endpoint, exc_info=True)
|
||||
except httpx.HTTPStatusError:
|
||||
logger.debug("Community telemetry endpoint returned an error: %s", endpoint, exc_info=True)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _is_heartbeat_due(cls, setup: DifySetup, now: datetime) -> bool:
|
||||
if setup.instance_id is None:
|
||||
return False
|
||||
|
||||
if setup.last_heartbeat_at is not None and setup.last_heartbeat_at.date() >= now.date():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
return value.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_os(value: str) -> str:
|
||||
os_name = value.lower()
|
||||
if os_name in {"linux", "darwin", "windows"}:
|
||||
return os_name
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_arch(value: str) -> str:
|
||||
arch = value.lower()
|
||||
if arch in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if arch in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
if arch.startswith("arm"):
|
||||
return "arm"
|
||||
if arch in {"i386", "i686", "x86"}:
|
||||
return "386"
|
||||
return "unknown"
|
||||
@@ -278,14 +278,14 @@ class VariableTruncator(BaseTruncator):
|
||||
target_length = self._array_element_limit
|
||||
|
||||
for i, item in enumerate(value):
|
||||
# ``File`` is routed through ``_truncate_json_primitives`` (whose
|
||||
# dedicated ``File`` branch returns the file as-is with its real
|
||||
# serialized size). That preserves the count cap
|
||||
# (``array_element_limit``) and the byte budget (``target_size``)
|
||||
# for ``list[File]`` — the original "Dirty fix" branch above this
|
||||
# loop bypassed both guarantees and reported ``used_size=2`` even
|
||||
# when the returned array serialized to well over the budget.
|
||||
# See https://github.com/langgenius/dify/issues/39218.
|
||||
# Dirty fix:
|
||||
# The output of `Start` node may contain list of `File` elements,
|
||||
# causing `AssertionError` while invoking `_truncate_json_primitives`.
|
||||
#
|
||||
# This check ensures that `list[File]` are handled separately
|
||||
if isinstance(item, File):
|
||||
truncated_value.append(item)
|
||||
continue
|
||||
if i >= target_length:
|
||||
return _PartResult(truncated_value, used_size, True)
|
||||
if i > 0:
|
||||
@@ -295,7 +295,7 @@ class VariableTruncator(BaseTruncator):
|
||||
break
|
||||
|
||||
remaining_budget = target_size - used_size
|
||||
if item is None or isinstance(item, (str, list, dict, bool, int, float, File, UpdatedVariable)):
|
||||
if item is None or isinstance(item, (str, list, dict, bool, int, float, UpdatedVariable)):
|
||||
part_result = self._truncate_json_primitives(item, remaining_budget)
|
||||
else:
|
||||
raise UnknownTypeError(f"got unknown type {type(item)} in array truncation")
|
||||
|
||||
@@ -116,7 +116,7 @@ class WebAppAuthService:
|
||||
|
||||
@classmethod
|
||||
def _get_account_jwt_token(cls, account: Account) -> str:
|
||||
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 24)
|
||||
exp = int(exp_dt.timestamp())
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -27,6 +27,7 @@ class WorkspaceService:
|
||||
tenant_info: dict[str, object] = {
|
||||
"id": tenant.id,
|
||||
"name": tenant.name,
|
||||
"plan": tenant.plan,
|
||||
"status": tenant.status,
|
||||
"created_at": tenant.created_at,
|
||||
"trial_end_reason": None,
|
||||
@@ -43,7 +44,6 @@ class WorkspaceService:
|
||||
tenant_info["role"] = tenant_account_join.role
|
||||
|
||||
feature = FeatureService.get_features(tenant.id, exclude_vector_space=True)
|
||||
tenant_info["plan"] = feature.billing.subscription.plan if feature.billing.enabled else None
|
||||
can_replace_logo = feature.can_replace_logo
|
||||
|
||||
if can_replace_logo and TenantService.has_roles(
|
||||
|
||||
@@ -457,7 +457,7 @@ def _publish_streaming_response(
|
||||
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
|
||||
def workflow_based_app_execution_task(
|
||||
payload: str,
|
||||
) -> Mapping[str, Any] | None:
|
||||
) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | None:
|
||||
exec_params = AppExecutionParams.model_validate_json(payload)
|
||||
|
||||
logger.info("workflow_based_app_execution_task run with params: %s", exec_params)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="community_telemetry.send_heartbeat", queue="schedule_executor")
|
||||
def send_community_telemetry_heartbeat() -> None:
|
||||
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_factory() as session:
|
||||
try:
|
||||
CommunityTelemetryService.report_heartbeat(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
@@ -17,11 +17,10 @@ class _SimpleJinja2Renderer:
|
||||
"""Minimal Jinja2-based renderer for integration tests (no code executor)."""
|
||||
|
||||
def render_template(self, template: str, variables: dict[str, object]) -> str:
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from jinja2 import Template
|
||||
|
||||
try:
|
||||
env = SandboxedEnvironment()
|
||||
return env.from_string(template).render(**variables)
|
||||
return Template(template).render(**variables)
|
||||
except Exception as exc:
|
||||
raise TemplateRenderError(str(exc)) from exc
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ class TestAppSiteApi:
|
||||
assert result["end_user_id"] == end_user.id
|
||||
assert result["plan"] == "basic"
|
||||
assert result["enable_site"] is True
|
||||
assert result["mode"] == AppMode.CHAT
|
||||
|
||||
@patch("controllers.web.site.FileService.get_file_presigned_url")
|
||||
@patch("controllers.web.site.FeatureService.get_features")
|
||||
@@ -179,7 +178,6 @@ class TestWebAppSiteResponse:
|
||||
response = WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.CHAT,
|
||||
site=_site_model(app_id=app_model.id),
|
||||
end_user_id="eu-1",
|
||||
features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True),
|
||||
@@ -187,7 +185,6 @@ class TestWebAppSiteResponse:
|
||||
)
|
||||
|
||||
assert response.app_id == app_model.id
|
||||
assert response.mode == AppMode.CHAT
|
||||
assert response.end_user_id == "eu-1"
|
||||
assert response.enable_site is True
|
||||
assert response.plan == "basic"
|
||||
@@ -212,7 +209,6 @@ class TestWebAppSiteResponse:
|
||||
response = WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.CHAT,
|
||||
site=site,
|
||||
end_user_id=None,
|
||||
features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True),
|
||||
@@ -240,7 +236,6 @@ class TestWebAppSiteResponse:
|
||||
response = WebAppSiteResponse.from_app_site(
|
||||
tenant=tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.CHAT,
|
||||
site=_site_model(app_id=app_model.id),
|
||||
end_user_id="eu-1",
|
||||
features=FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True),
|
||||
|
||||
@@ -24,10 +24,7 @@ class TestWorkspaceService:
|
||||
patch("services.workspace_service.dify_config") as mock_dify_config,
|
||||
):
|
||||
# Setup default mock returns
|
||||
feature = mock_feature_service.get_features.return_value
|
||||
feature.can_replace_logo = True
|
||||
feature.billing.enabled = True
|
||||
feature.billing.subscription.plan = "professional"
|
||||
mock_feature_service.get_features.return_value.can_replace_logo = True
|
||||
mock_tenant_service.has_roles.return_value = True
|
||||
mock_dify_config.FILES_URL = "https://example.com/files"
|
||||
|
||||
@@ -115,7 +112,7 @@ class TestWorkspaceService:
|
||||
assert result is not None
|
||||
assert result["id"] == tenant.id
|
||||
assert result["name"] == tenant.name
|
||||
assert result["plan"] == "professional"
|
||||
assert result["plan"] == tenant.plan
|
||||
assert result["status"] == tenant.status
|
||||
assert result["role"] == TenantAccountRole.OWNER
|
||||
assert result["created_at"] == tenant.created_at
|
||||
@@ -162,7 +159,7 @@ class TestWorkspaceService:
|
||||
assert result is not None
|
||||
assert result["id"] == tenant.id
|
||||
assert result["name"] == tenant.name
|
||||
assert result["plan"] == "professional"
|
||||
assert result["plan"] == tenant.plan
|
||||
assert result["status"] == tenant.status
|
||||
assert result["role"] == TenantAccountRole.OWNER
|
||||
assert result["created_at"] == tenant.created_at
|
||||
@@ -217,7 +214,7 @@ class TestWorkspaceService:
|
||||
assert result is not None
|
||||
assert result["id"] == tenant.id
|
||||
assert result["name"] == tenant.name
|
||||
assert result["plan"] == "professional"
|
||||
assert result["plan"] == tenant.plan
|
||||
assert result["status"] == tenant.status
|
||||
assert result["role"] == TenantAccountRole.NORMAL
|
||||
assert result["created_at"] == tenant.created_at
|
||||
@@ -609,23 +606,20 @@ class TestWorkspaceService:
|
||||
def test_get_tenant_info_should_not_include_cloud_fields_in_self_hosted(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""Cloud-only billing data should not appear in SELF_HOSTED mode."""
|
||||
"""next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode."""
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
|
||||
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
|
||||
feature.can_replace_logo = False
|
||||
feature.billing.enabled = False
|
||||
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False
|
||||
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
|
||||
|
||||
with patch("services.workspace_service.current_user", account):
|
||||
result = WorkspaceService.get_tenant_info(tenant, db_session_with_containers)
|
||||
|
||||
assert result is not None
|
||||
assert result["plan"] is None
|
||||
assert "next_credit_reset_date" not in result
|
||||
assert "trial_credits" not in result
|
||||
assert "trial_credits_used" not in result
|
||||
|
||||
@@ -115,7 +115,6 @@ def _agent_app_composer_response() -> dict:
|
||||
"active_config_snapshot_id": "version-1",
|
||||
},
|
||||
"active_config_snapshot": _version_response(),
|
||||
"active_config_is_published": True,
|
||||
"agent_soul": {},
|
||||
"save_options": ["save_to_current_version"],
|
||||
}
|
||||
@@ -377,7 +376,7 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
assert created["app_id"] == "app-created"
|
||||
assert created["debug_conversation_id"] == "debug-conversation-created"
|
||||
assert created["role"] == "Created role"
|
||||
assert "active_config_is_published" not in created
|
||||
assert created["active_config_is_published"] is False
|
||||
assert "bound_agent_id" not in created
|
||||
create_call = cast(dict[str, object], captured["create"])
|
||||
create_params = cast(Any, create_call["params"])
|
||||
@@ -488,7 +487,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
assert detail["debug_conversation_has_messages"] is True
|
||||
assert detail["debug_conversation_message_count"] == 2
|
||||
assert detail["role"] == "Resolved role"
|
||||
assert "active_config_is_published" not in detail
|
||||
assert detail["active_config_is_published"] is False
|
||||
assert "bound_agent_id" not in detail
|
||||
assert captured["get_app"] == {"app": app_model, "session": session}
|
||||
with app.test_request_context(
|
||||
@@ -503,7 +502,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
assert updated["debug_conversation_has_messages"] is True
|
||||
assert updated["debug_conversation_message_count"] == 2
|
||||
assert updated["role"] == "Resolved role"
|
||||
assert "active_config_is_published" not in updated
|
||||
assert updated["active_config_is_published"] is False
|
||||
assert "bound_agent_id" not in updated
|
||||
update_call = cast(dict[str, object], captured["update"])
|
||||
assert update_call["app"] is app_model
|
||||
@@ -846,6 +845,9 @@ def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.Monk
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "active_config_is_published", lambda _self, **kwargs: False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.FeatureService,
|
||||
"get_system_features",
|
||||
@@ -1297,14 +1299,13 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates)
|
||||
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
|
||||
assert composer["variant"] == "agent_app"
|
||||
assert composer["active_config_is_published"] is True
|
||||
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)["variant"] == "agent_app"
|
||||
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
|
||||
with app.test_request_context(json=payload):
|
||||
saved_composer = unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id)
|
||||
assert saved_composer["variant"] == "agent_app"
|
||||
assert saved_composer["active_config_is_published"] is True
|
||||
assert (
|
||||
unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id)["variant"]
|
||||
== "agent_app"
|
||||
)
|
||||
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
|
||||
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), MagicMock(), "tenant-1", agent_id) == {
|
||||
"result": "success",
|
||||
|
||||
@@ -15,11 +15,9 @@ from controllers.console.workspace.model_providers import (
|
||||
ModelProviderIconApi,
|
||||
ModelProviderListApi,
|
||||
ModelProviderPaymentCheckoutUrlApi,
|
||||
ModelProviderSummaryListApi,
|
||||
ModelProviderValidateApi,
|
||||
PreferredProviderTypeUpdateApi,
|
||||
)
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
@@ -29,10 +27,6 @@ from models.provider import ProviderType
|
||||
from services.entities.model_provider_entities import (
|
||||
CustomConfigurationResponse,
|
||||
CustomConfigurationStatus,
|
||||
ModelProviderCustomConfigurationSummaryResponse,
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
ModelProviderSystemConfigurationSummaryResponse,
|
||||
ProviderResponse,
|
||||
SystemConfigurationResponse,
|
||||
)
|
||||
@@ -146,80 +140,6 @@ class TestModelProviderListApi:
|
||||
assert result == {"data": []}
|
||||
|
||||
|
||||
class TestModelProviderSummaryListApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
api = ModelProviderSummaryListApi()
|
||||
method = unwrap(api.get)
|
||||
provider = ModelProviderSummaryResponse(
|
||||
tenant_id="tenant1",
|
||||
provider="langgenius/openai/openai",
|
||||
plugin_id="langgenius/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=I18nObject(en_US="OpenAI models"),
|
||||
icon_small=I18nObject(en_US="icon.svg"),
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
is_configured=True,
|
||||
custom_configuration=ModelProviderCustomConfigurationSummaryResponse(
|
||||
status=CustomConfigurationStatus.ACTIVE,
|
||||
available_credentials=[
|
||||
CredentialConfiguration(
|
||||
credential_id=VALID_UUID,
|
||||
credential_name="production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="223e4567-e89b-12d3-a456-426614174000",
|
||||
credential_name="backup",
|
||||
),
|
||||
],
|
||||
current_credential_id=VALID_UUID,
|
||||
current_credential_name="production",
|
||||
current_credential_usable=True,
|
||||
),
|
||||
system_configuration=ModelProviderSystemConfigurationSummaryResponse(enabled=False),
|
||||
)
|
||||
plugin = ModelProviderPluginSummaryResponse(
|
||||
installation_id="installation-1",
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source="marketplace",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.workspace.model_providers.ModelProviderService.get_provider_summary_list",
|
||||
return_value=([provider], {"langgenius/openai": plugin}),
|
||||
) as get_provider_summary_list,
|
||||
):
|
||||
result = method(api, "tenant1")
|
||||
|
||||
get_provider_summary_list.assert_called_once_with(tenant_id="tenant1")
|
||||
assert result["data"][0]["provider"] == "langgenius/openai/openai"
|
||||
assert "tenant_id" not in result["data"][0]
|
||||
assert result["data"][0]["custom_configuration"] == {
|
||||
"status": "active",
|
||||
"available_credentials": [
|
||||
{
|
||||
"credential_id": VALID_UUID,
|
||||
"credential_name": "production",
|
||||
},
|
||||
{
|
||||
"credential_id": "223e4567-e89b-12d3-a456-426614174000",
|
||||
"credential_name": "backup",
|
||||
},
|
||||
],
|
||||
"current_credential_id": VALID_UUID,
|
||||
"current_credential_name": "production",
|
||||
"current_credential_usable": True,
|
||||
}
|
||||
assert result["plugins"]["langgenius/openai"]["installation_id"] == "installation-1"
|
||||
|
||||
|
||||
class TestModelProviderCredentialApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
api = ModelProviderCredentialApi()
|
||||
|
||||
@@ -28,7 +28,6 @@ from controllers.console.workspace.plugin import (
|
||||
PluginFetchMarketplacePkgApi,
|
||||
PluginFetchPermissionApi,
|
||||
PluginIconApi,
|
||||
PluginInstalledIdsApi,
|
||||
PluginInstallFromGithubApi,
|
||||
PluginInstallFromMarketplaceApi,
|
||||
PluginInstallFromPkgApi,
|
||||
@@ -42,16 +41,12 @@ from controllers.console.workspace.plugin import (
|
||||
PluginUploadFromBundleApi,
|
||||
PluginUploadFromGithubApi,
|
||||
PluginUploadFromPkgApi,
|
||||
_list_hardcoded_builtin_tool_providers,
|
||||
)
|
||||
from core.plugin.entities.parameters import PluginParameterOption
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginDeclaration, PluginEntity, PluginInstallation
|
||||
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallation
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.entities.api_entities import ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from models.account import (
|
||||
Account,
|
||||
TenantAccountRole,
|
||||
@@ -450,7 +445,7 @@ class TestPluginCategoryListApi:
|
||||
mock_list = MagicMock(list=[plugin_item], has_more=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?page=2&page_size=10&query=weather&tags=search&tags=rag&language=zh_Hans"),
|
||||
app.test_request_context("/?page=2&page_size=10"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
|
||||
) as list_mock,
|
||||
@@ -461,75 +456,18 @@ class TestPluginCategoryListApi:
|
||||
):
|
||||
result = method(api, "t1", "tool")
|
||||
|
||||
list_mock.assert_called_once_with(
|
||||
"t1",
|
||||
"tool",
|
||||
2,
|
||||
10,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
list_mock.assert_called_once()
|
||||
assert list_mock.call_args.args[0] == "t1"
|
||||
assert list_mock.call_args.args[1] == "tool"
|
||||
assert list_mock.call_args.args[2] == 2
|
||||
assert list_mock.call_args.args[3] == 10
|
||||
assert result["plugins"][0]["id"] == "entity-1"
|
||||
assert result["plugins"][0]["plugin_unique_identifier"] == "test-author/test-plugin:1.0.0@checksum"
|
||||
assert result["builtin_tools"][0]["id"] == "builtin"
|
||||
assert result["builtin_tools"][0]["type"] == "builtin"
|
||||
assert result["has_more"] is True
|
||||
assert "total" not in result
|
||||
builtin_mock.assert_called_once_with(
|
||||
"t1",
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
def test_builtin_tool_providers_use_the_category_list_filters(self):
|
||||
search_provider = ToolProviderApiEntity(
|
||||
id="search-provider",
|
||||
author="dify",
|
||||
name="search-provider",
|
||||
description=I18nObject(en_US="Search provider", zh_Hans="搜索工具"),
|
||||
icon="icon.svg",
|
||||
label=I18nObject(en_US="Search", zh_Hans="搜索"),
|
||||
type=ToolProviderType.BUILT_IN,
|
||||
labels=["search"],
|
||||
)
|
||||
rag_provider = ToolProviderApiEntity(
|
||||
id="rag-provider",
|
||||
author="dify",
|
||||
name="rag-provider",
|
||||
description=I18nObject(en_US="RAG provider", zh_Hans="知识库工具"),
|
||||
icon="icon.svg",
|
||||
label=I18nObject(en_US="RAG", zh_Hans="知识库"),
|
||||
type=ToolProviderType.BUILT_IN,
|
||||
labels=["rag"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("controllers.console.workspace.plugin.ToolManager.list_default_builtin_providers", return_value=[]),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.ToolManager.list_hardcoded_providers",
|
||||
return_value=[MagicMock(), MagicMock()],
|
||||
),
|
||||
patch("controllers.console.workspace.plugin.is_filtered", return_value=False),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.ToolTransformService.builtin_provider_to_user_provider",
|
||||
side_effect=[search_provider, rag_provider],
|
||||
),
|
||||
patch("controllers.console.workspace.plugin.ToolTransformService.repack_provider"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.BuiltinToolProviderSort.sort",
|
||||
side_effect=lambda providers: providers,
|
||||
),
|
||||
):
|
||||
result = _list_hardcoded_builtin_tool_providers(
|
||||
"t1",
|
||||
query="搜索",
|
||||
tags=["search", "weather"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
assert [provider["id"] for provider in result] == ["search-provider"]
|
||||
builtin_mock.assert_called_once_with("t1")
|
||||
|
||||
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
|
||||
api = PluginCategoryListApi()
|
||||
@@ -792,39 +730,6 @@ class TestPluginListInstallationsFromIdsApi:
|
||||
assert result == ({"code": "plugin_error", "message": "error"}, 400)
|
||||
|
||||
|
||||
class TestPluginInstalledIdsApi:
|
||||
def test_success(self, app: Flask):
|
||||
api = PluginInstalledIdsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?category=tool"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
|
||||
return_value=["langgenius/openai", "langgenius/anthropic"],
|
||||
) as list_installed_plugin_ids,
|
||||
):
|
||||
result = method(api, "t1")
|
||||
|
||||
assert result == {"plugin_ids": ["langgenius/openai", "langgenius/anthropic"]}
|
||||
list_installed_plugin_ids.assert_called_once_with("t1", PluginCategory.Tool)
|
||||
|
||||
def test_daemon_error(self, app: Flask):
|
||||
api = PluginInstalledIdsApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?category=tool"),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginService.list_installed_plugin_ids",
|
||||
side_effect=PluginDaemonClientSideError("error"),
|
||||
),
|
||||
):
|
||||
result = method(api, "t1")
|
||||
|
||||
assert result == ({"code": "plugin_error", "message": "error"}, 400)
|
||||
|
||||
|
||||
class TestPluginUploadFromGithubApi:
|
||||
def test_success(self, app: Flask, user):
|
||||
api = PluginUploadFromGithubApi()
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import inspect
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.workspace.plugin_model_providers import (
|
||||
EnterprisePluginModelProvidersCacheInvalidate,
|
||||
InvalidatePluginModelProvidersCachePayload,
|
||||
)
|
||||
|
||||
|
||||
class TestInvalidatePluginModelProvidersCachePayload:
|
||||
def test_valid_payload(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
|
||||
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
|
||||
)
|
||||
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
|
||||
|
||||
def test_missing_tenant_ids_defaults_to_empty(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
|
||||
assert payload.tenant_ids == []
|
||||
|
||||
def test_unknown_field_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
|
||||
|
||||
|
||||
class TestEnterprisePluginModelProvidersCacheInvalidate:
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterprisePluginModelProvidersCacheInvalidate()
|
||||
|
||||
def _post(self, api_instance, app: Flask, payload):
|
||||
unwrapped_post = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = payload
|
||||
return unwrapped_post(api_instance)
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
|
||||
call("tenant-alpha"),
|
||||
call("tenant-beta"),
|
||||
]
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": []})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, None)
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
@@ -634,11 +634,6 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
|
||||
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
|
||||
operation = payload["paths"]["/workspaces/current/plugin/{category}/list"]["get"]
|
||||
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
|
||||
assert parameters["query"]["in"] == "query"
|
||||
assert parameters["tags"]["in"] == "query"
|
||||
assert parameters["tags"]["schema"]["type"] == "array"
|
||||
assert parameters["language"]["in"] == "query"
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
@@ -666,112 +661,3 @@ def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path)
|
||||
builtin_tool_schema = schemas["PluginCategoryBuiltinToolProviderResponse"]
|
||||
for field in ("plugin_unique_identifier", "team_credentials", "type", "tools"):
|
||||
assert field in builtin_tool_schema["properties"]
|
||||
|
||||
|
||||
def test_console_installed_plugin_ids_exported_schema_is_lightweight(tmp_path):
|
||||
from dev.generate_swagger_specs import generate_specs
|
||||
|
||||
written_paths = generate_specs(tmp_path)
|
||||
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
|
||||
operation = payload["paths"]["/workspaces/current/plugin/installed-ids"]["get"]
|
||||
parameters = {parameter["name"]: parameter for parameter in operation["parameters"]}
|
||||
assert parameters["category"]["in"] == "query"
|
||||
assert parameters["category"]["required"] is True
|
||||
assert parameters["category"]["schema"]["enum"] == [
|
||||
"agent-strategy",
|
||||
"datasource",
|
||||
"extension",
|
||||
"model",
|
||||
"tool",
|
||||
"trigger",
|
||||
]
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
response_schema = payload["components"]["schemas"][response_ref]
|
||||
|
||||
assert response_schema["required"] == ["plugin_ids"]
|
||||
assert response_schema["properties"] == {
|
||||
"plugin_ids": {
|
||||
"items": {"type": "string"},
|
||||
"title": "Plugin Ids",
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_console_model_provider_summary_exported_schema_is_lightweight(tmp_path):
|
||||
from dev.generate_swagger_specs import generate_specs
|
||||
|
||||
written_paths = generate_specs(tmp_path)
|
||||
console_openapi_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_openapi_path.read_text(encoding="utf-8"))
|
||||
operation = payload["paths"]["/workspaces/current/model-providers/summary"]["get"]
|
||||
assert operation.get("parameters", []) == []
|
||||
|
||||
response_ref = operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].removeprefix(
|
||||
"#/components/schemas/"
|
||||
)
|
||||
response_schema = payload["components"]["schemas"][response_ref]
|
||||
assert response_schema["required"] == ["data", "plugins"]
|
||||
assert response_schema["properties"]["data"]["items"]["$ref"] == (
|
||||
"#/components/schemas/ModelProviderSummaryResponse"
|
||||
)
|
||||
assert response_schema["properties"]["plugins"]["additionalProperties"]["$ref"] == (
|
||||
"#/components/schemas/ModelProviderPluginSummaryResponse"
|
||||
)
|
||||
|
||||
provider_properties = payload["components"]["schemas"]["ModelProviderSummaryResponse"]["properties"]
|
||||
assert set(provider_properties) == {
|
||||
"configurate_methods",
|
||||
"custom_configuration",
|
||||
"description",
|
||||
"icon_small",
|
||||
"icon_small_dark",
|
||||
"is_configured",
|
||||
"label",
|
||||
"plugin_id",
|
||||
"preferred_provider_type",
|
||||
"provider",
|
||||
"supported_model_types",
|
||||
"system_configuration",
|
||||
}
|
||||
assert "provider_credential_schema" not in provider_properties
|
||||
assert "model_credential_schema" not in provider_properties
|
||||
|
||||
custom_configuration_schema = payload["components"]["schemas"]["ModelProviderCustomConfigurationSummaryResponse"]
|
||||
custom_configuration_properties = custom_configuration_schema["properties"]
|
||||
assert set(custom_configuration_schema["required"]) == {
|
||||
"available_credentials",
|
||||
"current_credential_usable",
|
||||
"status",
|
||||
}
|
||||
assert set(custom_configuration_properties) == {
|
||||
"available_credentials",
|
||||
"current_credential_id",
|
||||
"current_credential_name",
|
||||
"current_credential_usable",
|
||||
"status",
|
||||
}
|
||||
assert custom_configuration_properties["available_credentials"]["items"]["$ref"] == (
|
||||
"#/components/schemas/CredentialConfiguration"
|
||||
)
|
||||
assert "has_credentials" not in custom_configuration_properties
|
||||
|
||||
credential_properties = payload["components"]["schemas"]["CredentialConfiguration"]["properties"]
|
||||
assert set(credential_properties) == {
|
||||
"credential_id",
|
||||
"credential_name",
|
||||
}
|
||||
assert "encrypted_config" not in credential_properties
|
||||
|
||||
plugin_properties = payload["components"]["schemas"]["ModelProviderPluginSummaryResponse"]["properties"]
|
||||
assert set(plugin_properties) == {
|
||||
"installation_id",
|
||||
"plugin_id",
|
||||
"plugin_unique_identifier",
|
||||
"runtime_type",
|
||||
"source",
|
||||
"version",
|
||||
}
|
||||
|
||||
@@ -163,7 +163,6 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask, dat
|
||||
assert body["expiration_time"] == int(expiration_time.timestamp())
|
||||
assert body["site"] == {
|
||||
"app_id": app_model.id,
|
||||
"mode": "chat",
|
||||
"end_user_id": None,
|
||||
"enable_site": True,
|
||||
"site": {
|
||||
@@ -384,7 +383,6 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
|
||||
assert body["expiration_time"] == int(expiration_time.timestamp())
|
||||
assert body["site"] == {
|
||||
"app_id": app_model.id,
|
||||
"mode": "chat",
|
||||
"end_user_id": None,
|
||||
"enable_site": True,
|
||||
"site": {
|
||||
|
||||
@@ -3,42 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
from configs import dify_config
|
||||
from controllers.web import site as site_module
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.model import AppMode, IconType, Site
|
||||
from services.feature_service import FeatureModel
|
||||
|
||||
|
||||
def test_app_site_api_returns_legacy_agent_compatible_mode() -> None:
|
||||
app_model = MagicMock()
|
||||
app_model.id = "app-id"
|
||||
app_model.tenant_id = "tenant-id"
|
||||
app_model.tenant = MagicMock(id="tenant-id", status="normal")
|
||||
app_model.mode_compatible_with_agent_with_session.return_value = AppMode.AGENT_CHAT
|
||||
end_user = MagicMock(id="end-user-id")
|
||||
site = MagicMock(spec=Site)
|
||||
response = MagicMock()
|
||||
response.model_dump.return_value = {"mode": AppMode.AGENT_CHAT}
|
||||
|
||||
with (
|
||||
patch.object(site_module, "db") as mock_db,
|
||||
patch.object(site_module.FeatureService, "get_features", return_value=FeatureModel(can_replace_logo=False)),
|
||||
patch.object(site_module, "_build_site_icon_url", return_value=None),
|
||||
patch.object(site_module.WebAppSiteResponse, "from_app_site", return_value=response) as mock_from_app_site,
|
||||
):
|
||||
mock_db.session.scalar.return_value = site
|
||||
result = site_module.AppSiteApi().get(app_model, end_user)
|
||||
|
||||
assert result["mode"] == AppMode.AGENT_CHAT
|
||||
app_model.mode_compatible_with_agent_with_session.assert_called_once_with(session=mock_db.session())
|
||||
mock_from_app_site.assert_called_once_with(
|
||||
tenant=app_model.tenant,
|
||||
app_model=app_model,
|
||||
mode=AppMode.AGENT_CHAT,
|
||||
site=site,
|
||||
end_user_id=end_user.id,
|
||||
features=FeatureModel(can_replace_logo=False),
|
||||
can_replace_logo=False,
|
||||
icon_url=None,
|
||||
)
|
||||
from models.model import IconType, Site
|
||||
|
||||
|
||||
def test_build_site_icon_url_uses_s3_presigned_url() -> None:
|
||||
|
||||
@@ -442,13 +442,6 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
def start(self):
|
||||
thread_data["started"] = True
|
||||
|
||||
def join(self, timeout):
|
||||
thread_data["joined"] = True
|
||||
thread_data["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
|
||||
@@ -482,8 +475,6 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
|
||||
assert response["response"] == {"raw": True}
|
||||
assert thread_data["started"] is True
|
||||
assert thread_data["joined"] is True
|
||||
assert thread_data["join_timeout"] == 300
|
||||
assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"]
|
||||
assert generator._dialogue_count == 3
|
||||
assert init_records.call_args.kwargs["session"] is db_session
|
||||
@@ -551,13 +542,6 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
def start(self):
|
||||
thread_data["started"] = True
|
||||
|
||||
def join(self, timeout):
|
||||
thread_data["joined"] = True
|
||||
thread_data["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
|
||||
@@ -590,8 +574,6 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
init_records.assert_not_called()
|
||||
get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session)
|
||||
assert thread_data["started"] is True
|
||||
assert thread_data["joined"] is True
|
||||
assert thread_data["join_timeout"] == 300
|
||||
db_session.commit.assert_not_called()
|
||||
db_session.refresh.assert_not_called()
|
||||
db_session.close.assert_called_once()
|
||||
|
||||
@@ -435,7 +435,6 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
|
||||
mocker.patch.object(module, "PipelineQueueManager", return_value=queue_manager)
|
||||
|
||||
worker_thread = MagicMock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
mocker.patch.object(module.threading, "Thread", return_value=worker_thread)
|
||||
|
||||
mocker.patch.object(generator, "_get_draft_var_saver_factory", return_value=MagicMock())
|
||||
@@ -462,7 +461,6 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
|
||||
)
|
||||
|
||||
assert result == "converted"
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
|
||||
def test_single_iteration_generate_validates_inputs(generator, mocker: MockerFixture):
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
@@ -372,58 +369,6 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default():
|
||||
|
||||
|
||||
class TestBaseAppGeneratorExtras:
|
||||
def test_wrap_stream_joins_worker_after_stream_exhaustion(self):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
|
||||
response_stream(),
|
||||
worker_thread,
|
||||
)
|
||||
|
||||
assert next(managed_stream) == {"event": "workflow_finished"}
|
||||
worker_thread.join.assert_not_called()
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
next(managed_stream)
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_wrap_stream_joins_worker_when_stream_closes(self):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_started"}
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
|
||||
response_stream(),
|
||||
worker_thread,
|
||||
)
|
||||
|
||||
assert next(managed_stream) == {"event": "workflow_started"}
|
||||
managed_stream.close()
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_join_worker_thread_warns_when_thread_remains_alive(self, caplog: pytest.LogCaptureFixture):
|
||||
worker_thread = Mock()
|
||||
worker_thread.name = "leaked-app-worker"
|
||||
worker_thread.is_alive.return_value = True
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_generator"):
|
||||
BaseAppGenerator._join_worker_thread(worker_thread)
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
assert "Possible app worker thread leak" in caplog.text
|
||||
assert "leaked-app-worker" in caplog.text
|
||||
|
||||
def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
|
||||
|
||||
@@ -211,13 +211,6 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout):
|
||||
worker_kwargs["joined"] = True
|
||||
worker_kwargs["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
|
||||
|
||||
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
|
||||
@@ -251,8 +244,6 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
assert result == "converted"
|
||||
assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
|
||||
assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
|
||||
assert worker_kwargs["joined"] is True
|
||||
assert worker_kwargs["join_timeout"] == 300
|
||||
assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id
|
||||
|
||||
|
||||
@@ -295,8 +286,6 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppRunner", side_effect=runner_ctor)
|
||||
|
||||
worker_lifecycle: dict[str, bool] = {}
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, kwargs):
|
||||
target(**kwargs)
|
||||
@@ -304,13 +293,6 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout):
|
||||
worker_lifecycle["joined"] = True
|
||||
worker_lifecycle["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread)
|
||||
|
||||
mocker.patch(
|
||||
@@ -349,7 +331,5 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
assert result == "raw-response"
|
||||
assert worker_lifecycle["joined"] is True
|
||||
assert worker_lifecycle["join_timeout"] == 300
|
||||
runner_instance.run.assert_called_once()
|
||||
queue_manager.graph_runtime_state = runtime_state
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.workflow.active_workflow_tasks import (
|
||||
active_workflow_task,
|
||||
get_active_workflow_task_count,
|
||||
@@ -32,51 +28,3 @@ def test_active_workflow_task_rejects_duplicate_task_id() -> None:
|
||||
with pytest.raises(ValueError, match="already active"):
|
||||
with active_workflow_task("task-a"):
|
||||
pass
|
||||
|
||||
|
||||
def test_managed_stream_waits_for_active_worker_cleanup() -> None:
|
||||
worker_started = threading.Event()
|
||||
release_worker = threading.Event()
|
||||
stream_exhausted = threading.Event()
|
||||
consumer_finished = threading.Event()
|
||||
consumer_errors: list[BaseException] = []
|
||||
|
||||
def run_worker() -> None:
|
||||
with active_workflow_task("task-a"):
|
||||
worker_started.set()
|
||||
release_worker.wait()
|
||||
|
||||
def response_stream() -> Generator[dict[str, str], None, None]:
|
||||
yield {"event": "workflow_finished"}
|
||||
stream_exhausted.set()
|
||||
|
||||
worker_thread = threading.Thread(target=run_worker)
|
||||
worker_thread.start()
|
||||
assert worker_started.wait(timeout=2)
|
||||
|
||||
managed_stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response_stream(), worker_thread)
|
||||
assert next(managed_stream) == {"event": "workflow_finished"}
|
||||
|
||||
def finish_stream() -> None:
|
||||
try:
|
||||
list(managed_stream)
|
||||
except BaseException as exc:
|
||||
consumer_errors.append(exc)
|
||||
finally:
|
||||
consumer_finished.set()
|
||||
|
||||
consumer_thread = threading.Thread(target=finish_stream)
|
||||
consumer_thread.start()
|
||||
try:
|
||||
assert stream_exhausted.wait(timeout=2)
|
||||
assert not consumer_finished.is_set()
|
||||
assert get_active_workflow_task_count() == 1
|
||||
finally:
|
||||
release_worker.set()
|
||||
consumer_thread.join(timeout=2)
|
||||
worker_thread.join(timeout=2)
|
||||
|
||||
assert not consumer_thread.is_alive()
|
||||
assert not worker_thread.is_alive()
|
||||
assert consumer_errors == []
|
||||
assert get_active_workflow_task_count() == 0
|
||||
|
||||
@@ -15,70 +15,6 @@ from models.model import AppMode
|
||||
|
||||
|
||||
class TestWorkflowAppGeneratorValidation:
|
||||
def test_generate_stream_joins_worker_after_response_exhaustion(self, monkeypatch: pytest.MonkeyPatch):
|
||||
generator = WorkflowAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
app_config = WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant",
|
||||
app_id="app",
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
additional_features=AppAdditionalFeatures(),
|
||||
variables=[],
|
||||
workflow_id="workflow-id",
|
||||
)
|
||||
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
|
||||
task_id="task",
|
||||
app_config=app_config,
|
||||
inputs={},
|
||||
files=[],
|
||||
user_id="user",
|
||||
stream=True,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
extras={},
|
||||
)
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **kwargs: contextlib.nullcontext())
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppQueueManager",
|
||||
lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.current_app",
|
||||
SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")),
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.contextvars.copy_context", lambda: "ctx")
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", lambda **kwargs: worker_thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.db",
|
||||
SimpleNamespace(session=SimpleNamespace(close=Mock())),
|
||||
)
|
||||
monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory")
|
||||
monkeypatch.setattr(generator, "_handle_response", lambda **kwargs: response_stream())
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert",
|
||||
lambda response, invoke_from: response,
|
||||
)
|
||||
|
||||
managed_stream = generator._generate(
|
||||
app_model=SimpleNamespace(mode=AppMode.WORKFLOW, tenant_id="tenant"),
|
||||
workflow=SimpleNamespace(id="workflow-id"),
|
||||
user=SimpleNamespace(id="user"),
|
||||
application_generate_entity=application_generate_entity,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
workflow_execution_repository=SimpleNamespace(),
|
||||
workflow_node_execution_repository=SimpleNamespace(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
worker_thread.start.assert_called_once_with()
|
||||
worker_thread.join.assert_not_called()
|
||||
assert list(managed_stream) == [{"event": "workflow_finished"}]
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self):
|
||||
workflow = SimpleNamespace(kind_or_standard="workflow")
|
||||
session = SimpleNamespace(scalar=Mock())
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Verify that Jinja2 transform_caller scripts block unsafe template attacks via SandboxedEnvironment."""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTransformer
|
||||
|
||||
MALICIOUS_TEMPLATES = [
|
||||
pytest.param(
|
||||
"{{ lipsum.__globals__.__builtins__.__import__('os').popen('id').read() }}",
|
||||
id="lipsum_globals_builtins",
|
||||
),
|
||||
pytest.param(
|
||||
"{{ ''.__class__.__mro__[1].__subclasses__() }}",
|
||||
id="string_class_mro",
|
||||
),
|
||||
pytest.param(
|
||||
"{{ cycler.__init__.__globals__.os.popen('whoami').read() }}",
|
||||
id="cycler_init_globals",
|
||||
),
|
||||
pytest.param(
|
||||
"{{ namespace.__init__.__globals__['__builtins__']['__import__']('os').system('id') }}",
|
||||
id="namespace_init_globals",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _exec_scripts(runner: str, preload: str) -> str:
|
||||
"""Execute preload then runner in a shared namespace, return captured stdout."""
|
||||
ns: dict = {}
|
||||
exec(compile(preload, "<preload>", "exec"), ns) # noqa: S102
|
||||
captured = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = captured
|
||||
try:
|
||||
exec(compile(runner, "<runner>", "exec"), ns) # noqa: S102
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
return captured.getvalue()
|
||||
|
||||
|
||||
class TestJinja2TransformCallerSandbox:
|
||||
"""Test transform_caller output (runner + preload) blocks attacks and allows safe templates."""
|
||||
|
||||
@pytest.mark.parametrize("malicious_template", MALICIOUS_TEMPLATES)
|
||||
def test_blocks_unsafe_template(self, malicious_template: str) -> None:
|
||||
runner, preload = Jinja2TemplateTransformer.transform_caller(malicious_template, {})
|
||||
ns: dict = {}
|
||||
exec(compile(preload, "<preload>", "exec"), ns) # noqa: S102
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
exec(compile(runner, "<runner>", "exec"), ns) # noqa: S102
|
||||
assert "unsafe" in str(exc_info.value).lower() or "security" in str(exc_info.value).lower()
|
||||
|
||||
def test_renders_safe_template(self) -> None:
|
||||
runner, preload = Jinja2TemplateTransformer.transform_caller(
|
||||
"Hello {{ name }}, you are {{ age }} years old!",
|
||||
{"name": "Alice", "age": 30},
|
||||
)
|
||||
output = _exec_scripts(runner, preload)
|
||||
assert "Hello Alice, you are 30 years old!" in output
|
||||
|
||||
def test_scripts_use_sandboxed_environment(self) -> None:
|
||||
runner, preload = Jinja2TemplateTransformer.transform_caller("{{ x }}", {"x": 1})
|
||||
assert "SandboxedEnvironment" in runner
|
||||
assert "SandboxedEnvironment" in preload
|
||||
@@ -28,19 +28,6 @@ class TestPluginModelClient:
|
||||
)
|
||||
assert request_mock.call_args.kwargs["params"] == {"page": 1, "page_size": 256}
|
||||
|
||||
def test_fetch_model_provider_bindings(self, mocker: MockerFixture):
|
||||
client = PluginModelClient()
|
||||
request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["binding-a"])
|
||||
|
||||
result = client.fetch_model_provider_bindings("tenant-1")
|
||||
|
||||
assert result == ["binding-a"]
|
||||
assert request_mock.call_args.args[:2] == (
|
||||
"GET",
|
||||
"plugin/tenant-1/management/models/bindings",
|
||||
)
|
||||
assert "params" not in request_mock.call_args.kwargs
|
||||
|
||||
def test_get_model_schema(self, mocker: MockerFixture):
|
||||
client = PluginModelClient()
|
||||
schema = SimpleNamespace(name="schema")
|
||||
|
||||
@@ -29,7 +29,6 @@ from core.plugin.entities.plugin import (
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStartResponse,
|
||||
PluginInstallTaskStatus,
|
||||
@@ -133,13 +132,7 @@ class TestPluginDiscovery:
|
||||
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
|
||||
) as mock_request:
|
||||
result = plugin_installer.list_plugins_by_category(
|
||||
"test-tenant",
|
||||
category=PluginCategory.Tool,
|
||||
page=2,
|
||||
page_size=10,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
"test-tenant", category=PluginCategory.Tool, page=2, page_size=10
|
||||
)
|
||||
|
||||
mock_request.assert_called_once()
|
||||
@@ -148,9 +141,6 @@ class TestPluginDiscovery:
|
||||
assert call_args.args[2] is PluginListWithoutTotalResponse
|
||||
assert call_args.kwargs["params"]["page"] == 2
|
||||
assert call_args.kwargs["params"]["page_size"] == 10
|
||||
assert call_args.kwargs["params"]["query"] == "weather"
|
||||
assert call_args.kwargs["params"]["tags"] == ["search", "rag"]
|
||||
assert call_args.kwargs["params"]["language"] == "zh_Hans"
|
||||
assert result.list == [mock_plugin_entity]
|
||||
assert result.has_more is True
|
||||
|
||||
@@ -166,23 +156,6 @@ class TestPluginDiscovery:
|
||||
# Assert: Verify empty list is returned
|
||||
assert len(result) == 0
|
||||
|
||||
def test_list_installed_plugin_ids(self, plugin_installer):
|
||||
"""The lightweight ID endpoint is unpaginated and does not request plugin details."""
|
||||
mock_response = PluginInstalledIdsDaemonResponse(plugin_ids=["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
with patch.object(
|
||||
plugin_installer, "_request_with_plugin_daemon_response", return_value=mock_response
|
||||
) as mock_request:
|
||||
result = plugin_installer.list_installed_plugin_ids("test-tenant", PluginCategory.Tool)
|
||||
|
||||
mock_request.assert_called_once_with(
|
||||
"GET",
|
||||
"plugin/test-tenant/management/installation/ids",
|
||||
PluginInstalledIdsDaemonResponse,
|
||||
params={"category": "tool"},
|
||||
)
|
||||
assert result == ["langgenius/openai", "langgenius/anthropic"]
|
||||
|
||||
def test_fetch_plugin_by_identifier_found(self, plugin_installer):
|
||||
"""Test fetching a plugin by its unique identifier when it exists."""
|
||||
# Arrange: Mock successful fetch
|
||||
|
||||
@@ -22,23 +22,6 @@ class TestCleanProcessor:
|
||||
expected = "normalpadding"
|
||||
assert CleanProcessor.clean(text_with_ufffe, None) == expected
|
||||
|
||||
def test_clean_preserves_valid_extended_characters(self):
|
||||
"""Default cleaning must not strip valid printable characters.
|
||||
|
||||
The invalid-symbol filter used to include the UTF-8 bytes of U+FFFE
|
||||
(0xEF 0xBF 0xBE) inside a character class. On a decoded string those
|
||||
bytes are the code points U+00EF, U+00BF and U+00BE, i.e. the valid
|
||||
characters 'ï', '¿' and '¾', so words like "naïve" and Spanish
|
||||
questions like "¿Cómo?" were being silently corrupted on ingest.
|
||||
"""
|
||||
assert CleanProcessor.clean("naïve", None) == "naïve"
|
||||
assert CleanProcessor.clean("¿Cómo estás?", None) == "¿Cómo estás?"
|
||||
assert CleanProcessor.clean("¾ cup sugar", None) == "¾ cup sugar"
|
||||
assert CleanProcessor.clean("￾", None) == "￾"
|
||||
|
||||
# The U+FFFE noncharacter is still stripped by its dedicated substitution.
|
||||
assert CleanProcessor.clean("keep\ufffedrop", None) == "keepdrop"
|
||||
|
||||
def test_clean_with_none_process_rule(self):
|
||||
"""Test cleaning with None process_rule - only default cleaning applied."""
|
||||
text = "Hello<|World\x00"
|
||||
|
||||
@@ -1372,19 +1372,6 @@ class TestIndexingRunnerDocumentCleaning:
|
||||
assert "\ufffe" not in result
|
||||
assert "Text with" in result
|
||||
|
||||
def test_filter_string_preserves_valid_extended_characters(self):
|
||||
"""filter_string must keep valid printable characters like 'ï', '¿', '¾'."""
|
||||
# Arrange
|
||||
text = "naïve ¿Cómo? ¾ done"
|
||||
|
||||
# Act
|
||||
result = IndexingRunner.filter_string(text)
|
||||
|
||||
# Assert
|
||||
assert result == text
|
||||
# The U+FFFE noncharacter is still stripped.
|
||||
assert IndexingRunner.filter_string("keep\ufffedrop") == "keepdrop"
|
||||
|
||||
|
||||
class TestIndexingRunnerSplitter:
|
||||
"""Unit tests for text splitter configuration.
|
||||
|
||||
@@ -952,21 +952,6 @@ class TestFixedRecursiveCharacterTextSplitter:
|
||||
assert "word1" in combined
|
||||
assert "word2" in combined
|
||||
|
||||
def test_preserves_spaces_when_recursively_splitting_long_paragraph(self):
|
||||
"""Ensure recursive space splitting preserves word boundaries."""
|
||||
text = "여름철에는 항상 기상상황에 주목하며 주변 사람들과 함께 정보를 공유합니다."
|
||||
splitter = FixedRecursiveCharacterTextSplitter(
|
||||
fixed_separator="\n\n",
|
||||
chunk_size=20,
|
||||
chunk_overlap=0,
|
||||
keep_separator=True,
|
||||
)
|
||||
|
||||
result = splitter.split_text(text)
|
||||
|
||||
assert len(result) > 1
|
||||
assert " ".join(result) == text
|
||||
|
||||
def test_character_level_splitting(self):
|
||||
"""Test character-level splitting when no separator works."""
|
||||
text = "verylongwordwithoutspaces"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from extensions.ext_celery import _enqueue_initial_community_telemetry_heartbeat
|
||||
|
||||
|
||||
def test_beat_start_enqueues_community_telemetry_heartbeat() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_called_once_with()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_not_scheduled() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_task_is_unavailable() -> None:
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
@@ -548,7 +548,6 @@ def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.Mon
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by="account-1",
|
||||
created_by="account-1",
|
||||
app_id="app-1",
|
||||
@@ -568,7 +567,6 @@ def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.Mon
|
||||
result = AgentComposerService.load_agent_app_composer(session=session, tenant_id="tenant-1", app_id="app-1")
|
||||
|
||||
assert result["save_options"] == [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value]
|
||||
assert result["active_config_is_published"] is True
|
||||
|
||||
|
||||
def test_save_agent_app_composer_rejects_version_save_strategy():
|
||||
@@ -612,11 +610,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"load_agent_composer",
|
||||
lambda **kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published},
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
@@ -634,7 +628,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
)
|
||||
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"loaded": True, "active_config_is_published": False}
|
||||
assert result == {"loaded": True}
|
||||
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
|
||||
assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json")
|
||||
assert agent.active_config_is_published is False
|
||||
@@ -663,11 +657,7 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
|
||||
lambda **_kwargs: SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"load_agent_composer",
|
||||
lambda **_kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published},
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
@@ -676,7 +666,7 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
|
||||
}
|
||||
)
|
||||
|
||||
result = AgentComposerService.save_agent_app_composer(
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
@@ -685,7 +675,6 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
|
||||
)
|
||||
|
||||
assert agent.active_config_is_published is True
|
||||
assert result["active_config_is_published"] is True
|
||||
assert fake_session.flushes >= 1
|
||||
|
||||
|
||||
|
||||
@@ -806,105 +806,7 @@ class TestPluginListEndpointCounts:
|
||||
assert tool_plugin.endpoints_active == 0
|
||||
|
||||
|
||||
class TestPluginCategoryList:
|
||||
def test_list_by_category_forwards_search_and_tag_filters(self) -> None:
|
||||
plugins = SimpleNamespace(list=[], has_more=False)
|
||||
|
||||
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
|
||||
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_by_category(
|
||||
"tenant-1",
|
||||
PluginCategory.Tool,
|
||||
2,
|
||||
25,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
assert result is plugins
|
||||
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
||||
"tenant-1",
|
||||
PluginCategory.Tool,
|
||||
2,
|
||||
25,
|
||||
query="weather",
|
||||
tags=["search", "rag"],
|
||||
language="zh_Hans",
|
||||
)
|
||||
|
||||
def test_filtered_model_category_does_not_reconcile_from_a_partial_result(self) -> None:
|
||||
plugins = SimpleNamespace(list=[], has_more=False)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||
):
|
||||
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_by_category(
|
||||
"tenant-1",
|
||||
PluginCategory.Model,
|
||||
1,
|
||||
100,
|
||||
query="openai",
|
||||
tags=[],
|
||||
language="en_US",
|
||||
)
|
||||
|
||||
assert result is plugins
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_not_called()
|
||||
|
||||
|
||||
class TestInstalledPluginIds:
|
||||
def test_list_installed_plugin_ids_uses_lightweight_daemon_endpoint(self) -> None:
|
||||
with patch(f"{MODULE}.PluginInstaller") as installer_cls:
|
||||
installer_cls.return_value.list_installed_plugin_ids.return_value = [
|
||||
"langgenius/openai",
|
||||
"langgenius/anthropic",
|
||||
]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_installed_plugin_ids("tenant-1", PluginCategory.Tool)
|
||||
|
||||
assert result == ["langgenius/openai", "langgenius/anthropic"]
|
||||
installer_cls.return_value.list_installed_plugin_ids.assert_called_once_with("tenant-1", PluginCategory.Tool)
|
||||
|
||||
|
||||
class TestPluginModelProviderCacheInvalidation:
|
||||
def test_list_model_provider_bindings_reconciles_remote_provider_cache(self) -> None:
|
||||
"""The summary binding read owns the remote marker once the full category list leaves the first-load path."""
|
||||
remote_binding = _build_remote_model_plugin()
|
||||
client = MagicMock()
|
||||
client.fetch_model_provider_bindings.return_value = [remote_binding]
|
||||
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MODULE}.PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins",
|
||||
return_value=True,
|
||||
) as should_invalidate,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||
):
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_model_provider_bindings("tenant-1", client=client)
|
||||
|
||||
assert result == [remote_binding]
|
||||
client.fetch_model_provider_bindings.assert_called_once_with("tenant-1")
|
||||
should_invalidate.assert_called_once_with("tenant-1", [remote_binding])
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
|
||||
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
|
||||
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
|
||||
with (
|
||||
@@ -948,13 +850,7 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
|
||||
assert result is plugins
|
||||
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
||||
"tenant-1",
|
||||
PluginCategory.Model,
|
||||
1,
|
||||
100,
|
||||
query="",
|
||||
tags=(),
|
||||
language="en_US",
|
||||
"tenant-1", PluginCategory.Model, 1, 100
|
||||
)
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
@@ -1021,42 +917,14 @@ class TestPluginModelProviderCacheInvalidation:
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||
|
||||
@pytest.mark.parametrize(("page", "has_more"), [(1, True), (2, False)])
|
||||
def test_list_model_category_does_not_reconcile_partial_page(self, page: int, has_more: bool) -> None:
|
||||
"""Only an unfiltered, complete first page may write the remote model marker."""
|
||||
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
|
||||
"""The current model category result clears provider cache when the previous debug model disappears."""
|
||||
installed_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=has_more)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(
|
||||
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
|
||||
),
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||
):
|
||||
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, page, 100)
|
||||
|
||||
assert result is plugins
|
||||
invalidate_cache.assert_not_called()
|
||||
store_marker.assert_not_called()
|
||||
|
||||
def test_list_model_category_complete_first_page_reconciles_remote_plugin_disconnect(self) -> None:
|
||||
installed_plugin = SimpleNamespace(
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=False)
|
||||
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, select
|
||||
@@ -1326,10 +1325,7 @@ class TestRegisterService:
|
||||
with patch("services.account_service.AccountService.create_account") as mock_create_account:
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
with (
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant,
|
||||
patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install,
|
||||
):
|
||||
with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
@@ -1348,39 +1344,7 @@ class TestRegisterService:
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
|
||||
dify_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert dify_setup is not None
|
||||
assert dify_setup.instance_id is not None
|
||||
assert str(UUID(dify_setup.instance_id)) == dify_setup.instance_id
|
||||
assert dify_setup.install_reported_at is None
|
||||
assert dify_setup.last_heartbeat_at is None
|
||||
mock_report_install.assert_called_once_with(session=sqlite_session)
|
||||
|
||||
def test_setup_succeeds_when_telemetry_install_report_fails(
|
||||
self, sqlite_session: Session, mock_external_service_dependencies
|
||||
):
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account", return_value=mock_account),
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist"),
|
||||
patch(
|
||||
"services.account_service.CommunityTelemetryService.report_install",
|
||||
side_effect=RuntimeError("telemetry unavailable"),
|
||||
),
|
||||
):
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
"password123",
|
||||
"192.168.1.1",
|
||||
"en-US",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
|
||||
def test_setup_failure_rollback(self, sqlite_session: Session, mock_external_service_dependencies):
|
||||
"""Test setup failure with proper rollback."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,12 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from core.entities.model_entities import ModelStatus
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
from models.provider import ProviderType
|
||||
from services import model_provider_service as service_module
|
||||
from services.errors.app_model_config import ProviderNotFoundError
|
||||
from services.model_provider_service import ModelProviderService, _ProviderSummaryState
|
||||
from services.model_provider_service import ModelProviderService
|
||||
|
||||
|
||||
def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]:
|
||||
@@ -99,247 +96,6 @@ class TestModelProviderServiceConfiguration:
|
||||
assert result[0].provider == "openai"
|
||||
assert result[0].custom_configuration.status.value == "no-configure"
|
||||
|
||||
def test_get_provider_summary_list_uses_lightweight_state_and_plugin_bindings(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = ModelProviderService()
|
||||
provider = SimpleNamespace(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=I18nObject(en_US="OpenAI models"),
|
||||
icon_small=I18nObject(en_US="icon.svg"),
|
||||
icon_small_dark=I18nObject(en_US="icon-dark.svg"),
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
binding = SimpleNamespace(
|
||||
provider="openai",
|
||||
plugin_id="langgenius/openai",
|
||||
installation_id="installation-1",
|
||||
plugin_unique_identifier="langgenius/openai:1.2.3@checksum",
|
||||
runtime_type="local",
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
version="1.2.3",
|
||||
)
|
||||
state = _ProviderSummaryState(
|
||||
has_custom_provider=True,
|
||||
available_credentials=[
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-1",
|
||||
credential_name="Production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-2",
|
||||
credential_name="Backup",
|
||||
),
|
||||
],
|
||||
has_custom_models=False,
|
||||
current_credential_id="credential-1",
|
||||
current_credential_name="Production",
|
||||
current_credential_usable=True,
|
||||
preferred_provider_type=ProviderType.CUSTOM,
|
||||
)
|
||||
call_order: list[str] = []
|
||||
manager_constructor = MagicMock(side_effect=AssertionError("summary must not construct ProviderManager"))
|
||||
monkeypatch.setattr(service, "_get_provider_manager", manager_constructor)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"list_model_provider_bindings",
|
||||
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("bindings") or [binding]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"fetch_plugin_model_providers",
|
||||
MagicMock(side_effect=lambda *_args, **_kwargs: call_order.append("providers") or [provider]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_load_provider_summary_states",
|
||||
MagicMock(return_value={provider.provider: state}),
|
||||
)
|
||||
monkeypatch.setattr(service, "_is_system_provider_enabled", MagicMock(return_value=False))
|
||||
|
||||
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
|
||||
|
||||
assert len(providers) == 1
|
||||
assert providers[0].provider == provider.provider
|
||||
assert providers[0].plugin_id == "langgenius/openai"
|
||||
assert providers[0].is_configured is True
|
||||
assert providers[0].custom_configuration.available_credentials == [
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-1",
|
||||
credential_name="Production",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-2",
|
||||
credential_name="Backup",
|
||||
),
|
||||
]
|
||||
assert providers[0].custom_configuration.current_credential_name == "Production"
|
||||
assert providers[0].custom_configuration.current_credential_usable is True
|
||||
assert providers[0].system_configuration.enabled is False
|
||||
assert plugins["langgenius/openai"].installation_id == "installation-1"
|
||||
assert plugins["langgenius/openai"].version == "1.2.3"
|
||||
assert call_order == ["bindings", "providers"]
|
||||
manager_constructor.assert_not_called()
|
||||
|
||||
def test_get_provider_summary_list_returns_all_unique_provider_metadata(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = ModelProviderService()
|
||||
llm_provider = SimpleNamespace(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
description=None,
|
||||
icon_small=None,
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.LLM],
|
||||
configurate_methods=[],
|
||||
)
|
||||
embedding_provider = SimpleNamespace(
|
||||
provider="langgenius/embedding/embedding",
|
||||
label=I18nObject(en_US="Embedding"),
|
||||
description=None,
|
||||
icon_small=None,
|
||||
icon_small_dark=None,
|
||||
supported_model_types=[ModelType.TEXT_EMBEDDING],
|
||||
configurate_methods=[],
|
||||
)
|
||||
llm_binding = SimpleNamespace(
|
||||
provider="openai",
|
||||
plugin_id="langgenius/openai",
|
||||
installation_id="installation-openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source=service_module.PluginInstallationSource.Marketplace,
|
||||
version="1.0.0",
|
||||
)
|
||||
embedding_binding = SimpleNamespace(
|
||||
provider="embedding",
|
||||
plugin_id="langgenius/embedding",
|
||||
installation_id="installation-embedding",
|
||||
plugin_unique_identifier="langgenius/embedding:1.0.0@checksum",
|
||||
runtime_type="local",
|
||||
source=service_module.PluginInstallationSource.Marketplace,
|
||||
version="1.0.0",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"list_model_provider_bindings",
|
||||
MagicMock(return_value=[llm_binding, embedding_binding]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module.PluginService,
|
||||
"fetch_plugin_model_providers",
|
||||
MagicMock(return_value=[llm_provider, llm_provider, embedding_provider]),
|
||||
)
|
||||
monkeypatch.setattr(service, "_load_provider_summary_states", MagicMock(return_value={}))
|
||||
monkeypatch.setattr(service, "_is_system_provider_enabled", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(service_module, "is_filtered", MagicMock(return_value=False))
|
||||
|
||||
providers, plugins = service.get_provider_summary_list(tenant_id="tenant-1")
|
||||
|
||||
assert [provider.provider for provider in providers] == [
|
||||
"langgenius/openai/openai",
|
||||
"langgenius/embedding/embedding",
|
||||
]
|
||||
assert providers[0].is_configured is False
|
||||
assert providers[0].custom_configuration.status.value == "no-configure"
|
||||
assert providers[0].custom_configuration.available_credentials == []
|
||||
assert set(plugins) == {"langgenius/openai", "langgenius/embedding"}
|
||||
|
||||
def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
|
||||
state = _ProviderSummaryState(has_custom_provider=True)
|
||||
|
||||
preferred_provider_type = ModelProviderService._get_preferred_provider_type(
|
||||
state,
|
||||
custom_present=True,
|
||||
system_enabled=True,
|
||||
)
|
||||
|
||||
assert preferred_provider_type == ProviderType.CUSTOM
|
||||
|
||||
def test_load_provider_summary_states_reads_only_lightweight_columns(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
canonical_provider = "langgenius/openai/openai"
|
||||
session = MagicMock()
|
||||
session.execute.side_effect = [
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
provider_name="openai",
|
||||
credential_id="credential-legacy",
|
||||
credential_provider_name="openai",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
SimpleNamespace(
|
||||
provider_name=canonical_provider,
|
||||
credential_id="credential-current",
|
||||
credential_provider_name=canonical_provider,
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
),
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
id="credential-legacy",
|
||||
provider_name="openai",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="credential-current",
|
||||
provider_name=canonical_provider,
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
),
|
||||
SimpleNamespace(all=lambda: [SimpleNamespace(provider_name="openai")]),
|
||||
SimpleNamespace(
|
||||
all=lambda: [
|
||||
SimpleNamespace(
|
||||
provider_name=canonical_provider,
|
||||
preferred_provider_type=ProviderType.SYSTEM,
|
||||
)
|
||||
]
|
||||
),
|
||||
]
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = session
|
||||
create_session = MagicMock(return_value=session_context)
|
||||
monkeypatch.setattr(service_module.session_factory, "create_session", create_session)
|
||||
|
||||
states = ModelProviderService._load_provider_summary_states("tenant-1")
|
||||
|
||||
state = states[canonical_provider]
|
||||
assert state.has_custom_provider is True
|
||||
assert state.available_credentials == [
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-legacy",
|
||||
credential_name="Legacy",
|
||||
),
|
||||
CredentialConfiguration(
|
||||
credential_id="credential-current",
|
||||
credential_name="Production",
|
||||
),
|
||||
]
|
||||
assert state.has_custom_models is True
|
||||
assert state.current_credential_id == "credential-current"
|
||||
assert state.current_credential_name == "Production"
|
||||
assert state.current_credential_usable is True
|
||||
assert state.preferred_provider_type == ProviderType.SYSTEM
|
||||
|
||||
statements = [str(execute_call.args[0]) for execute_call in session.execute.call_args_list]
|
||||
assert len(statements) == 4
|
||||
assert all("encrypted_config" not in statement for statement in statements)
|
||||
assert "count(" not in statements[1].lower()
|
||||
assert "provider_credentials.id" in statements[1]
|
||||
assert "provider_credentials.credential_name" in statements[1]
|
||||
assert "ORDER BY provider_credentials.created_at DESC, provider_credentials.id DESC" in statements[1]
|
||||
assert "provider_model_credentials" in statements[2]
|
||||
|
||||
def test_get_models_by_provider_should_wrap_model_entities_with_tenant_context(self) -> None:
|
||||
service, manager = _create_service_with_mocked_manager()
|
||||
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import DifySetup
|
||||
from services import telemetry_service
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telemetry_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "CI", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events")
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
"https://telemetry-cn.example.test/v1/events",
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2)
|
||||
|
||||
|
||||
def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("setting", "value"),
|
||||
[
|
||||
("EDITION", "CLOUD"),
|
||||
("DISABLE_TELEMETRY", True),
|
||||
("DO_NOT_TRACK", True),
|
||||
("CI", True),
|
||||
("TELEMETRY_ENDPOINT", ""),
|
||||
],
|
||||
)
|
||||
def test_telemetry_is_disabled_when_a_required_condition_is_not_met(
|
||||
telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool
|
||||
):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, setting, value)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_enabled):
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert sent_payloads[0]["event"] == "install"
|
||||
assert sent_payloads[0]["instance_id"] == setup.instance_id
|
||||
assert sent_payloads[0]["version"] == "installed-version"
|
||||
assert "installed_at" in sent_payloads[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", install_reported_at=datetime(2026, 7, 12, 8, 0, 0))
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert (
|
||||
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is True
|
||||
)
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_failure_keeps_install_pending(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_uses_fallback_endpoint_after_network_failure(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
urls: list[str] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
urls.append(url)
|
||||
if url == telemetry_service.dify_config.TELEMETRY_ENDPOINT:
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
assert urls == [
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
telemetry_service.dify_config.TELEMETRY_FALLBACK_ENDPOINT,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_does_not_use_fallback_endpoint_after_http_error(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
500,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_retries_pending_install_before_heartbeat(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
now = datetime(2026, 7, 13, 0, 0, 0)
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=now) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert saved_setup.last_heartbeat_at == now
|
||||
assert [(payload["event"], payload["version"]) for payload in sent_payloads] == [
|
||||
("install", "installed-version"),
|
||||
("heartbeat", "running-version"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_skips_when_already_sent_today(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
last_heartbeat_at=datetime(2026, 7, 13, 9, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert (
|
||||
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
)
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_failure_does_not_mark_the_day_reported(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert (
|
||||
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
)
|
||||
assert setup.last_heartbeat_at is None
|
||||
|
||||
|
||||
def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True)
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "")
|
||||
|
||||
def fake_post(url: str, json: dict[str, str], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
|
||||
|
||||
def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
)
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
204,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is True
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_heartbeat_is_not_due_without_instance_id():
|
||||
setup = DifySetup(version="1.0.0")
|
||||
|
||||
assert CommunityTelemetryService._is_heartbeat_due(setup, datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("Linux", "linux"),
|
||||
("Plan9", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_os(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_os(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("x86_64", "amd64"),
|
||||
("aarch64", "arm64"),
|
||||
("armv7l", "arm"),
|
||||
("i686", "386"),
|
||||
("riscv64", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_arch(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_arch(value) == expected
|
||||
@@ -673,118 +673,3 @@ def test_dummy_variable_truncator_methods():
|
||||
assert isinstance(result, TruncationResult)
|
||||
assert result.result == segment
|
||||
assert result.truncated is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for langgenius/dify#39218.
|
||||
#
|
||||
# Before the fix, ``_truncate_array`` had a "Dirty fix" branch that
|
||||
# unconditionally appended every ``File`` element to ``truncated_value``
|
||||
# *before* the count cap and the byte-budget check, and *before*
|
||||
# ``used_size`` was ever incremented. That made ``list[File]`` arrays:
|
||||
# 1. uncapped by ``array_element_limit``,
|
||||
# 2. uncounted against ``max_size_bytes``, and
|
||||
# 3. always reported ``truncated=False``.
|
||||
# The fix routes ``File`` through ``_truncate_json_primitives``'s dedicated
|
||||
# ``File`` branch, which returns the file as-is with its real serialized
|
||||
# size, while preserving the count cap and the byte budget.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFileArrayTruncationRegression39218:
|
||||
"""``list[File]`` must respect ``array_element_limit`` and the byte budget."""
|
||||
|
||||
@pytest.fixture
|
||||
def truncator(self) -> VariableTruncator:
|
||||
return VariableTruncator(
|
||||
array_element_limit=3,
|
||||
max_size_bytes=1000,
|
||||
string_length_limit=50,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_file(name: str = "f") -> File:
|
||||
return File(
|
||||
id=name,
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
remote_url=f"https://example.com/{name}.txt",
|
||||
filename=f"{name}.txt",
|
||||
extension=".txt",
|
||||
mime_type="text/plain",
|
||||
size=1024,
|
||||
)
|
||||
|
||||
def test_file_array_respects_element_count_cap(self, truncator: VariableTruncator) -> None:
|
||||
# Use a target_size larger than ``count * file_size`` so the byte
|
||||
# budget never binds — only the count cap should fire.
|
||||
# Each File serializes to ~237 bytes; 3 files = ~713 bytes.
|
||||
files = [self._make_file(f"f{i}") for i in range(500)]
|
||||
|
||||
result = truncator._truncate_array(files, target_size=10_000_000)
|
||||
|
||||
# Before the fix, all 500 File entries survived (``len(value)==500``,
|
||||
# ``truncated==False``). After the fix, the array is capped at
|
||||
# ``array_element_limit=3`` and ``truncated`` flips to True.
|
||||
assert len(result.value) == 3
|
||||
assert result.truncated is True
|
||||
|
||||
def test_file_array_reports_real_used_size(self, truncator: VariableTruncator) -> None:
|
||||
# Large budget so the count cap fires before the byte budget does.
|
||||
files = [self._make_file(f"f{i}") for i in range(500)]
|
||||
|
||||
result = truncator._truncate_array(files, target_size=10_000_000)
|
||||
|
||||
# Before the fix, ``used_size`` for a File array was the empty-array
|
||||
# baseline of 2 bytes (``[]``), regardless of how many File entries
|
||||
# actually returned. After the fix, ``used_size`` reflects the real
|
||||
# serialized size of the returned ``File`` payload.
|
||||
assert result.value_size > 100
|
||||
assert result.truncated is True
|
||||
|
||||
def test_file_array_respects_byte_budget(self, truncator: VariableTruncator) -> None:
|
||||
# Use a small ``target_size`` so the byte budget is the binding
|
||||
# constraint. Each File serializes to ~237 bytes, so even one File
|
||||
# blows the 200-byte budget.
|
||||
files = [self._make_file(f"f{i}") for i in range(50)]
|
||||
|
||||
result = truncator._truncate_array(files, target_size=200)
|
||||
|
||||
# Before the fix, all 50 File entries survived and ``used_size``
|
||||
# reported ``2`` (the empty-array baseline). After the fix, the
|
||||
# loop sees the File payload: ``value_size`` reflects the real
|
||||
# serialized size, and the loop stops after the first File because
|
||||
# adding the next one would exceed ``target_size``.
|
||||
assert len(result.value) == 1
|
||||
assert result.value_size > 100 # the File's real serialized size
|
||||
assert result.value_size <= 250 # in the ballpark of the budget
|
||||
|
||||
def test_mixed_array_counts_files_toward_cap(self, truncator: VariableTruncator) -> None:
|
||||
mixed: list[object] = [
|
||||
self._make_file("f0"),
|
||||
"a",
|
||||
self._make_file("f1"),
|
||||
"b",
|
||||
self._make_file("f2"),
|
||||
"c",
|
||||
self._make_file("f3"),
|
||||
"d",
|
||||
]
|
||||
|
||||
result = truncator._truncate_array(mixed, target_size=10_000_000)
|
||||
|
||||
# 8 items, cap of 3 → exactly 3 items. Files and primitives are
|
||||
# counted together toward the cap.
|
||||
assert len(result.value) == 3
|
||||
assert result.truncated is True
|
||||
|
||||
def test_single_file_in_array_is_preserved(self, truncator: VariableTruncator) -> None:
|
||||
result = truncator._truncate_array([self._make_file("only")], target_size=10_000_000)
|
||||
|
||||
# The File itself is not truncated — the dedicated ``File`` branch
|
||||
# in ``_truncate_json_primitives`` returns the file untouched. Only
|
||||
# the array-shape accounting changes.
|
||||
assert len(result.value) == 1
|
||||
assert isinstance(result.value[0], File)
|
||||
assert result.value[0].id == "only"
|
||||
assert result.truncated is False
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tasks import community_telemetry_task
|
||||
|
||||
|
||||
def _configure_task_session(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
session = Mock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
monkeypatch.setattr(community_telemetry_task, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(community_telemetry_task, "sessionmaker", Mock(return_value=session_factory))
|
||||
return session
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_reports_with_a_database_session(monkeypatch: pytest.MonkeyPatch):
|
||||
session = _configure_task_session(monkeypatch)
|
||||
report_heartbeat = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.CommunityTelemetryService, "report_heartbeat", report_heartbeat)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
report_heartbeat.assert_called_once_with(session=session)
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_swallows_report_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_task_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
community_telemetry_task.CommunityTelemetryService,
|
||||
"report_heartbeat",
|
||||
Mock(side_effect=RuntimeError("telemetry unavailable")),
|
||||
)
|
||||
log_debug = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.logger, "debug", log_debug)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
log_debug.assert_called_once_with("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
Generated
+3
-3
@@ -2740,11 +2740,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.14.2"
|
||||
version = "6.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -531,14 +531,6 @@ services:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces.
|
||||
# Network isolation: local_sandbox has NO direct route to `api`. Its only
|
||||
# networks are `agent_sandbox_network` (so agent_backend can reach it on 5004
|
||||
# for shellctl, and it can reach agent_backend directly) and
|
||||
# `local_sandbox_proxy_network` (so its egress is forced through
|
||||
# agent_ssrf_proxy).
|
||||
# All non-agent_backend/localhost traffic goes through the Squid forward proxy
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
restart: always
|
||||
@@ -547,9 +539,6 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
- SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
- HTTP_PROXY=http://agent_ssrf_proxy:3128
|
||||
- HTTPS_PROXY=http://agent_ssrf_proxy:3128
|
||||
- NO_PROXY=localhost,127.0.0.1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"]
|
||||
interval: 30s
|
||||
@@ -557,8 +546,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- agent_sandbox_network
|
||||
- local_sandbox_proxy_network
|
||||
- default
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
@@ -684,33 +672,6 @@ services:
|
||||
condition: service_started
|
||||
networks:
|
||||
- default
|
||||
# Shared internal network with local_sandbox so agent_backend can reach it
|
||||
# on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`.
|
||||
- agent_sandbox_network
|
||||
|
||||
# Dedicated SSRF proxy for the dify-agent local_sandbox.
|
||||
agent_ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template
|
||||
- ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
|
||||
]
|
||||
environment:
|
||||
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
networks:
|
||||
# Needs to reach api and agent_backend as forward-proxy destinations.
|
||||
- default
|
||||
# Only agent_ssrf_proxy and local_sandbox share this internal network, so
|
||||
# the local_sandbox can reach Squid without gaining a direct route to `api`.
|
||||
- local_sandbox_proxy_network
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
@@ -720,7 +681,6 @@ services:
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template
|
||||
- ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
@@ -1293,19 +1253,6 @@ networks:
|
||||
ssrf_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# Internal network shared only by agent_ssrf_proxy and local_sandbox.
|
||||
local_sandbox_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# shellctl control channel (agent_backend -> local_sandbox:5004).
|
||||
# sandbox can access agent backend through this network, this is
|
||||
# a known limitation.
|
||||
#
|
||||
# The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution
|
||||
# is still possible through the shellctl channel.
|
||||
agent_sandbox_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
milvus:
|
||||
driver: bridge
|
||||
opensearch-net:
|
||||
|
||||
@@ -199,7 +199,6 @@ services:
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template
|
||||
- ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
|
||||
@@ -537,14 +537,6 @@ services:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces.
|
||||
# Network isolation: local_sandbox has NO direct route to `api`. Its only
|
||||
# networks are `agent_sandbox_network` (so agent_backend can reach it on 5004
|
||||
# for shellctl, and it can reach agent_backend directly) and
|
||||
# `local_sandbox_proxy_network` (so its egress is forced through
|
||||
# agent_ssrf_proxy).
|
||||
# All non-agent_backend/localhost traffic goes through the Squid forward proxy
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
restart: always
|
||||
@@ -553,9 +545,6 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
- SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
- HTTP_PROXY=http://agent_ssrf_proxy:3128
|
||||
- HTTPS_PROXY=http://agent_ssrf_proxy:3128
|
||||
- NO_PROXY=localhost,127.0.0.1
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"]
|
||||
interval: 30s
|
||||
@@ -563,8 +552,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- agent_sandbox_network
|
||||
- local_sandbox_proxy_network
|
||||
- default
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
@@ -690,33 +678,6 @@ services:
|
||||
condition: service_started
|
||||
networks:
|
||||
- default
|
||||
# Shared internal network with local_sandbox so agent_backend can reach it
|
||||
# on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`.
|
||||
- agent_sandbox_network
|
||||
|
||||
# Dedicated SSRF proxy for the dify-agent local_sandbox.
|
||||
agent_ssrf_proxy:
|
||||
image: ubuntu/squid:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template
|
||||
- ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh",
|
||||
]
|
||||
environment:
|
||||
HTTP_PORT: ${SSRF_HTTP_PORT:-3128}
|
||||
COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid}
|
||||
networks:
|
||||
# Needs to reach api and agent_backend as forward-proxy destinations.
|
||||
- default
|
||||
# Only agent_ssrf_proxy and local_sandbox share this internal network, so
|
||||
# the local_sandbox can reach Squid without gaining a direct route to `api`.
|
||||
- local_sandbox_proxy_network
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
@@ -726,7 +687,6 @@ services:
|
||||
restart: always
|
||||
volumes:
|
||||
- ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template
|
||||
- ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template
|
||||
- ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh
|
||||
entrypoint:
|
||||
[
|
||||
@@ -1299,19 +1259,6 @@ networks:
|
||||
ssrf_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# Internal network shared only by agent_ssrf_proxy and local_sandbox.
|
||||
local_sandbox_proxy_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# shellctl control channel (agent_backend -> local_sandbox:5004).
|
||||
# sandbox can access agent backend through this network, this is
|
||||
# a known limitation.
|
||||
#
|
||||
# The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution
|
||||
# is still possible through the shellctl channel.
|
||||
agent_sandbox_network:
|
||||
driver: bridge
|
||||
internal: true
|
||||
milvus:
|
||||
driver: bridge
|
||||
opensearch-net:
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
tail -F /var/log/squid/access.log 2>/dev/null &
|
||||
tail -F /var/log/squid/error.log 2>/dev/null &
|
||||
tail -F /var/log/squid/store.log 2>/dev/null &
|
||||
tail -F /var/log/squid/cache.log 2>/dev/null &
|
||||
|
||||
expand_env() {
|
||||
awk '{
|
||||
while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) {
|
||||
var = substr($0, RSTART+2, RLENGTH-3)
|
||||
val = ENVIRON[var]
|
||||
$0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH)
|
||||
}
|
||||
print
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
echo "[ENTRYPOINT] replacing environment variables in the templates"
|
||||
expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf
|
||||
expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf
|
||||
|
||||
/usr/sbin/squid -Nz
|
||||
echo "[ENTRYPOINT] starting squid"
|
||||
/usr/sbin/squid -f /etc/squid/squid.conf -NYC 1
|
||||
@@ -74,22 +74,16 @@ if [ -n "${SSRF_SANDBOX_PROXY_PORT:-}" ]; then
|
||||
} >> "$SANDBOX_PROXY_CONF"
|
||||
fi
|
||||
|
||||
# Replace environment variables in a template file.
|
||||
expand_env() {
|
||||
awk '{
|
||||
while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) {
|
||||
var = substr($0, RSTART+2, RLENGTH-3)
|
||||
val = ENVIRON[var]
|
||||
$0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH)
|
||||
}
|
||||
print
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
# Replace environment variables in the templates and output to squid.conf
|
||||
echo "[ENTRYPOINT] replacing environment variables in the templates"
|
||||
expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf
|
||||
expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf
|
||||
# Replace environment variables in the template and output to the squid.conf
|
||||
echo "[ENTRYPOINT] replacing environment variables in the template"
|
||||
awk '{
|
||||
while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) {
|
||||
var = substr($0, RSTART+2, RLENGTH-3)
|
||||
val = ENVIRON[var]
|
||||
$0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH)
|
||||
}
|
||||
print
|
||||
}' /etc/squid/squid.conf.template > /etc/squid/squid.conf
|
||||
|
||||
/usr/sbin/squid -Nz
|
||||
echo "[ENTRYPOINT] starting squid"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Dedicated Squid config for the dify-agent local_sandbox SSRF proxy.
|
||||
# All traffic on this proxy is restricted to:
|
||||
# - agent_backend /agent-stub/* endpoints
|
||||
# - Dify API /files/* endpoints (signed upload/download URLs)
|
||||
# External internet is allowed; all other private-network destinations are denied.
|
||||
|
||||
include /etc/squid/dify_common.conf
|
||||
|
||||
acl dst_agent_backend dstdomain agent_backend
|
||||
acl dst_dify_api dstdomain api
|
||||
acl path_files urlpath_regex -i ^/files/
|
||||
acl path_agent_stub urlpath_regex -i ^/agent-stub/
|
||||
|
||||
http_port ${HTTP_PORT}
|
||||
|
||||
http_access deny !Safe_ports
|
||||
http_access deny CONNECT !SSL_ports
|
||||
http_access allow dst_agent_backend path_agent_stub
|
||||
http_access allow dst_dify_api path_files
|
||||
http_access deny to_private_networks
|
||||
http_access allow all
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user