Compare commits

..
Author SHA1 Message Date
yyh 5d790d634d Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-27 15:16:43 +08:00
林玮 (Jade Lin) 2b343f25d4 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 14:59:40 +08:00
林玮 (Jade Lin) 45317a11b9 fix: bound app worker cleanup wait 2026-07-27 14:54:04 +08:00
yyhandGitHub 58e2bcbba1 fix(web): stop reporting workspace status (#39626) 2026-07-27 06:22:37 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e1b55f54e6 feat(agent): add a squid proxy for agent sandbox (#39544)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 06:21:20 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
989039db6e fix: use jinja sandbox (#39609)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 06:03:01 +00:00
林玮 (Jade Lin) 0e6af39553 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 13:57:41 +08:00
JoelGitHub林玮autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
52428df1bd feat: add amptitude to new agent (#39608)
Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 05:54:45 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
28d174603f fix: align workspace card plan ownership (#39616)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 05:01:02 +00:00
yyh b81d521e0b perf(web): prefetch workspace list on intent 2026-07-27 12:47:19 +08:00
Xiyuan ChenandGitHub b6099d09ff feat(inner_api): add endpoint to invalidate plugin model providers cache (#39468) 2026-07-27 04:33:36 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2962a7ea93 chore(deps): bump the github-actions-dependencies group with 6 updates (#39603)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 04:32:09 +00:00
yyh c487dc2d7c fix(web): refine workspace switcher loading state 2026-07-27 12:27:12 +08:00
zyssyz123andGitHub cc01189966 fix(agent): expose publish state from composer (#39615) 2026-07-27 04:05:38 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d563d6e7df chore: bump pypdf from 6.10.2 to 6.14.2 in /dify-agent (#39502)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 03:56:57 +00:00
autofix-ci[bot]andyyh 0ed9fb4db6 [autofix.ci] apply automated fixes 2026-07-27 11:56:34 +08:00
yyh 52b825a4a1 fix: align workspace card plan ownership 2026-07-27 11:56:33 +08:00
yyhyyhautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ffb1e29527 refactor(workflow): remove hook barrel exports (#39588)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 11:42:40 +08:00
林玮 (Jade Lin) bedb3c571b fix: correct workflow task return type 2026-07-27 11:40:34 +08:00
林玮 (Jade Lin) 05df469e28 test: join workflow resume thread stub 2026-07-27 11:35:56 +08:00
林玮 (Jade Lin) 07ab81c9f0 test: join workflow generation thread stub 2026-07-27 11:35:23 +08:00
CYJ1226andGitHub e4e99d198b fix: preserve spaces during recursive text splitting (#39498) 2026-07-27 03:21:26 +00:00
林玮 (Jade Lin) 1319f485a1 Merge remote-tracking branch 'origin/linw/snp-581-celery-task-should-join-thread' into deploy/saas 2026-07-27 11:09:17 +08:00
yyhandGitHub 34e6e5a049 refactor(web): simplify Dify logo theming (#39580) 2026-07-27 03:01:24 +00:00
林玮 (Jade Lin)GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7e6ba05464 feat(api): expose app mode in webapp site response (#39607)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 02:57:18 +00:00
Madan kumarandGitHub aeda37db68 fix(rag): stop the document cleaner from stripping valid characters ï, ¿, ¾ (#39215) 2026-07-27 02:53:33 +00:00
林玮 (Jade Lin) b12ad644ee Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 10:47:28 +08:00
FFXNandGitHub 441f9f9ec0 fix: The expiration time for web app login to JWT is incorrectly set. (#39537) 2026-07-27 02:43:32 +00:00
林玮 (Jade Lin) 8073ba2fbe refactor(web): remove redundant app mode propagation 2026-07-27 10:42:07 +08:00
f16c249b1e fix(api): honor array-element limit and byte budget for list[File] in VariableTruncator (#39218) (#39220)
Co-authored-by: Taranum Wasu <taranumwasu@Taranums-MacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:12:57 +00:00
FFXNGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
858b3b74dc feat: add CE telemetry report (#39452)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-27 02:04:28 +00:00
autofix-ci[bot]andGitHub 4919cc2395 [autofix.ci] apply automated fixes 2026-07-27 02:03:29 +00:00
d5c0e927c6 fix(web): stop chunk length/overlap inputs collapsing to an unusable width in narrow containers (#39600)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 02:03:17 +00:00
Asuka MinatoandGitHub b81737cfb3 test: use sqlite3 session in test_message_service (#38698) 2026-07-27 02:01:47 +00:00
林玮 (Jade Lin) e3dcf2efdb Merge remote-tracking branch 'origin/linw/snp-560-webapp-site-response-with-mode' into deploy/saas 2026-07-27 09:43:03 +08:00
hjlarry de7d4d4ab2 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 15:36:41 +08:00
hjlarry a1efdeeabb fix: simplify plugin list API contracts 2026-07-25 15:34:38 +08:00
autofix-ci[bot]andGitHub 15cea0c08d [autofix.ci] apply automated fixes 2026-07-25 07:10:01 +00:00
hjlarry 543642542f Merge remote-tracking branch 'myori/deploy/saas' into deploy/saas 2026-07-25 15:07:07 +08:00
hjlarry 0d71273e30 Merge branch 'build/plugin-list-improve' into deploy/saas 2026-07-25 14:56:43 +08:00
hjlarry 671742d5b6 chore: improve summary api 2026-07-25 11:04:22 +08:00
林玮 (Jade Lin) e67d45cf7c fix: wait for workflow worker cleanup 2026-07-24 20:24:30 +08:00
林玮 (Jade Lin) 5d5439a169 feat: expose app mode in webapp site response 2026-07-24 20:15:10 +08:00
林玮 (Jade Lin) 6506d06f61 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:54 +08:00
林玮 (Jade Lin) 689da4782f Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-24 16:00:43 +08:00
林玮 (Jade Lin) 3efe5f0da8 refactor(web): use semantic labelling for explore banner slides
(cherry picked from commit 8e89bd38ae)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 3665f6cb73 refactor(web): add accessible label to plan range switcher
(cherry picked from commit 810d028a3c)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 39ffee49ff refactor(web): use semantic markup for workflow save status
(cherry picked from commit a689278288)
2026-07-24 15:48:27 +08:00
林玮 (Jade Lin) 952d3482bb refactor(web): use semantic markup for billing usage info cards
(cherry picked from commit 87b9873bb7)
2026-07-24 15:48:27 +08:00
autofix-ci[bot]andGitHub 6fc6ac3d4c [autofix.ci] apply automated fixes 2026-07-24 05:42:34 +00:00
hjlarry c1647cd2a6 feat: model plugin summary api 2026-07-24 13:38:35 +08:00
林玮 (Jade Lin) 9b257f7df2 Merge remote-tracking branch 'origin/main' into deploy/saas 2026-07-23 18:18:26 +08:00
autofix-ci[bot]andGitHub aff527ce39 [autofix.ci] apply automated fixes 2026-07-23 08:36:56 +00:00
hjlarry 4ca9c36421 Merge remote-tracking branch 'origin/build/plugin-list-improve' into deploy/saas 2026-07-23 16:36:16 +08:00
hjlarry 00e7f4d984 feat: add category-scoped installed plugin ids 2026-07-23 16:13:38 +08:00
hjlarry 4375c7b988 feat: plugin list query improve 2026-07-23 10:32:26 +08:00
188 changed files with 5037 additions and 2425 deletions
+6 -6
View File
@@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -88,13 +88,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -139,13 +139,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: "3.12"
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
+2 -2
View File
@@ -97,7 +97,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
+6 -6
View File
@@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: "3.12"
@@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: "3.12"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
pull-requests: write
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
migration-changed: ${{ steps.changes.outputs.migration }}
sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
@@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python & UV
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -72,7 +72,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
+5 -5
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: false
python-version: "3.12"
@@ -93,7 +93,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -144,7 +144,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -186,7 +186,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@af0559ee4f514d1ef21826982bed13f7edc3c35e # v1.0.178
uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -34,7 +34,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
python-version: "3.12"
+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -100,7 +100,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -132,7 +132,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
-1
View File
@@ -729,7 +729,6 @@ 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
+36
View File
@@ -816,6 +816,41 @@ 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
@@ -1599,6 +1634,7 @@ class FeatureConfig(
TenantIsolatedTaskQueueConfig,
ToolConfig,
UpdateConfig,
CommunityTelemetryConfig,
WorkflowConfig,
WorkflowNodeExecutionConfig,
WorkspaceConfig,
-5
View File
@@ -257,7 +257,6 @@ 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):
@@ -410,10 +409,6 @@ 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,7 +26,11 @@ 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 ProviderResponse
from services.entities.model_provider_entities import (
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ProviderResponse,
)
from services.model_provider_service import ModelProviderService
@@ -91,6 +95,11 @@ 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
@@ -114,6 +123,7 @@ register_response_schema_models(
console_ns,
SimpleResultResponse,
ModelProviderListResponse,
ModelProviderSummaryListResponse,
ProviderCredentialsResponse,
ValidationResultResponse,
ModelProviderPaymentCheckoutUrlResponse,
@@ -140,6 +150,25 @@ 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))
+96 -5
View File
@@ -1,5 +1,5 @@
import io
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Literal, TypedDict
@@ -43,6 +43,7 @@ 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
@@ -90,9 +91,21 @@ 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):
@@ -325,6 +338,10 @@ class PluginListResponse(ResponseModel):
total: int
class PluginInstalledIdsResponse(ResponseModel):
plugin_ids: list[str]
class PluginVersionsResponse(ResponseModel):
versions: Mapping[str, PluginService.LatestPluginCache | None]
@@ -384,6 +401,7 @@ register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginInstalledIdsQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@@ -420,6 +438,7 @@ register_response_schema_models(
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
PluginInstallationsResponse,
PluginInstalledIdsResponse,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginManifestResponse,
@@ -477,7 +496,39 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
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."""
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
@@ -498,6 +549,13 @@ def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any
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)
@@ -552,7 +610,9 @@ 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))
args = PluginCategoryListQuery.model_validate(
{**request.args.to_dict(flat=True), "tags": request.args.getlist("tags")}
)
try:
plugin_category = PluginCategory(category)
@@ -560,13 +620,26 @@ 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)
plugins = PluginService.list_by_category(
tenant_id,
plugin_category,
args.page,
args.page_size,
query=args.query,
tags=args.tags,
language=args.language,
)
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)
builtin_tools = _list_hardcoded_builtin_tool_providers(
tenant_id,
query=args.query,
tags=args.tags,
language=args.language,
)
return dump_response(
PluginCategoryListResponse,
@@ -578,6 +651,24 @@ 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__])
+2
View File
@@ -23,6 +23,7 @@ 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)
@@ -35,6 +36,7 @@ __all__ = [
"_knowledge_retrieval",
"_mail",
"_plugin",
"_plugin_model_providers",
"_runtime_credentials",
"_workspace",
"api",
@@ -0,0 +1,39 @@
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
+2 -1
View File
@@ -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, Site
from models.model import App, AppMode, Site
from repositories.factory import DifyAPIRepositoryFactory
from services.feature_service import FeatureService
from services.human_input_file_upload_service import HumanInputFileUploadService
@@ -207,6 +207,7 @@ 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,
+5 -1
View File
@@ -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, EndUser, IconType, Site
from models.model import App, AppMode, EndUser, IconType, Site
from services.feature_service import FeatureModel, FeatureService
from services.file_service import FileService
@@ -67,6 +67,7 @@ class WebAppCustomConfigResponse(ResponseModel):
class WebAppSiteResponse(ResponseModel):
app_id: str
mode: AppMode
end_user_id: str | None = None
enable_site: bool
site: WebSiteResponse
@@ -83,6 +84,7 @@ class WebAppSiteResponse(ResponseModel):
*,
tenant: Tenant,
app_model: App,
mode: AppMode,
site: Site,
end_user_id: str | None,
features: FeatureModel,
@@ -109,6 +111,7 @@ 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,
@@ -167,6 +170,7 @@ 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,23 +616,34 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message_snapshot = MessageSnapshot.from_message(message)
session.close()
# 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,
),
)
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 AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
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
def _generate_worker(
self,
+29
View File
@@ -1,3 +1,5 @@
import logging
import threading
from collections.abc import Generator, Mapping, Sequence
from contextlib import AbstractContextManager, nullcontext
from typing import TYPE_CHECKING, Any, Union, final
@@ -23,6 +25,10 @@ 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:
@@ -64,6 +70,29 @@ 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,17 +351,28 @@ class PipelineGenerator(BaseAppGenerator):
user,
tenant_id=pipeline.tenant_id,
)
# 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,
)
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 WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
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
def single_iteration_generate(
self,
+21 -10
View File
@@ -405,17 +405,28 @@ class WorkflowAppGenerator(BaseAppGenerator):
tenant_id=app_model.tenant_id,
)
# 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,
)
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 WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
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
def single_iteration_generate(
self,
@@ -39,15 +39,16 @@ 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')
template = jinja2.Template(template_code)
env = SandboxedEnvironment()
template = env.from_string(template_code)
return template.render(**inputs)
# decode and prepare input dict
@@ -67,12 +68,13 @@ class Jinja2TemplateTransformer(TemplateTransformer):
@override
def get_preload_script(cls) -> str:
preload_script = dedent("""
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from base64 import b64decode
def _jinja2_preload_():
# prepare jinja2 environment, load template and render before to avoid sandbox issue
template = jinja2.Template('{{s}}')
# prepare jinja2 sandboxed environment, load template and render
env = SandboxedEnvironment()
template = env.from_string('{{s}}')
template.render(s='a')
if __name__ == '__main__':
+1 -1
View File
@@ -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\xEF\xBF\xBE]", "", text)
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text)
# Unicode U+FFFE
text = re.sub("\ufffe", "", text)
return text
+17 -1
View File
@@ -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
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity, PluginInstallationSource
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin
from core.trigger.entities.entities import TriggerProviderEntity
@@ -94,6 +94,18 @@ 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.
@@ -207,6 +219,10 @@ class PluginListResponse(BaseModel):
total: int
class PluginInstalledIdsDaemonResponse(BaseModel):
plugin_ids: list[str]
class PluginListWithoutTotalResponse(BaseModel):
list: list[PluginEntity]
has_more: bool
+9
View File
@@ -6,6 +6,7 @@ from core.plugin.entities.plugin_daemon import (
PluginBasicBooleanResponse,
PluginDaemonInnerError,
PluginLLMNumTokensResponse,
PluginModelProviderBinding,
PluginModelProviderEntity,
PluginModelSchemaEntity,
PluginStringResultResponse,
@@ -47,6 +48,14 @@ 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,
+28 -2
View File
@@ -14,6 +14,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
@@ -68,6 +69,16 @@ 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",
@@ -77,13 +88,28 @@ class PluginInstaller(BasePluginClient):
)
def list_plugins_by_category(
self, tenant_id: str, category: PluginCategory, page: int, page_size: int
self,
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> 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"},
params={
"page": page,
"page_size": page_size,
"response_type": "paged",
"query": query,
"tags": list(tags),
"language": language,
},
)
def upload_pkg(
+50 -7
View File
@@ -48,6 +48,7 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTaskStatus,
PluginListResponse,
PluginListWithoutTotalResponse,
PluginModelProviderBinding,
PluginModelProviderEntity,
PluginVerification,
)
@@ -78,6 +79,12 @@ 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
@@ -265,7 +272,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[PluginEntity]) -> str | None:
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[_ModelPluginIdentity]) -> str | None:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
@@ -350,7 +357,7 @@ class PluginService:
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
plugins: Sequence[_ModelPluginIdentity],
) -> 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)
@@ -656,6 +663,26 @@ 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.
@@ -672,17 +699,33 @@ class PluginService:
@staticmethod
def list_by_category(
tenant_id: str, category: PluginCategory, page: int, page_size: int
tenant_id: str,
category: PluginCategory,
page: int,
page_size: int,
*,
query: str = "",
tags: Sequence[str] = (),
language: str = "en_US",
) -> PluginListWithoutTotalResponse:
"""
List plugins in one category with a has-more cursor signal and without calculating total.
The daemon scans tenant installations in the existing list order and stops once it finds one extra match.
This keeps pagination usable before category is persisted on installation rows.
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.
"""
manager = PluginInstaller()
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
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:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
+1 -1
View File
@@ -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\xEF\xBF\xBE]", "", text)
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text)
# Unicode U+FFFE
text = re.sub("\ufffe", "", text)
+2 -2
View File
@@ -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":
+27
View File
@@ -5,6 +5,7 @@ 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
@@ -36,6 +37,19 @@ 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
@@ -260,6 +274,19 @@ 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)
+1
View File
@@ -383,6 +383,7 @@ 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]
@@ -0,0 +1,30 @@
"""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")
+3
View File
@@ -362,6 +362,9 @@ 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
)
+92 -1
View File
@@ -10567,6 +10567,13 @@ 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
@@ -11112,6 +11119,19 @@ 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
@@ -11370,8 +11390,11 @@ 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
@@ -13243,6 +13266,7 @@ 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 |
@@ -13282,7 +13306,6 @@ 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 |
@@ -19307,6 +19330,16 @@ 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 |
@@ -19319,6 +19352,49 @@ 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 |
@@ -20307,8 +20383,11 @@ 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
@@ -20509,6 +20588,18 @@ 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 |
+7
View File
@@ -965,6 +965,12 @@ 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 |
@@ -1646,6 +1652,7 @@ 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 |
+7 -1
View File
@@ -75,6 +75,7 @@ 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 (
@@ -1953,7 +1954,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)
dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4()))
session.add(dify_setup)
session.commit()
except Exception as e:
@@ -1966,6 +1967,11 @@ 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,
+1
View File
@@ -405,6 +405,7 @@ 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,6 +17,7 @@ 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,
@@ -69,6 +70,64 @@ 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.
+272 -1
View File
@@ -1,18 +1,42 @@
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 ProviderType
from models.provider import (
Provider,
ProviderCredential,
ProviderModel,
ProviderModelCredential,
ProviderType,
TenantPreferredModelProvider,
)
from models.provider_ids import ModelProviderID
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
DefaultModelResponse,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ModelWithProviderEntityResponse,
ProviderResponse,
ProviderWithModelsResponse,
@@ -24,6 +48,17 @@ 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
@@ -132,6 +167,242 @@ 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.
+165
View File
@@ -0,0 +1,165 @@
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"
+9 -9
View File
@@ -278,14 +278,14 @@ class VariableTruncator(BaseTruncator):
target_length = self._array_element_limit
for i, item in enumerate(value):
# 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
# ``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.
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, UpdatedVariable)):
if item is None or isinstance(item, (str, list, dict, bool, int, float, File, UpdatedVariable)):
part_result = self._truncate_json_primitives(item, remaining_budget)
else:
raise UnknownTypeError(f"got unknown type {type(item)} in array truncation")
+1 -1
View File
@@ -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 * 24)
exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
exp = int(exp_dt.timestamp())
payload = {
+1 -1
View File
@@ -27,7 +27,6 @@ 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,
@@ -44,6 +43,7 @@ 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,
) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | 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)
+19
View File
@@ -0,0 +1,19 @@
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,10 +17,11 @@ 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 import Template
from jinja2.sandbox import SandboxedEnvironment
try:
return Template(template).render(**variables)
env = SandboxedEnvironment()
return env.from_string(template).render(**variables)
except Exception as exc:
raise TemplateRenderError(str(exc)) from exc
@@ -97,6 +97,7 @@ 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")
@@ -178,6 +179,7 @@ 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),
@@ -185,6 +187,7 @@ 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"
@@ -209,6 +212,7 @@ 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),
@@ -236,6 +240,7 @@ 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,7 +24,10 @@ class TestWorkspaceService:
patch("services.workspace_service.dify_config") as mock_dify_config,
):
# Setup default mock returns
mock_feature_service.get_features.return_value.can_replace_logo = True
feature = mock_feature_service.get_features.return_value
feature.can_replace_logo = True
feature.billing.enabled = True
feature.billing.subscription.plan = "professional"
mock_tenant_service.has_roles.return_value = True
mock_dify_config.FILES_URL = "https://example.com/files"
@@ -112,7 +115,7 @@ class TestWorkspaceService:
assert result is not None
assert result["id"] == tenant.id
assert result["name"] == tenant.name
assert result["plan"] == tenant.plan
assert result["plan"] == "professional"
assert result["status"] == tenant.status
assert result["role"] == TenantAccountRole.OWNER
assert result["created_at"] == tenant.created_at
@@ -159,7 +162,7 @@ class TestWorkspaceService:
assert result is not None
assert result["id"] == tenant.id
assert result["name"] == tenant.name
assert result["plan"] == tenant.plan
assert result["plan"] == "professional"
assert result["status"] == tenant.status
assert result["role"] == TenantAccountRole.OWNER
assert result["created_at"] == tenant.created_at
@@ -214,7 +217,7 @@ class TestWorkspaceService:
assert result is not None
assert result["id"] == tenant.id
assert result["name"] == tenant.name
assert result["plan"] == tenant.plan
assert result["plan"] == "professional"
assert result["status"] == tenant.status
assert result["role"] == TenantAccountRole.NORMAL
assert result["created_at"] == tenant.created_at
@@ -606,20 +609,23 @@ 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
):
"""next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode."""
"""Cloud-only billing data 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
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.billing.enabled = 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,6 +115,7 @@ 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"],
}
@@ -376,7 +377,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 created["active_config_is_published"] is False
assert "active_config_is_published" not in created
assert "bound_agent_id" not in created
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
@@ -487,7 +488,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 detail["active_config_is_published"] is False
assert "active_config_is_published" not in detail
assert "bound_agent_id" not in detail
assert captured["get_app"] == {"app": app_model, "session": session}
with app.test_request_context(
@@ -502,7 +503,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 updated["active_config_is_published"] is False
assert "active_config_is_published" not in updated
assert "bound_agent_id" not in updated
update_call = cast(dict[str, object], captured["update"])
assert update_call["app"] is app_model
@@ -845,9 +846,6 @@ 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",
@@ -1299,13 +1297,14 @@ 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)
assert unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)["variant"] == "agent_app"
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
assert composer["variant"] == "agent_app"
assert composer["active_config_is_published"] is True
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
with app.test_request_context(json=payload):
assert (
unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id)["variant"]
== "agent_app"
)
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 cast(dict[str, object], captured["save"])["agent_id"] == agent_id
assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), MagicMock(), "tenant-1", agent_id) == {
"result": "success",
@@ -15,9 +15,11 @@ 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
@@ -27,6 +29,10 @@ from models.provider import ProviderType
from services.entities.model_provider_entities import (
CustomConfigurationResponse,
CustomConfigurationStatus,
ModelProviderCustomConfigurationSummaryResponse,
ModelProviderPluginSummaryResponse,
ModelProviderSummaryResponse,
ModelProviderSystemConfigurationSummaryResponse,
ProviderResponse,
SystemConfigurationResponse,
)
@@ -140,6 +146,80 @@ 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,6 +28,7 @@ from controllers.console.workspace.plugin import (
PluginFetchMarketplacePkgApi,
PluginFetchPermissionApi,
PluginIconApi,
PluginInstalledIdsApi,
PluginInstallFromGithubApi,
PluginInstallFromMarketplaceApi,
PluginInstallFromPkgApi,
@@ -41,12 +42,16 @@ 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 PluginDeclaration, PluginEntity, PluginInstallation
from core.plugin.entities.plugin import PluginCategory, 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,
@@ -445,7 +450,7 @@ class TestPluginCategoryListApi:
mock_list = MagicMock(list=[plugin_item], has_more=True)
with (
app.test_request_context("/?page=2&page_size=10"),
app.test_request_context("/?page=2&page_size=10&query=weather&tags=search&tags=rag&language=zh_Hans"),
patch(
"controllers.console.workspace.plugin.PluginService.list_by_category", return_value=mock_list
) as list_mock,
@@ -456,18 +461,75 @@ class TestPluginCategoryListApi:
):
result = method(api, "t1", "tool")
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
list_mock.assert_called_once_with(
"t1",
"tool",
2,
10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
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")
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"]
def test_non_tool_category_does_not_include_builtin_tools(self, app: Flask):
api = PluginCategoryListApi()
@@ -730,6 +792,39 @@ 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()
@@ -0,0 +1,64 @@
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,6 +634,11 @@ 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/"
)
@@ -661,3 +666,112 @@ 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,6 +163,7 @@ 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": {
@@ -383,6 +384,7 @@ 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,7 +3,42 @@ 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 IconType, Site
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,
)
def test_build_site_icon_url_uses_s3_presigned_url() -> None:
@@ -442,6 +442,13 @@ 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)
@@ -475,6 +482,8 @@ 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
@@ -542,6 +551,13 @@ 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)
@@ -574,6 +590,8 @@ 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,6 +435,7 @@ 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())
@@ -461,6 +462,7 @@ 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,3 +1,6 @@
import logging
from unittest.mock import Mock
import pytest
from core.app.apps.base_app_generator import BaseAppGenerator
@@ -369,6 +372,58 @@ 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,6 +211,13 @@ 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")
@@ -244,6 +251,8 @@ 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
@@ -286,6 +295,8 @@ 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)
@@ -293,6 +304,13 @@ 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(
@@ -331,5 +349,7 @@ 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,5 +1,9 @@
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,
@@ -28,3 +32,51 @@ 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,6 +15,70 @@ 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())
@@ -0,0 +1,67 @@
"""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,6 +28,19 @@ 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,6 +29,7 @@ from core.plugin.entities.plugin import (
)
from core.plugin.entities.plugin_daemon import (
PluginDecodeResponse,
PluginInstalledIdsDaemonResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginInstallTaskStatus,
@@ -132,7 +133,13 @@ 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
"test-tenant",
category=PluginCategory.Tool,
page=2,
page_size=10,
query="weather",
tags=["search", "rag"],
language="zh_Hans",
)
mock_request.assert_called_once()
@@ -141,6 +148,9 @@ 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
@@ -156,6 +166,23 @@ 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,6 +22,23 @@ 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,6 +1372,19 @@ 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,6 +952,21 @@ 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"
@@ -0,0 +1,43 @@
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,6 +548,7 @@ 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",
@@ -567,6 +568,7 @@ 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():
@@ -610,7 +612,11 @@ 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})
monkeypatch.setattr(
AgentComposerService,
"load_agent_composer",
lambda **kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published},
)
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
@@ -628,7 +634,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}
assert result == {"loaded": True, "active_config_is_published": False}
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
@@ -657,7 +663,11 @@ 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})
monkeypatch.setattr(
AgentComposerService,
"load_agent_composer",
lambda **_kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published},
)
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
@@ -666,7 +676,7 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
}
)
AgentComposerService.save_agent_app_composer(
result = AgentComposerService.save_agent_app_composer(
session=session,
tenant_id="tenant-1",
app_id="app-1",
@@ -675,6 +685,7 @@ 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,7 +806,105 @@ 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 (
@@ -850,7 +948,13 @@ class TestPluginModelProviderCacheInvalidation:
assert result is plugins
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
"tenant-1", PluginCategory.Model, 1, 100
"tenant-1",
PluginCategory.Model,
1,
100,
query="",
tags=(),
language="en_US",
)
invalidate_cache.assert_called_once_with("tenant-1")
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
@@ -917,14 +1021,42 @@ class TestPluginModelProviderCacheInvalidation:
invalidate_cache.assert_not_called()
store_marker.assert_called_once_with("tenant-1", remote_plugin_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."""
@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."""
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=True)
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)
with (
patch(f"{MODULE}.PluginInstaller") as installer_cls,
@@ -2,6 +2,7 @@ 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
@@ -1325,7 +1326,10 @@ 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:
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,
):
RegisterService.setup(
"admin@example.com",
"Admin User",
@@ -1344,7 +1348,39 @@ class TestRegisterService:
session=sqlite_session,
)
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
assert sqlite_session.scalar(select(DifySetup)) is not None
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
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,12 +5,15 @@ 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
from services.model_provider_service import ModelProviderService, _ProviderSummaryState
def _create_service_with_mocked_manager() -> tuple[ModelProviderService, MagicMock]:
@@ -96,6 +99,247 @@ 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()
@@ -0,0 +1,333 @@
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,3 +673,118 @@ 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
@@ -0,0 +1,40 @@
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)
+3 -3
View File
@@ -2740,11 +2740,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.10.2"
version = "6.14.2"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+54 -1
View File
@@ -531,6 +531,14 @@ 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
@@ -539,6 +547,9 @@ 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
@@ -546,7 +557,8 @@ services:
retries: 3
start_period: 10s
networks:
- default
- agent_sandbox_network
- local_sandbox_proxy_network
# plugin daemon
plugin_daemon:
@@ -672,6 +684,33 @@ 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
@@ -681,6 +720,7 @@ 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:
[
@@ -1253,6 +1293,19 @@ 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
View File
@@ -199,6 +199,7 @@ 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:
[
+54 -1
View File
@@ -537,6 +537,14 @@ 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
@@ -545,6 +553,9 @@ 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
@@ -552,7 +563,8 @@ services:
retries: 3
start_period: 10s
networks:
- default
- agent_sandbox_network
- local_sandbox_proxy_network
# plugin daemon
plugin_daemon:
@@ -678,6 +690,33 @@ 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
@@ -687,6 +726,7 @@ 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:
[
@@ -1259,6 +1299,19 @@ 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:
@@ -0,0 +1,25 @@
#!/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
+16 -10
View File
@@ -74,16 +74,22 @@ if [ -n "${SSRF_SANDBOX_PROXY_PORT:-}" ]; then
} >> "$SANDBOX_PROXY_CONF"
fi
# 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
# 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
/usr/sbin/squid -Nz
echo "[ENTRYPOINT] starting squid"
@@ -0,0 +1,21 @@
# 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