Compare commits

..

38 Commits

Author SHA1 Message Date
4b49988d4b docs: remove repo billing context 2026-07-07 10:45:51 -07:00
2f6688c19b docs: add pay by invoice billing context 2026-07-07 10:43:09 -07:00
abd720146d test(services): cover DSL import and plugin migration regressions (#36072)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-07 15:31:04 +00:00
5308b95aff refactor(web): reduce query atom subscriptions (#38521) 2026-07-07 15:10:58 +00:00
09c5c5e5ed refactor(test): replace SimpleNamespace with typed mocks in schedule service tests (#38393) 2026-07-07 14:36:17 +00:00
64aa142681 chore(api): cache the setup status to cut down DB access (#36966)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
2026-07-07 14:30:28 +00:00
56f3d0a11e refactor(web): clarify app context bootstrap graph (#38516)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 14:08:38 +00:00
6edce14e88 fix: can't debug model plugins (#38500) 2026-07-07 13:03:32 +00:00
yyh
3ddfba5ca5 fix(web): add backdrop blur to skip nav (#38517) 2026-07-07 13:02:30 +00:00
yyh
2c6ec1a761 refactor(web): move app context layout styles to shell (#38511) 2026-07-07 09:47:30 +00:00
yyh
31b17513c2 docs(component): document focus-visible guidance (#38509) 2026-07-07 08:58:35 +00:00
fb92e9a347 chore: improve cherry pick missed message (#38496) 2026-07-07 07:53:14 +00:00
6922c45489 chore: update editor permission (#38505) 2026-07-07 07:53:01 +00:00
faaa4708a6 fix: editor should not manage member (#38503) 2026-07-07 07:39:24 +00:00
dd0c4a2296 fix: resolve 36288 mypy errors (#37850)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-07 06:37:20 +00:00
f3ba28463b chore: add sqlite3 to conftest (#38475) 2026-07-07 05:04:44 +00:00
800c9f4fca test(e2e): stabilize Agent v2 external runtime checks (#38493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 04:48:12 +00:00
5a342f9258 feat(mcp): support MCP protocol 2025-06-18 for workflow-as-MCP server (version negotiation + structured output) (#37892)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-07 03:26:50 +00:00
dbd3316615 chore: set NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW default to true (#38362)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 02:20:15 +00:00
0a3426ea38 refactor(api): clarify DSL import and plugin migration boundaries (#38483)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 02:06:49 +00:00
b9c7199d34 fix(web): unify detail sidebar home control (#38487) 2026-07-07 01:44:14 +00:00
fc01d112a0 refactor(api): Stop masking refresh-token service errors as 401 (#38463)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-06 18:14:54 +00:00
yyh
d0ea5a5e0d chore(agent-v2): sync changes (#38442)
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com>
Co-authored-by: 盐粒 Yanli <mail@yanli.one>
2026-07-06 13:51:33 +00:00
yyh
bdb3469ca0 refactor(dify-ui): consolidate radio family API (#38479) 2026-07-06 12:27:49 +00:00
c47663d77b chore: Update sidebar web app menu translations (#38473) 2026-07-06 10:09:36 +00:00
1247fa28f1 fix(web): redirect imported apps with creator permissions (#38460) 2026-07-06 09:11:56 +00:00
64d72c6fb9 feat(api): pass app_id to model plugins for provider-side cost attribution (#35859)
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-06 08:54:16 +00:00
78ca1a8a76 fix(api): resolve plugin external user ids in backwards invocation (#38098)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harshkashyap@Harshs-MacBook-Pro.local>
2026-07-06 08:25:26 +00:00
5aae5c15b3 refactor(api): migrate web auth endpoints to BaseModel (#37961)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:53:21 +00:00
b1724073fc test: example use sqlite3 as unittest backend (#38159) 2026-07-06 07:31:51 +00:00
904fadde20 refactor(api): remove member field compatibility (#37966)
Co-authored-by: WH-2099 <wh2099@pm.me>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:00:43 +00:00
77ae583b44 refactor(api): migrate dataset rag pipeline endpoints to BaseModel (#37958)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-07-06 07:00:04 +00:00
c3b1508712 refactor(api): migrate workspace account endpoints to BaseModel (#37954) 2026-07-06 06:57:40 +00:00
yyh
8208b786ee docs(dify-ui): clarify radio composition stories (#38456) 2026-07-06 06:40:17 +00:00
d9c99daf29 fix(api): isolate side-effect session writes in multimodal and RAG handlers (#38210)
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-07-06 05:17:12 +00:00
93eb6d32b5 fix(web): update snippet placeholder icon color (#38445) 2026-07-06 05:10:36 +00:00
de5af6c2da build(deps): bump the github-actions-dependencies group across 1 directory with 12 updates (#38430)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 04:39:10 +00:00
586c8de1a0 refactor: drop redundant len(tag_ids)==0 check in get_target_ids_by_tag_ids (#38447) 2026-07-06 04:35:58 +00:00
498 changed files with 11877 additions and 5399 deletions

View File

@ -24,6 +24,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
@ -53,6 +54,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- `jotai-tanstack-query` query atoms do not support TanStack Query tracked properties. A component that reads `useAtomValue(queryAtom)` subscribes to the whole query result, even if it only accesses `data`, `isLoading`, or `isError`. Export field-specific derived atoms and have components read the exact fields they render; use `selectAtom(queryAtom, result => result.field)` for query-result fields so unchanged selections do not notify subscribers. Keep direct `useAtomValue(queryAtom)` only when the component or hook genuinely needs the full observer result.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.

View File

@ -45,7 +45,7 @@ while IFS= read -r commit_sha; do
)
if [[ -z "$source_sha" ]]; then
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT If version differences prevent using git cherry-pick -x, manually add '(cherry picked from commit <sha>)' to the commit message."
failed=1
continue
fi

View File

@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"

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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@ -73,12 +73,12 @@ jobs:
dify-agent/pyproject.toml
dify-agent/uv.lock
- if: github.event_name != 'merge_group'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@ -97,7 +97,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@ -107,7 +107,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
@ -159,10 +159,10 @@ jobs:
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.build_context }}
@ -197,14 +197,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |

View File

@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0

View File

@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 1

View File

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

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"

View File

@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.context }}

View File

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

View File

@ -46,8 +46,8 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |

View File

@ -18,8 +18,8 @@ jobs:
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true

View File

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

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@ -36,7 +36,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: false
python-version: "3.12"
@ -88,7 +88,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -139,7 +139,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -168,7 +168,7 @@ jobs:
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
@ -185,7 +185,7 @@ jobs:
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
@ -196,7 +196,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@ -214,7 +214,7 @@ jobs:
.editorconfig
- name: Super-linter
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
if: steps.changed-files.outputs.any_changed == 'true'
env:
BASH_SEVERITY: warning

View File

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

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@ -16,7 +16,7 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.account import TenantPluginAutoUpgradeCategory, TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
@ -188,13 +188,13 @@ def transform_datasource_credentials(environment: str):
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
jina_plugin_id = "langgenius/jina_datasource"
if environment == "online":
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
notion_package_identifier = plugin_migration._fetch_latest_package_identifier(notion_plugin_id)
firecrawl_package_identifier = plugin_migration._fetch_latest_package_identifier(firecrawl_plugin_id)
jina_package_identifier = plugin_migration._fetch_latest_package_identifier(jina_plugin_id)
else:
notion_plugin_unique_identifier = None
firecrawl_plugin_unique_identifier = None
jina_plugin_unique_identifier = None
notion_package_identifier = None
firecrawl_package_identifier = None
jina_package_identifier = None
oauth_credential_type = CredentialType.OAUTH2
api_key_credential_type = CredentialType.API_KEY
@ -219,9 +219,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if notion_plugin_id not in installed_plugins_ids:
if notion_plugin_unique_identifier:
if notion_package_identifier:
# install notion plugin
PluginService.install_from_marketplace_pkg(tenant_id, [notion_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [notion_package_identifier])
auth_count = 0
for notion_tenant_credential in notion_tenant_credentials:
auth_count += 1
@ -279,9 +279,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if firecrawl_plugin_id not in installed_plugins_ids:
if firecrawl_plugin_unique_identifier:
if firecrawl_package_identifier:
# install firecrawl plugin
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_package_identifier])
auth_count = 0
for firecrawl_tenant_credential in firecrawl_tenant_credentials:
@ -343,10 +343,10 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if jina_plugin_id not in installed_plugins_ids:
if jina_plugin_unique_identifier:
if jina_package_identifier:
# install jina plugin
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
logger.debug("Installing Jina plugin %s", jina_package_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_package_identifier])
auth_count = 0
for jina_tenant_credential in jina_tenant_credentials:
@ -406,7 +406,7 @@ def migrate_data_for_plugin():
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
category_count = len(TenantPluginAutoUpgradeCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)

View File

@ -0,0 +1,54 @@
from typing import Any
from sqlalchemy import select
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from extensions.ext_database import db
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App
def get_published_agent_app_feature_dict_and_user_input_form(
app_model: App,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return public Agent App parameters backed by the published Agent Soul."""
app_model_config = app_model.app_model_config
agent_id = app_model.bound_agent_id
if not agent_id:
raise AgentAppGeneratorError("Agent App has no bound Agent")
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
# active_config_is_published means the draft has no unpublished edits; the public app
# can still read parameters from the active snapshot while a newer draft is pending.
if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
features_dict = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)

View File

@ -108,14 +108,8 @@ class SandboxReadResponse(ResponseModel):
text: str | None = None
class SandboxToolFileResponse(ResponseModel):
transfer_method: Literal["tool_file"] = "tool_file"
reference: str
class SandboxUploadResponse(ResponseModel):
path: str
file: SandboxToolFileResponse
url: str
register_schema_models(
@ -225,7 +219,7 @@ class AgentAppSandboxReadResource(Resource):
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required
@ -322,7 +316,7 @@ class WorkflowAgentSandboxReadResource(Resource):
)
class WorkflowAgentSandboxUploadResource(Resource):
@console_ns.doc("upload_workflow_agent_sandbox_file")
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
@setup_required

View File

@ -124,6 +124,7 @@ Use only the current Build chat message history to identify changes that need to
validate old config unless the message history already shows that the old config is invalid.
Only update the build-draft config note when the current Build chat contains durable context that later runs need.
Write the config note in the language used by the message history.
Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config
skills, config env, tools, models, knowledge, or prompt settings.

View File

@ -9,7 +9,12 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from constants.languages import get_valid_language
from controllers.common.fields import SimpleResultDataResponse, SimpleResultOptionalDataResponse, SimpleResultResponse
from controllers.common.fields import (
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
@ -51,7 +56,7 @@ from models.account import Account
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
from services.billing_service import BillingService
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
from services.errors.account import AccountRegisterError
from services.errors.account import AccountRegisterError, RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
@ -87,6 +92,7 @@ register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPay
register_response_schema_models(
console_ns,
SimpleResultDataResponse,
SimpleResultMessageResponse,
SimpleResultOptionalDataResponse,
SimpleResultResponse,
)
@ -154,16 +160,19 @@ class LoginApi(Resource):
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return {
"result": "fail",
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
}
return SimpleResultOptionalDataResponse(
result="fail",
data="workspace not found, please contact system admin to invite you to join in a workspace",
).model_dump(mode="json")
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
# response-contract:ignore cookie-bearing Flask response
response = make_response(
SimpleResultOptionalDataResponse(result="success").model_dump(mode="json", exclude_none=True)
)
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
@ -178,12 +187,11 @@ class LogoutApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@with_current_user
def post(self, account: Account):
if isinstance(account, flask_login.AnonymousUserMixin):
response = make_response({"result": "success"})
else:
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
if not isinstance(account, flask_login.AnonymousUserMixin):
AccountService.logout(account=account)
flask_login.logout_user()
response = make_response({"result": "success"})
# Clear cookies on logout
clear_access_token_from_cookie(response)
@ -219,7 +227,7 @@ class ResetPasswordSendEmailApi(Resource):
is_allow_register=FeatureService.get_system_features().is_allow_register,
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/email-code-login")
@ -252,7 +260,7 @@ class EmailCodeLoginSendEmailApi(Resource):
else:
token = AccountService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/email-code-login/validity")
@ -326,7 +334,8 @@ class EmailCodeLoginApi(Resource):
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body
response = make_response({"result": "success"})
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
set_csrf_token_to_cookie(request, response, token_pair.csrf_token)
# Set HTTP-only secure cookies for tokens
@ -338,26 +347,34 @@ class EmailCodeLoginApi(Resource):
@console_ns.route("/refresh-token")
class RefreshTokenApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(401, "Unauthorized", console_ns.models[SimpleResultMessageResponse.__name__])
def post(self):
# Get refresh token from cookie instead of request body
refresh_token = extract_refresh_token(request)
if not refresh_token:
return {"result": "fail", "message": "No refresh token provided"}, 401
return SimpleResultMessageResponse(result="fail", message="No refresh token provided").model_dump(
mode="json"
), 401
try:
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
except Unauthorized as exc:
return SimpleResultMessageResponse(result="fail", message=exc.description or "Unauthorized.").model_dump(
mode="json"
), 401
except (RefreshTokenNotFoundError, RefreshTokenAccountNotFoundError) as exc:
return SimpleResultMessageResponse(result="fail", message=str(exc)).model_dump(mode="json"), 401
# Create response with new cookies
response = make_response({"result": "success"})
# Create response with new cookies
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
except Exception as e:
return {"result": "fail", "message": str(e)}, 401
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
def _get_account_with_case_fallback(email: str):

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.common.fields import RedirectResponse, SimpleResultResponse
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
@ -19,11 +19,13 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.entities.provider_entities import ProviderConfig
from core.plugin.entities.plugin_daemon import PluginOAuthAuthorizationUrlResponse
from core.plugin.impl.oauth import OAuthHandler
from core.tools.entities.common_entities import I18nObject
from fields.base import ResponseModel
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.provider_ids import DatasourceProviderID
@ -33,7 +35,9 @@ from services.plugin.oauth_service import OAuthProxyService
class DatasourceCredentialPayload(BaseModel):
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any]
credentials: dict[str, Any] = Field(
description="Plugin-defined credential parameters. The schema is declared by the datasource provider."
)
class DatasourceCredentialDeletePayload(BaseModel):
@ -43,11 +47,17 @@ class DatasourceCredentialDeletePayload(BaseModel):
class DatasourceCredentialUpdatePayload(BaseModel):
credential_id: str
name: str | None = Field(default=None, max_length=100)
credentials: dict[str, Any] | None = Field(default=None)
credentials: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined credential parameters. The schema is declared by the datasource provider.",
)
class DatasourceCustomClientPayload(BaseModel):
client_params: dict[str, Any] | None = Field(default=None)
client_params: dict[str, Any] | None = Field(
default=None,
description="Plugin-defined OAuth client parameters. The schema is declared by the datasource provider.",
)
enable_oauth_custom_client: bool | None = None
@ -71,8 +81,48 @@ class DatasourceOAuthCallbackQuery(BaseModel):
context_id: str | None = Field(default=None, description="OAuth proxy context ID")
class DatasourceCredentialsResponse(ResponseModel):
result: Any
class DatasourceCredentialResponse(ResponseModel):
credential: dict[str, Any] = Field(
description="Obfuscated plugin-defined credential parameters from the datasource provider."
)
type: str
name: str
avatar_url: str | None
id: str
is_default: bool
class DatasourceCredentialListResponse(ResponseModel):
result: list[DatasourceCredentialResponse]
class DatasourceOAuthSchemaResponse(ResponseModel):
client_schema: list[ProviderConfig]
credentials_schema: list[ProviderConfig]
oauth_custom_client_params: dict[str, Any] | None = Field(
description="Masked plugin-defined OAuth client parameters, when configured for the tenant."
)
is_oauth_custom_client_enabled: bool
is_system_oauth_params_exists: bool
redirect_uri: str
class DatasourceProviderAuthResponse(ResponseModel):
author: str
provider: str
plugin_id: str
plugin_unique_identifier: str
icon: str
name: str
label: I18nObject
description: I18nObject
credential_schema: list[ProviderConfig]
oauth_schema: DatasourceOAuthSchemaResponse | None
credentials_list: list[DatasourceCredentialResponse]
class DatasourceProviderAuthListResponse(ResponseModel):
result: list[DatasourceProviderAuthResponse]
register_schema_models(
@ -88,9 +138,9 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
DatasourceCredentialsResponse,
DatasourceCredentialListResponse,
DatasourceProviderAuthListResponse,
PluginOAuthAuthorizationUrlResponse,
RedirectResponse,
SimpleResultResponse,
)
@ -100,7 +150,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthAuthorizationQuery))
@console_ns.response(
200,
"Authorization URL retrieved successfully",
"Datasource OAuth authorization URL generated successfully",
console_ns.models[PluginOAuthAuthorizationUrlResponse.__name__],
)
@setup_required
@ -140,7 +190,8 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
redirect_uri=redirect_uri,
system_credentials=oauth_config,
)
response = make_response(jsonable_encoder(authorization_url_response))
# response-contract:ignore cookie-bearing Flask response
response = make_response(dump_response(PluginOAuthAuthorizationUrlResponse, authorization_url_response))
response.set_cookie(
"context_id",
context_id,
@ -154,11 +205,8 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource):
@console_ns.route("/oauth/plugin/<path:provider_id>/datasource/callback")
class DatasourceOAuthCallback(Resource):
@console_ns.doc(params=query_params_from_model(DatasourceOAuthCallbackQuery))
@console_ns.response(
302,
"Redirect to console OAuth callback page",
console_ns.models[RedirectResponse.__name__],
)
# response-contract:ignore redirect response
@console_ns.response(302, "Redirect to OAuth callback page")
@setup_required
def get(self, provider_id: str):
context_id = request.cookies.get("context_id") or request.args.get("context_id")
@ -217,7 +265,9 @@ class DatasourceOAuthCallback(Resource):
@console_ns.route("/auth/plugin/datasource/<path:provider_id>")
class DatasourceAuth(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
200, "Datasource credential created successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -238,12 +288,16 @@ class DatasourceAuth(Resource):
)
except CredentialsValidateFailedError as ex:
raise ValueError(str(ex))
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceCredentialListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, user: Account, provider_id: str):
@ -256,7 +310,7 @@ class DatasourceAuth(Resource):
plugin_id=datasource_provider_id.plugin_id,
user=user,
)
return {"result": datasources}, 200
return dump_response(DatasourceCredentialListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/delete")
@ -282,13 +336,15 @@ class DatasourceAuthDeleteApi(Resource):
provider=provider_name,
plugin_id=plugin_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update")
class DatasourceAuthUpdateApi(Resource):
@console_ns.expect(console_ns.models[DatasourceCredentialUpdatePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
201, "Datasource credential updated successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -308,12 +364,16 @@ class DatasourceAuthUpdateApi(Resource):
credentials=payload.credentials or {},
name=payload.name,
)
return {"result": "success"}, 201
return SimpleResultResponse(result="success").model_dump(mode="json"), 201
@console_ns.route("/auth/plugin/datasource/list")
class DatasourceAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@console_ns.response(
200,
"Datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@ -321,12 +381,16 @@ class DatasourceAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_all_datasource_credentials(tenant_id=current_tenant_id)
return {"result": jsonable_encoder(datasources)}, 200
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/default-list")
class DatasourceHardCodeAuthListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[DatasourceCredentialsResponse.__name__])
@console_ns.response(
200,
"Default datasource credentials retrieved successfully",
console_ns.models[DatasourceProviderAuthListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@ -334,13 +398,15 @@ class DatasourceHardCodeAuthListApi(Resource):
def get(self, current_tenant_id: str):
datasource_provider_service = DatasourceProviderService()
datasources = datasource_provider_service.get_hard_code_datasource_credentials(tenant_id=current_tenant_id)
return {"result": jsonable_encoder(datasources)}, 200
return dump_response(DatasourceProviderAuthListResponse, {"result": datasources}), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/custom-client")
class DatasourceAuthOauthCustomClient(Resource):
@console_ns.expect(console_ns.models[DatasourceCustomClientPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(
200, "Datasource OAuth custom client saved successfully", console_ns.models[SimpleResultResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -357,7 +423,7 @@ class DatasourceAuthOauthCustomClient(Resource):
client_params=payload.client_params or {},
enabled=payload.enable_oauth_custom_client or False,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@setup_required
@login_required
@ -371,7 +437,7 @@ class DatasourceAuthOauthCustomClient(Resource):
tenant_id=current_tenant_id,
datasource_provider_id=datasource_provider_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/default")
@ -393,7 +459,7 @@ class DatasourceAuthDefaultApi(Resource):
datasource_provider_id=datasource_provider_id,
credential_id=payload.id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route("/auth/plugin/datasource/<path:provider_id>/update-name")
@ -416,4 +482,4 @@ class DatasourceUpdateProviderNameApi(Resource):
name=payload.name,
credential_id=payload.credential_id,
)
return {"result": "success"}, 200
return SimpleResultResponse(result="success").model_dump(mode="json"), 200

View File

@ -3,9 +3,9 @@ from typing import Any
from flask_restx import ( # type: ignore
Resource, # type: ignore
)
from pydantic import BaseModel, RootModel
from pydantic import BaseModel
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.datasets.wraps import get_rag_pipeline
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
@ -21,18 +21,13 @@ class Parser(BaseModel):
credential_id: str | None = None
class DataSourceContentPreviewResponse(RootModel[Any]):
root: Any
register_schema_models(console_ns, Parser)
register_response_schema_models(console_ns, DataSourceContentPreviewResponse)
@console_ns.route("/rag/pipelines/<uuid:pipeline_id>/workflows/published/datasource/nodes/<string:node_id>/preview")
class DataSourceContentPreviewApi(Resource):
@console_ns.expect(console_ns.models[Parser.__name__])
@console_ns.response(200, "Success", console_ns.models[DataSourceContentPreviewResponse.__name__])
@console_ns.response(200, "Success")
@setup_required
@login_required
@account_initialization_required

View File

@ -13,7 +13,6 @@ import services
from controllers.common.fields import (
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
)
from controllers.common.fields import Parameters as ParametersResponse
@ -391,7 +390,6 @@ register_response_schema_models(
ParametersResponse,
AudioBinaryResponse,
AudioTranscriptResponse,
GeneratedAppResponse,
SimpleResultResponse,
SiteResponse,
SuggestedQuestionsResponse,
@ -406,7 +404,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@ -434,6 +432,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@ -478,7 +477,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
@ -513,6 +512,7 @@ class TrialChatApi(TrialAppResource):
streaming=True,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@ -680,7 +680,7 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
@ -710,6 +710,7 @@ class TrialCompletionApi(TrialAppResource):
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")

View File

@ -13,7 +13,7 @@ from services.account_service import RegisterService, TenantService
from .error import AlreadySetupError, NotInitValidateError
from .init_validate import get_init_validate_status
from .wraps import only_edition_self_hosted
from .wraps import mark_setup_completed, only_edition_self_hosted
class SetupRequestPayload(BaseModel):
@ -96,6 +96,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
language=payload.language,
session=db.session,
)
mark_setup_completed()
return SetupResponse(result="success")

View File

@ -8,7 +8,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.account import TenantPluginPermission
from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission
def plugin_permission_required(
@ -40,22 +40,22 @@ def plugin_permission_required(
if install_required:
match permission.install_permission:
case TenantPluginPermission.InstallPermission.NOBODY:
case TenantPluginInstallPermission.NOBODY:
raise Forbidden()
case TenantPluginPermission.InstallPermission.ADMINS:
case TenantPluginInstallPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginPermission.InstallPermission.EVERYONE:
case TenantPluginInstallPermission.EVERYONE:
pass
if debug_required:
match permission.debug_permission:
case TenantPluginPermission.DebugPermission.NOBODY:
case TenantPluginDebugPermission.NOBODY:
raise Forbidden()
case TenantPluginPermission.DebugPermission.ADMINS:
case TenantPluginDebugPermission.ADMINS:
if not user.is_admin_or_owner:
raise Forbidden()
case TenantPluginPermission.DebugPermission.EVERYONE:
case TenantPluginDebugPermission.EVERYONE:
pass
return view(*args, **kwargs)

View File

@ -1,12 +1,13 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from http import HTTPStatus
from typing import Literal
import pytz
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, field_validator, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
@ -47,7 +48,7 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import Account as AccountResponse
from fields.member_fields import AccountResponse
from graphon.file import helpers as file_helpers
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp
@ -194,10 +195,6 @@ register_schema_models(
)
def _serialize_account(account) -> dict[str, Any]:
return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json")
class AccountIntegrateResponse(ResponseModel):
provider: str
created_at: int | None = None
@ -236,23 +233,15 @@ class EducationAutocompleteResponse(ResponseModel):
has_next: bool | None = None
class EducationActivateResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
register_schema_models(
console_ns,
AccountIntegrateResponse,
AccountIntegrateListResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
)
register_response_schema_models(
console_ns,
AccountResponse,
AccountIntegrateResponse,
AccountIntegrateListResponse,
AvatarUrlResponse,
EducationActivateResponse,
EducationVerifyResponse,
EducationStatusResponse,
EducationAutocompleteResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
@ -262,7 +251,7 @@ register_response_schema_models(
@console_ns.route("/account/init")
class AccountInitApi(Resource):
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@with_current_user
@ -302,7 +291,7 @@ class AccountInitApi(Resource):
account.initialized_at = naive_utc_now()
db.session.commit()
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/profile")
@ -310,11 +299,11 @@ class AccountProfileApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@enterprise_license_required
@with_current_user
def get(self, current_user: Account):
return _serialize_account(current_user)
return dump_response(AccountResponse, current_user)
@console_ns.route("/account/name")
@ -323,14 +312,14 @@ class AccountNameApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, session=db.session, name=args.name)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/avatar")
@ -338,7 +327,7 @@ class AccountAvatarApi(Resource):
@console_ns.doc("get_account_avatar")
@console_ns.doc(description="Get account avatar url")
@console_ns.doc(params=query_params_from_model(AccountAvatarQuery))
@console_ns.response(200, "Success", console_ns.models[AvatarUrlResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AvatarUrlResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -349,7 +338,7 @@ class AccountAvatarApi(Resource):
avatar = args.avatar
if avatar.startswith(("http://", "https://")):
return dump_response(AvatarUrlResponse, {"avatar_url": avatar})
return AvatarUrlResponse(avatar_url=avatar).model_dump(mode="json")
upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1))
if upload_file is None:
@ -362,13 +351,13 @@ class AccountAvatarApi(Resource):
raise NotFound("Avatar file not found")
avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id)
return dump_response(AvatarUrlResponse, {"avatar_url": avatar_url})
return AvatarUrlResponse(avatar_url=avatar_url).model_dump(mode="json")
@console_ns.expect(console_ns.models[AccountAvatarPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -376,7 +365,7 @@ class AccountAvatarApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-language")
@ -385,7 +374,7 @@ class AccountInterfaceLanguageApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -395,7 +384,7 @@ class AccountInterfaceLanguageApi(Resource):
current_user, session=db.session, interface_language=args.interface_language
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/interface-theme")
@ -404,7 +393,7 @@ class AccountInterfaceThemeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -414,7 +403,7 @@ class AccountInterfaceThemeApi(Resource):
current_user, session=db.session, interface_theme=args.interface_theme
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/timezone")
@ -423,7 +412,7 @@ class AccountTimezoneApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -431,7 +420,7 @@ class AccountTimezoneApi(Resource):
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/password")
@ -440,7 +429,7 @@ class AccountPasswordApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -452,7 +441,7 @@ class AccountPasswordApi(Resource):
except ServiceCurrentPasswordIncorrectError:
raise CurrentPasswordIncorrectError()
return _serialize_account(current_user)
return dump_response(AccountResponse, current_user)
@console_ns.route("/account/integrates")
@ -460,7 +449,7 @@ class AccountIntegrateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountIntegrateListResponse.__name__])
@with_current_user
def get(self, account: Account):
account_integrates = db.session.scalars(
@ -471,33 +460,29 @@ class AccountIntegrateApi(Resource):
oauth_base_path = "/console/api/oauth/login"
providers = ["github", "google"]
integrate_data = []
integrate_data: list[AccountIntegrateResponse] = []
for provider in providers:
existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None)
if existing_integrate:
integrate_data.append(
{
"id": existing_integrate.id,
"provider": provider,
"created_at": existing_integrate.created_at,
"is_bound": True,
"link": None,
}
AccountIntegrateResponse(
provider=provider,
created_at=to_timestamp(existing_integrate.created_at),
is_bound=True,
link=None,
)
)
else:
integrate_data.append(
{
"id": None,
"provider": provider,
"created_at": None,
"is_bound": False,
"link": f"{base_url}{oauth_base_path}/{provider}",
}
AccountIntegrateResponse(
provider=provider,
created_at=None,
is_bound=False,
link=f"{base_url}{oauth_base_path}/{provider}",
)
)
return AccountIntegrateListResponse(
data=[AccountIntegrateResponse.model_validate(item) for item in integrate_data]
).model_dump(mode="json")
return AccountIntegrateListResponse(data=integrate_data).model_dump(mode="json")
@console_ns.route("/account/delete/verify")
@ -505,19 +490,19 @@ class AccountDeleteVerifyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@with_current_user
def get(self, account: Account):
token, code = AccountService.generate_account_deletion_verification_code(account)
AccountService.send_account_deletion_verification_email(account, code)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/account/delete")
class AccountDeleteApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -531,13 +516,13 @@ class AccountDeleteApi(Resource):
AccountService.delete_account(account)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/delete/feedback")
class AccountDeleteUpdateFeedbackApi(Resource):
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@ -545,7 +530,7 @@ class AccountDeleteUpdateFeedbackApi(Resource):
BillingService.update_account_deletion_feedback(args.email, args.feedback)
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/account/education/verify")
@ -555,18 +540,19 @@ class EducationVerifyApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationVerifyResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
return EducationVerifyResponse.model_validate(
BillingService.EducationIdentity.verify(account.id, account.email) or {}
).model_dump(mode="json")
return dump_response(
EducationVerifyResponse, BillingService.EducationIdentity.verify(account.id, account.email) or {}
)
@console_ns.route("/account/education")
class EducationApi(Resource):
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[EducationActivateResponse.__name__])
# response-contract:ignore billing-service activation payload; TODO: model education activation result.
@console_ns.response(HTTPStatus.OK, "Success")
@setup_required
@login_required
@account_initialization_required
@ -577,21 +563,22 @@ class EducationApi(Resource):
payload = console_ns.payload or {}
args = EducationActivatePayload.model_validate(payload)
return BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
result = BillingService.EducationIdentity.activate(account, args.token, args.institution, args.role)
return result
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationStatusResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
res = BillingService.EducationIdentity.status(account.id) or {}
# convert expire_at to UTC timestamp from isoformat
if res and "expire_at" in res:
res["expire_at"] = datetime.fromisoformat(res["expire_at"]).astimezone(pytz.utc)
return EducationStatusResponse.model_validate(res).model_dump(mode="json")
return dump_response(EducationStatusResponse, res)
@console_ns.route("/account/education/autocomplete")
@ -602,20 +589,21 @@ class EducationAutoCompleteApi(Resource):
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(200, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
payload = request.args.to_dict(flat=True)
args = EducationAutocompleteQuery.model_validate(payload)
return EducationAutocompleteResponse.model_validate(
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {}
).model_dump(mode="json")
return dump_response(
EducationAutocompleteResponse,
BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) or {},
)
@console_ns.route("/account/change-email")
class ChangeEmailSendEmailApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@enable_change_email
@setup_required
@login_required
@ -669,13 +657,13 @@ class ChangeEmailSendEmailApi(Resource):
language=language,
phase=send_phase,
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/account/change-email/validity")
class ChangeEmailCheckApi(Resource):
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@enable_change_email
@setup_required
@login_required
@ -716,7 +704,9 @@ class ChangeEmailCheckApi(Resource):
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
return VerificationTokenResponse(is_valid=True, email=normalized_token_email, token=new_token).model_dump(
mode="json"
)
@console_ns.route("/account/change-email/reset")
@ -726,7 +716,7 @@ class ChangeEmailResetApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
@with_current_user
def post(self, current_user: Account):
payload = console_ns.payload or {}
@ -768,13 +758,13 @@ class ChangeEmailResetApi(Resource):
email=normalized_new_email,
)
return _serialize_account(updated_account)
return dump_response(AccountResponse, updated_account)
@console_ns.route("/account/change-email/check-email-unique")
class CheckEmailUnique(Resource):
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
def post(self):
payload = console_ns.payload or {}
@ -784,4 +774,4 @@ class CheckEmailUnique(Resource):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email, session=db.session):
raise EmailAlreadyInUseError()
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@ -6,13 +6,17 @@ verb-based aliases stay available as deprecated resources so OpenAPI metadata
marks only the legacy paths as deprecated.
"""
from datetime import datetime
from enum import StrEnum
from http import HTTPStatus
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.fields import SuccessResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
@ -24,8 +28,14 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user_id,
)
from core.entities.parameter_entities import (
AppSelectorScope,
ModelSelectorScope,
ToolSelectorScope,
)
from core.entities.provider_entities import ProviderConfigType
from core.plugin.impl.exc import PluginPermissionDeniedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from fields.base import ResponseModel
from libs.login import login_required
from services.plugin.endpoint_service import EndpointService
@ -40,14 +50,17 @@ class EndpointIdPayload(BaseModel):
endpoint_id: str
class EndpointUpdatePayload(BaseModel):
class EndpointSettingsPayload(BaseModel):
settings: dict[str, Any]
name: str = Field(min_length=1)
class LegacyEndpointUpdatePayload(EndpointIdPayload):
settings: dict[str, Any]
name: str = Field(min_length=1)
class EndpointUpdatePayload(EndpointSettingsPayload):
pass
class LegacyEndpointUpdatePayload(EndpointIdPayload, EndpointSettingsPayload):
pass
class EndpointListQuery(BaseModel):
@ -59,98 +72,158 @@ class EndpointListForPluginQuery(EndpointListQuery):
plugin_id: str
class EndpointCreateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderConfigScope(StrEnum):
ALL = AppSelectorScope.ALL.value
CHAT = AppSelectorScope.CHAT.value
WORKFLOW = AppSelectorScope.WORKFLOW.value
COMPLETION = AppSelectorScope.COMPLETION.value
LLM = ModelSelectorScope.LLM.value
TEXT_EMBEDDING = ModelSelectorScope.TEXT_EMBEDDING.value
RERANK = ModelSelectorScope.RERANK.value
TTS = ModelSelectorScope.TTS.value
SPEECH2TEXT = ModelSelectorScope.SPEECH2TEXT.value
MODERATION = ModelSelectorScope.MODERATION.value
VISION = ModelSelectorScope.VISION.value
CUSTOM = ToolSelectorScope.CUSTOM.value
BUILTIN = ToolSelectorScope.BUILTIN.value
class EndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigI18nResponse(ResponseModel):
en_US: str
zh_Hans: str | None = None
pt_BR: str | None = None
ja_JP: str | None = None
class PluginEndpointListResponse(BaseModel):
endpoints: list[dict[str, Any]] = Field(
description="Endpoint information",
)
class EndpointProviderConfigOptionResponse(ResponseModel):
value: str
label: EndpointProviderConfigI18nResponse
class EndpointDeleteResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderConfigResponse(ResponseModel):
type: ProviderConfigType
name: str
scope: EndpointProviderConfigScope | None = None
required: bool = False
default: int | str | float | bool | None = None
options: list[EndpointProviderConfigOptionResponse] | None = None
multiple: bool = False
label: EndpointProviderConfigI18nResponse | None = None
help: EndpointProviderConfigI18nResponse | None = None
url: str | None = None
placeholder: EndpointProviderConfigI18nResponse | None = None
class EndpointUpdateResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointDeclarationResponse(ResponseModel):
path: str
method: str
hidden: bool = False
class EndpointEnableResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointProviderDeclarationResponse(ResponseModel):
settings: list[EndpointProviderConfigResponse] = Field(default_factory=list)
endpoints: list[EndpointDeclarationResponse] | None = Field(default_factory=list)
class EndpointDisableResponse(BaseModel):
success: bool = Field(description="Operation success")
class EndpointListItemResponse(ResponseModel):
id: str
created_at: datetime
updated_at: datetime
tenant_id: str
plugin_id: str
settings: dict[str, Any]
expired_at: datetime
declaration: EndpointProviderDeclarationResponse = Field(default_factory=EndpointProviderDeclarationResponse)
name: str
enabled: bool
url: str
hook_id: str
class EndpointListResponse(ResponseModel):
endpoints: list[EndpointListItemResponse] = Field(description="Endpoint information")
register_schema_models(
console_ns,
EndpointCreatePayload,
EndpointIdPayload,
EndpointSettingsPayload,
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
EndpointListQuery,
EndpointListForPluginQuery,
EndpointCreateResponse,
)
register_response_schema_models(
console_ns,
SuccessResponse,
EndpointProviderConfigOptionResponse,
EndpointProviderConfigResponse,
EndpointDeclarationResponse,
EndpointProviderDeclarationResponse,
EndpointListItemResponse,
EndpointListResponse,
PluginEndpointListResponse,
EndpointDeleteResponse,
EndpointUpdateResponse,
EndpointEnableResponse,
EndpointDisableResponse,
)
def _create_endpoint(tenant_id: str, user_id: str) -> dict[str, bool]:
def _create_endpoint(tenant_id: str, user_id: str) -> bool:
"""Create a plugin endpoint for the injected workspace and user."""
args = EndpointCreatePayload.model_validate(console_ns.payload)
try:
return {
"success": EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
}
return EndpointService.create_endpoint(
tenant_id=tenant_id,
user_id=user_id,
plugin_unique_identifier=args.plugin_unique_identifier,
name=args.name,
settings=args.settings,
)
except PluginPermissionDeniedError as e:
raise ValueError(e.description) from e
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
def _update_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
"""Update a plugin endpoint identified by the canonical path parameter."""
args = EndpointUpdatePayload.model_validate(console_ns.payload)
return {
"success": EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
}
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
name=args.name,
settings=args.settings,
)
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> dict[str, bool]:
def _legacy_update_endpoint(tenant_id: str, user_id: str) -> bool:
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return EndpointService.update_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=args.endpoint_id,
name=args.name,
settings=args.settings,
)
def _delete_endpoint(tenant_id: str, user_id: str, endpoint_id: str) -> bool:
"""Delete a plugin endpoint identified by the canonical path parameter."""
return {
"success": EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
}
return EndpointService.delete_endpoint(
tenant_id=tenant_id,
user_id=user_id,
endpoint_id=endpoint_id,
)
def _delete_endpoint_from_payload(tenant_id: str, user_id: str) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
def _set_endpoint_enabled(tenant_id: str, user_id: str, *, enabled: bool) -> bool:
args = EndpointIdPayload.model_validate(console_ns.payload)
action = EndpointService.enable_endpoint if enabled else EndpointService.disable_endpoint
return action(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
@console_ns.route("/workspaces/current/endpoints")
@ -161,11 +234,11 @@ class EndpointCollectionApi(Resource):
@console_ns.doc(description="Create a new plugin endpoint")
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -174,7 +247,7 @@ class EndpointCollectionApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/create")
@ -190,11 +263,11 @@ class DeprecatedEndpointCreateApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointCreatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint created successfully",
console_ns.models[EndpointCreateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -203,7 +276,7 @@ class DeprecatedEndpointCreateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
return _create_endpoint(tenant_id=tenant_id, user_id=user_id)
return SuccessResponse(success=_create_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/list")
@ -212,7 +285,7 @@ class EndpointListApi(Resource):
@console_ns.doc(description="List plugin endpoints with pagination")
@console_ns.doc(params=query_params_from_model(EndpointListQuery))
@console_ns.response(
200,
HTTPStatus.OK,
"Success",
console_ns.models[EndpointListResponse.__name__],
)
@ -224,20 +297,15 @@ class EndpointListApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
page_size = args.page_size
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=page,
page_size=page_size,
)
}
endpoints = EndpointService.list_endpoints(
tenant_id=tenant_id,
user_id=user_id,
page=args.page,
page_size=args.page_size,
)
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/list/plugin")
class EndpointListForSinglePluginApi(Resource):
@ -245,9 +313,9 @@ class EndpointListForSinglePluginApi(Resource):
@console_ns.doc(description="List endpoints for a specific plugin")
@console_ns.doc(params=query_params_from_model(EndpointListForPluginQuery))
@console_ns.response(
200,
HTTPStatus.OK,
"Success",
console_ns.models[PluginEndpointListResponse.__name__],
console_ns.models[EndpointListResponse.__name__],
)
@setup_required
@login_required
@ -257,22 +325,16 @@ class EndpointListForSinglePluginApi(Resource):
def get(self, tenant_id: str, user_id: str):
args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True))
page = args.page
page_size = args.page_size
plugin_id = args.plugin_id
return jsonable_encoder(
{
"endpoints": EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=plugin_id,
page=page,
page_size=page_size,
)
}
endpoints = EndpointService.list_endpoints_for_single_plugin(
tenant_id=tenant_id,
user_id=user_id,
plugin_id=args.plugin_id,
page=args.page,
page_size=args.page_size,
)
return EndpointListResponse(endpoints=endpoints).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/<string:id>")
class EndpointItemApi(Resource):
@ -282,11 +344,11 @@ class EndpointItemApi(Resource):
@console_ns.doc(description="Delete a plugin endpoint")
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -295,18 +357,20 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def delete(self, tenant_id: str, user_id: str, id: str):
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
return SuccessResponse(
success=_delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
@console_ns.doc("update_endpoint")
@console_ns.doc(description="Update a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointUpdatePayload.__name__])
@console_ns.doc(params={"id": {"description": "Endpoint ID", "type": "string", "required": True}})
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -315,7 +379,9 @@ class EndpointItemApi(Resource):
@with_current_user_id
@with_current_tenant_id
def patch(self, tenant_id: str, user_id: str, id: str):
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
return SuccessResponse(
success=_update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=id)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/delete")
@ -332,11 +398,11 @@ class DeprecatedEndpointDeleteApi(Resource):
)
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint deleted successfully",
console_ns.models[EndpointDeleteResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -345,8 +411,9 @@ class DeprecatedEndpointDeleteApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return _delete_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
return SuccessResponse(success=_delete_endpoint_from_payload(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
@console_ns.route("/workspaces/current/endpoints/update")
@ -363,11 +430,11 @@ class DeprecatedEndpointUpdateApi(Resource):
)
@console_ns.expect(console_ns.models[LegacyEndpointUpdatePayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint updated successfully",
console_ns.models[EndpointUpdateResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -376,8 +443,9 @@ class DeprecatedEndpointUpdateApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = LegacyEndpointUpdatePayload.model_validate(console_ns.payload)
return _update_endpoint(tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id)
return SuccessResponse(success=_legacy_update_endpoint(tenant_id=tenant_id, user_id=user_id)).model_dump(
mode="json"
)
@console_ns.route("/workspaces/current/endpoints/enable")
@ -386,11 +454,11 @@ class EndpointEnableApi(Resource):
@console_ns.doc(description="Enable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint enabled successfully",
console_ns.models[EndpointEnableResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -399,13 +467,9 @@ class EndpointEnableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.enable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=True)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/endpoints/disable")
@ -414,11 +478,11 @@ class EndpointDisableApi(Resource):
@console_ns.doc(description="Disable a plugin endpoint")
@console_ns.expect(console_ns.models[EndpointIdPayload.__name__])
@console_ns.response(
200,
HTTPStatus.OK,
"Endpoint disabled successfully",
console_ns.models[EndpointDisableResponse.__name__],
console_ns.models[SuccessResponse.__name__],
)
@console_ns.response(403, "Admin privileges required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Admin privileges required")
@setup_required
@login_required
@is_admin_or_owner_required
@ -427,10 +491,6 @@ class EndpointDisableApi(Resource):
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, user_id: str):
args = EndpointIdPayload.model_validate(console_ns.payload)
return {
"success": EndpointService.disable_endpoint(
tenant_id=tenant_id, user_id=user_id, endpoint_id=args.endpoint_id
)
}
return SuccessResponse(
success=_set_endpoint_enabled(tenant_id=tenant_id, user_id=user_id, enabled=False)
).model_dump(mode="json")

View File

@ -1,10 +1,12 @@
from http import HTTPStatus
from urllib import parse
from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound
import services
from configs import dify_config
@ -30,8 +32,8 @@ from controllers.console.wraps import (
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from fields.member_fields import AccountWithRoleListResponse, AccountWithRoleResponse
from libs.helper import dump_response, extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
@ -45,6 +47,11 @@ class MemberInvitePayload(BaseModel):
role: str
language: str | None = None
@field_validator("emails")
@classmethod
def normalize_emails(cls, emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
class MemberRoleUpdatePayload(BaseModel):
role: str
@ -70,14 +77,14 @@ class MemberInviteResultResponse(ResponseModel):
message: str | None = None
class MemberInviteResponse(ResponseModel):
class MemberActionResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
class MemberActionTenantResponse(ResponseModel):
class MemberInviteResponse(ResponseModel):
result: str
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
@ -92,13 +99,14 @@ register_schema_models(
)
register_response_schema_models(
console_ns,
AccountWithRole,
AccountWithRoleList,
AccountWithRoleResponse,
AccountWithRoleListResponse,
MemberActionResponse,
MemberInviteResponse,
MemberInviteResultResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
MemberInviteResponse,
MemberActionTenantResponse,
)
@ -124,10 +132,6 @@ def _normalize_enum_value(value: object) -> str:
return str(normalized) if normalized is not None else ""
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
@ -179,7 +183,7 @@ class MemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account | None = None):
if current_user is None:
@ -216,9 +220,7 @@ class MemberListApi(Resource):
}
)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
return dump_response(AccountWithRoleListResponse, {"accounts": serialized_members}), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/invite-email")
@ -226,7 +228,7 @@ class MemberInviteEmailApi(Resource):
"""Invite a new member by email."""
@console_ns.expect(console_ns.models[MemberInvitePayload.__name__])
@console_ns.response(201, "Success", console_ns.models[MemberInviteResponse.__name__])
@console_ns.response(HTTPStatus.CREATED, "Success", console_ns.models[MemberInviteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -235,26 +237,26 @@ class MemberInviteEmailApi(Resource):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = _normalize_invitee_emails(args.emails)
invitee_emails = args.emails
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
inviter = current_user
if not inviter.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
check_workspace_member_invite_permission(inviter.current_tenant.id)
invitation_results = []
invitation_results: list[MemberInviteResultResponse] = []
console_web_url = dify_config.CONSOLE_WEB_URL
tenant_id = inviter.current_tenant.id
@ -277,63 +279,65 @@ class MemberInviteEmailApi(Resource):
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
{
"status": "success",
"email": invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
MemberInviteResultResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
)
)
except AccountAlreadyInTenantError:
invitation_results.append(
{
"status": "already_member",
"email": invitee_email,
"message": "Account already in workspace.",
}
MemberInviteResultResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
)
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
invitation_results.append(
MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e))
)
return {
"result": "success",
"invitation_results": invitation_results,
"tenant_id": str(inviter.current_tenant.id) if inviter.current_tenant else "",
}, 201
return MemberInviteResponse(
result="success",
invitation_results=invitation_results,
tenant_id=inviter.current_tenant.id if inviter.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.CREATED
@console_ns.route("/workspaces/current/members/<uuid:member_id>")
class MemberCancelInviteApi(Resource):
"""Cancel an invitation by member id."""
@console_ns.response(200, "Success", console_ns.models[MemberActionTenantResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[MemberActionResponse.__name__])
@with_current_user
def delete(self, current_user: Account, member_id: UUID):
if not current_user.current_tenant:
raise ValueError("No current tenant")
member = db.session.get(Account, str(member_id))
if member is None:
abort(404)
abort(HTTPStatus.NOT_FOUND)
else:
try:
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except Exception as e:
raise ValueError(str(e))
return {
"result": "success",
"tenant_id": str(current_user.current_tenant.id) if current_user.current_tenant else "",
}, 200
return MemberActionResponse(
result="success",
tenant_id=current_user.current_tenant.id if current_user.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/<uuid:member_id>/update-role")
@ -341,7 +345,7 @@ class MemberUpdateRoleApi(Resource):
"""Update member role."""
@console_ns.expect(console_ns.models[MemberRoleUpdatePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -352,14 +356,14 @@ class MemberUpdateRoleApi(Resource):
new_role = args.role
if not TenantAccountRole.is_valid_role(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not _is_role_enabled(new_role, current_user.current_tenant.id):
return {"code": "invalid-role", "message": "Invalid role"}, 400
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
abort(HTTPStatus.NOT_FOUND)
try:
assert member is not None, "Member not found"
@ -367,17 +371,17 @@ class MemberUpdateRoleApi(Resource):
current_user.current_tenant, member, new_role, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
return {"code": "cannot-operate-self", "message": str(e)}, HTTPStatus.BAD_REQUEST
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
return {"code": "forbidden", "message": str(e)}, HTTPStatus.FORBIDDEN
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
return {"code": "member-not-found", "message": str(e)}, HTTPStatus.NOT_FOUND
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, 400
return {"code": "role-already-assigned", "message": str(e)}, HTTPStatus.BAD_REQUEST
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")
@console_ns.route("/workspaces/current/dataset-operators")
@ -387,15 +391,13 @@ class DatasetOperatorMemberListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountWithRoleListResponse.__name__])
@with_current_user
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
return dump_response(AccountWithRoleListResponse, {"accounts": members}), HTTPStatus.OK
@console_ns.route("/workspaces/current/members/send-owner-transfer-confirm-email")
@ -403,7 +405,7 @@ class SendOwnerTransferEmailApi(Resource):
"""Send owner transfer email."""
@console_ns.expect(console_ns.models[OwnerTransferEmailPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -435,13 +437,13 @@ class SendOwnerTransferEmailApi(Resource):
workspace_name=current_user.current_tenant.name if current_user.current_tenant else "",
)
return {"result": "success", "data": token}
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/owner-transfer-check")
class OwnerTransferCheckApi(Resource):
@console_ns.expect(console_ns.models[OwnerTransferCheckPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -480,13 +482,13 @@ class OwnerTransferCheckApi(Resource):
_, new_token = AccountService.generate_owner_transfer_token(user_email, code=args.code, additional_data={})
AccountService.reset_owner_transfer_error_rate_limit(user_email)
return {"is_valid": True, "email": token_data.get("email"), "token": new_token}
return VerificationTokenResponse(is_valid=True, email=user_email, token=new_token).model_dump(mode="json")
@console_ns.route("/workspaces/current/members/<uuid:member_id>/owner-transfer")
class OwnerTransfer(Resource):
@console_ns.expect(console_ns.models[OwnerTransferPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -516,8 +518,7 @@ class OwnerTransfer(Resource):
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
return # Never reached, but helps type checker
raise NotFound()
if not current_user.current_tenant:
raise ValueError("No current tenant")
@ -546,4 +547,4 @@ class OwnerTransfer(Resource):
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
return SimpleResultResponse(result="success").model_dump(mode="json")

View File

@ -42,7 +42,15 @@ from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.account import (
Account,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategy,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginDebugPermission,
TenantPluginInstallPermission,
)
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
@ -52,9 +60,9 @@ from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
upgrade_mode: TenantPluginAutoUpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
@ -127,8 +135,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
class ParserDynamicOptions(BaseModel):
@ -150,16 +158,14 @@ class ParserDynamicOptionsWithCredentials(BaseModel):
class PluginPermissionSettingsPayload(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginInstallPermission = TenantPluginInstallPermission.EVERYONE
debug_permission: TenantPluginDebugPermission = TenantPluginDebugPermission.EVERYONE
class PluginAutoUpgradeSettingsPayload(BaseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting = (
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
)
strategy_setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY
upgrade_time_of_day: int = 0
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
upgrade_mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE
exclude_plugins: list[str] = Field(default_factory=list)
include_plugins: list[str] = Field(default_factory=list)
@ -170,15 +176,15 @@ class PluginAutoUpgradeChangeResponse(ResponseModel):
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
strategy_setting: TenantPluginAutoUpgradeStrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
upgrade_mode: TenantPluginAutoUpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
@ -209,19 +215,19 @@ class PluginDeclarationResponse(ResponseModel):
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
category: TenantPluginAutoUpgradeCategory
class ParserReadme(BaseModel):
@ -339,8 +345,8 @@ class PluginTaskResponse(ResponseModel):
class PluginPermissionResponse(ResponseModel):
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
install_permission: TenantPluginInstallPermission
debug_permission: TenantPluginDebugPermission
class PluginDynamicOptionsResponse(ResponseModel):
@ -408,22 +414,22 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
TenantPluginDebugPermission,
TenantPluginAutoUpgradeCategory,
TenantPluginAutoUpgradeMode,
TenantPluginAutoUpgradeStrategySetting,
TenantPluginInstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
category: TenantPluginAutoUpgradeCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"upgrade_mode": TenantPluginAutoUpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
@ -987,8 +993,8 @@ class PluginFetchPermissionApi(Resource):
if not permission:
return jsonable_encoder(
{
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
"install_permission": TenantPluginInstallPermission.EVERYONE,
"debug_permission": TenantPluginDebugPermission.EVERYONE,
}
)

View File

@ -4,7 +4,7 @@ from typing import Any
from urllib.parse import quote
from flask import Response, request
from flask_restx import Resource, marshal
from flask_restx import Resource
from pydantic import Field as PydanticField
from pydantic import field_validator
from sqlalchemy.orm import Session, sessionmaker
@ -37,8 +37,7 @@ from controllers.console.wraps import (
from core.plugin.entities.plugin import PluginDependency
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.snippet_fields import snippet_fields, snippet_list_fields
from libs.helper import to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.snippet import SnippetType
@ -198,13 +197,16 @@ class CustomizedSnippetsApi(Resource):
tag_ids=query.tag_ids,
)
return {
"data": marshal(snippets, snippet_list_fields),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
}, 200
return dump_response(
SnippetPaginationResponse,
{
"data": snippets,
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": has_more,
},
), 200
@console_ns.doc("create_customized_snippet")
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
@ -245,7 +247,7 @@ class CustomizedSnippetsApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 201
return dump_response(SnippetResponse, snippet), 201
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
@ -268,7 +270,7 @@ class CustomizedSnippetDetailApi(Resource):
if not snippet:
raise NotFound("Snippet not found")
return marshal(snippet, snippet_fields), 200
return dump_response(SnippetResponse, snippet), 200
@console_ns.doc("update_customized_snippet")
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
@ -317,7 +319,7 @@ class CustomizedSnippetDetailApi(Resource):
except ValueError as e:
return {"message": str(e)}, 400
return marshal(snippet, snippet_fields), 200
return dump_response(SnippetResponse, snippet), 200
@console_ns.doc("delete_customized_snippet")
@console_ns.response(204, "Snippet deleted successfully")
@ -533,4 +535,4 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
session.commit()
session.refresh(snippet)
return {"result": "success", "use_count": snippet.use_count}, 200
return SnippetUseCountResponse(result="success", use_count=snippet.use_count).model_dump(mode="json"), 200

View File

@ -1,8 +1,9 @@
import logging
from datetime import datetime
from http import HTTPStatus
from flask import request
from flask_restx import Resource, fields, marshal
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Unauthorized
@ -16,7 +17,12 @@ from controllers.common.errors import (
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
@ -31,7 +37,7 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
@ -133,7 +139,7 @@ class WorkspaceListItemResponse(ResponseModel):
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value):
def _normalize_enum_like(cls, value):
if value is None:
return None
if isinstance(value, str):
@ -146,7 +152,7 @@ class WorkspaceListItemResponse(ResponseModel):
return to_timestamp(value)
class WorkspaceListResponse(ResponseModel):
class WorkspacePaginationResponse(ResponseModel):
data: list[WorkspaceListItemResponse]
has_more: bool
limit: int
@ -159,7 +165,7 @@ class SwitchWorkspaceResponse(ResponseModel):
new_tenant: TenantInfoResponse
class WorkspaceMutationResponse(ResponseModel):
class WorkspaceTenantResultResponse(ResponseModel):
result: str
tenant: TenantInfoResponse
@ -174,6 +180,16 @@ class WorkspacePermissionResponse(ResponseModel):
allow_owner_transfer: bool
WORKSPACE_LOGO_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Workspace web app logo file. Only SVG and PNG files are supported.",
}
}
register_schema_models(
console_ns,
WorkspaceListQuery,
@ -184,53 +200,21 @@ register_schema_models(
register_response_schema_models(
console_ns,
TenantInfoResponse,
TenantListItemResponse,
TenantListResponse,
WorkspaceListResponse,
SwitchWorkspaceResponse,
WorkspaceMutationResponse,
WorkspaceLogoUploadResponse,
WorkspaceCustomConfigResponse,
WorkspaceListItemResponse,
WorkspacePaginationResponse,
SwitchWorkspaceResponse,
WorkspaceTenantResultResponse,
WorkspaceLogoUploadResponse,
WorkspacePermissionResponse,
)
provider_fields = {
"provider_name": fields.String,
"provider_type": fields.String,
"is_valid": fields.Boolean,
"token_is_set": fields.Boolean,
}
tenant_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"role": fields.String,
"in_trial": fields.Boolean,
"trial_end_reason": fields.String,
"custom_config": fields.Raw(attribute="custom_config"),
"trial_credits": fields.Integer,
"trial_credits_used": fields.Integer,
"next_credit_reset_date": fields.Integer,
}
tenants_fields = {
"id": fields.String,
"name": fields.String,
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
workspace_fields = {"id": fields.String, "name": fields.String, "status": fields.String, "created_at": TimestampField}
@console_ns.route("/workspaces")
class TenantListApi(Resource):
@console_ns.response(200, "Success", console_ns.models[TenantListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -281,18 +265,17 @@ class TenantListApi(Resource):
tenant_dicts.append(tenant_dict)
return {"workspaces": marshal(tenant_dicts, tenants_fields)}, 200
return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), HTTPStatus.OK
@console_ns.route("/all-workspaces")
class WorkspaceListApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceListQuery))
@console_ns.response(200, "Success", console_ns.models[WorkspaceListResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePaginationResponse.__name__])
@setup_required
@admin_required
def get(self):
payload = request.args.to_dict(flat=True)
args = WorkspaceListQuery.model_validate(payload)
args = query_params_from_request(WorkspaceListQuery)
stmt = select(Tenant).order_by(Tenant.created_at.desc())
tenants = paginate_query(stmt, page=args.page, per_page=args.limit)
@ -301,13 +284,9 @@ class WorkspaceListApi(Resource):
if tenants.has_next:
has_more = True
return {
"data": marshal(tenants.items, workspace_fields),
"has_more": has_more,
"limit": args.limit,
"page": args.page,
"total": tenants.total,
}, 200
return WorkspacePaginationResponse(
data=tenants.items, has_more=has_more, limit=args.limit, page=args.page, total=tenants.total or 0
).model_dump(mode="json"), HTTPStatus.OK
@console_ns.route("/workspaces/current", endpoint="workspaces_current")
@ -316,7 +295,7 @@ class TenantApi(Resource):
@setup_required
@login_required
@account_initialization_required
@console_ns.response(200, "Success", console_ns.models[TenantInfoResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantInfoResponse.__name__])
@with_current_user
def post(self, current_user: Account):
if request.path == "/info":
@ -336,13 +315,13 @@ class TenantApi(Resource):
else:
raise Unauthorized("workspace is archived")
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), 200
return dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant)), HTTPStatus.OK
@console_ns.route("/workspaces/switch")
class SwitchWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[SwitchWorkspacePayload.__name__])
@console_ns.response(200, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SwitchWorkspaceResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -351,7 +330,7 @@ class SwitchWorkspaceApi(Resource):
payload = console_ns.payload or {}
args = SwitchWorkspacePayload.model_validate(payload)
# check if tenant_id is valid, 403 if not
# Check whether the tenant_id belongs to the current account.
try:
TenantService.switch_tenant(current_user, args.tenant_id, session=db.session)
except Exception:
@ -361,13 +340,15 @@ class SwitchWorkspaceApi(Resource):
if new_tenant is None:
raise ValueError("Tenant not found")
return {"result": "success", "new_tenant": marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)}
return SwitchWorkspaceResponse(
result="success", new_tenant=WorkspaceService.get_tenant_info(new_tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/custom-config")
class CustomConfigWorkspaceApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -390,12 +371,15 @@ class CustomConfigWorkspaceApi(Resource):
tenant.custom_config_dict = custom_config_dict
db.session.commit()
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/custom-config/webapp-logo/upload")
class WebappLogoWorkspaceApi(Resource):
@console_ns.response(201, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@console_ns.doc(consumes=["multipart/form-data"], params=WORKSPACE_LOGO_UPLOAD_PARAMS)
@console_ns.response(HTTPStatus.CREATED, "Logo uploaded", console_ns.models[WorkspaceLogoUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -431,13 +415,13 @@ class WebappLogoWorkspaceApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return {"id": upload_file.id}, 201
return WorkspaceLogoUploadResponse(id=upload_file.id).model_dump(mode="json"), HTTPStatus.CREATED
@console_ns.route("/workspaces/info")
class WorkspaceInfoApi(Resource):
@console_ns.expect(console_ns.models[WorkspaceInfoPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[WorkspaceMutationResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -453,14 +437,16 @@ class WorkspaceInfoApi(Resource):
tenant.name = args.name
db.session.commit()
return {"result": "success", "tenant": marshal(WorkspaceService.get_tenant_info(tenant), tenant_fields)}
return WorkspaceTenantResultResponse(
result="success", tenant=WorkspaceService.get_tenant_info(tenant)
).model_dump(mode="json")
@console_ns.route("/workspaces/current/permission")
class WorkspacePermissionApi(Resource):
"""Get workspace permissions for the current workspace."""
@console_ns.response(200, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspacePermissionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -477,8 +463,8 @@ class WorkspacePermissionApi(Resource):
# Get workspace permissions from enterprise service
permission = EnterpriseService.WorkspacePermissionService.get_permission(current_tenant_id)
return {
"workspace_id": permission.workspace_id,
"allow_member_invite": permission.allow_member_invite,
"allow_owner_transfer": permission.allow_owner_transfer,
}, 200
return WorkspacePermissionResponse(
workspace_id=permission.workspace_id,
allow_member_invite=permission.allow_member_invite,
allow_owner_transfer=permission.allow_owner_transfer,
).model_dump(mode="json"), HTTPStatus.OK

View File

@ -4,7 +4,7 @@ import os
import time
from collections.abc import Callable
from functools import wraps
from typing import Any, Concatenate, overload
from typing import Any, Concatenate, Protocol, cast, overload
from flask import abort, request
from pydantic import BaseModel, ValidationError
@ -46,6 +46,60 @@ ERROR_MSG_INVALID_ENCRYPTED_DATA = "Invalid encrypted data"
ERROR_MSG_INVALID_ENCRYPTED_CODE = "Invalid encrypted code"
class OnceTrueCallable[**P](Protocol):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> bool: ...
def mark_success(self) -> None: ...
def reset_success(self) -> None: ...
def once_true[**P](func: Callable[P, bool]) -> OnceTrueCallable[P]:
"""Wrap a predicate so only a strict True result is memoized."""
has_success = False
def mark_success() -> None:
nonlocal has_success
has_success = True
def reset_success() -> None:
nonlocal has_success
has_success = False
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> bool:
nonlocal has_success
if has_success:
return True
result = func(*args, **kwargs)
if result is True:
has_success = True
return result
wrapper.mark_success = mark_success # type: ignore[attr-defined]
wrapper.reset_success = reset_success # type: ignore[attr-defined]
return cast(OnceTrueCallable[P], wrapper)
def mark_setup_completed() -> None:
"""Remember in this process that one-time self-hosted setup has completed."""
_is_setup_completed.mark_success()
@once_true
def _is_setup_completed() -> bool:
"""Check whether setup exists, caching only successful observations.
Use `once_true` instead of `@cache` because a pre-setup False result must not be memoized.
"""
return db.session.scalar(select(DifySetup).limit(1)) is not None
@overload
def account_initialization_required[T, **P, R](
view: Callable[Concatenate[T, P], R],
@ -246,7 +300,9 @@ def setup_required[T, **P, R](
@overload
def setup_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
def setup_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Require self-hosted bootstrap setup before serving protected routes."""
...
def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
@ -255,7 +311,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
# The overloads keep Resource methods method-aware for pyrefly while
# preserving support for plain functions used in tests and utilities.
# check setup
if dify_config.EDITION == "SELF_HOSTED" and not db.session.scalar(select(DifySetup).limit(1)):
if dify_config.EDITION == "SELF_HOSTED" and not _is_setup_completed():
if os.environ.get("INIT_PASSWORD"):
raise NotInitValidateError()
raise NotSetupError()

View File

@ -1,6 +1,6 @@
from typing import Any, Union
from flask import Response
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
from controllers.common.schema import register_schema_model
from controllers.mcp import mcp_ns
from core.mcp import types as mcp_types
from core.mcp.server.streamable_http import handle_mcp_request
from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protocol_version
from extensions.ext_database import db
from graphon.variables.input_entities import VariableEntity, VariableEntityType
from libs import helper
@ -68,6 +68,17 @@ class MCPAppApi(Resource):
request_id: Union[int, str] | None = args.id
mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True))
# Resolve the negotiated protocol version from the MCP-Protocol-Version header.
is_initialize = isinstance(mcp_request.root, mcp_types.InitializeRequest)
header_value = request.headers.get("MCP-Protocol-Version")
protocol_version = negotiate_protocol_version(header_value, is_initialize)
if protocol_version is None:
# A notification never receives a response, even with an unsupported header.
if isinstance(mcp_request, mcp_types.ClientNotification):
protocol_version = mcp_types.DEFAULT_NEGOTIATED_VERSION
else:
return self._protocol_version_error_response(request_id, header_value)
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
# Get MCP server and app
mcp_server, app = self._get_mcp_server_and_app(server_code, session)
@ -77,7 +88,28 @@ class MCPAppApi(Resource):
user_input_form = self._get_user_input_form(app)
# Handle notification vs request differently
return self._process_mcp_message(mcp_request, request_id, app, mcp_server, user_input_form, session)
return self._process_mcp_message(
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
)
def _protocol_version_error_response(
self, request_id: Union[int, str] | None, header_value: str | None
) -> Response:
"""Return a JSON-RPC error for an unsupported MCP-Protocol-Version header.
Per JSON-RPC 2.0, an error whose request id is unknown uses a null id, so we echo the
offending request's id directly (None -> null) instead of fabricating a placeholder.
"""
error_data = mcp_types.ErrorData(
code=mcp_types.INVALID_REQUEST,
message=f"Unsupported MCP-Protocol-Version: {header_value}",
)
error_response = {
"jsonrpc": "2.0",
"id": request_id,
"error": error_data.model_dump(by_alias=True, mode="json", exclude_none=True),
}
return helper.compact_generate_response(error_response)
def _get_mcp_server_and_app(self, server_code: str, session: Session) -> tuple[AppMCPServer, App]:
"""Get and validate MCP server and app in one query session"""
@ -104,12 +136,15 @@ class MCPAppApi(Resource):
mcp_server: AppMCPServer,
user_input_form: list[VariableEntity],
session: Session,
protocol_version: str,
) -> Response:
"""Process MCP message (notification or request)"""
if isinstance(mcp_request, mcp_types.ClientNotification):
return self._handle_notification(mcp_request)
else:
return self._handle_request(mcp_request, request_id, app, mcp_server, user_input_form, session)
return self._handle_request(
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
)
def _handle_notification(self, mcp_request: mcp_types.ClientNotification) -> Response:
"""Handle MCP notification"""
@ -127,12 +162,15 @@ class MCPAppApi(Resource):
mcp_server: AppMCPServer,
user_input_form: list[VariableEntity],
session: Session,
protocol_version: str,
) -> Response:
"""Handle MCP request"""
if request_id is None:
raise MCPRequestError(mcp_types.INVALID_REQUEST, "Request ID is required")
result = self._handle_mcp_request(app, mcp_server, mcp_request, user_input_form, session, request_id)
result = self._handle_mcp_request(
app, mcp_server, mcp_request, user_input_form, session, request_id, protocol_version
)
if result is None:
# This shouldn't happen for requests, but handle gracefully
raise MCPRequestError(mcp_types.INTERNAL_ERROR, "No response generated for request")
@ -229,6 +267,7 @@ class MCPAppApi(Resource):
user_input_form: list[VariableEntity],
session: Session,
request_id: Union[int, str],
protocol_version: str,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
"""Handle MCP request and return response"""
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
@ -238,4 +277,6 @@ class MCPAppApi(Resource):
client_name = f"{client_info.name}@{client_info.version}"
end_user = self._create_end_user(client_name, app.tenant_id, app.id, mcp_server.id, session)
return handle_mcp_request(session, app, mcp_request, user_input_form, mcp_server, end_user, request_id)
return handle_mcp_request(
session, app, mcp_request, user_input_form, mcp_server, end_user, request_id, protocol_version
)

View File

@ -49,6 +49,7 @@ class OpenApiErrorCode(StrEnum):
# domain codes (must match the error_code attribute of the exception
# classes raised on the openapi surface)
APP_UNAVAILABLE = "app_unavailable"
AGENT_NOT_PUBLISHED = "agent_not_published"
CONVERSATION_COMPLETED = "conversation_completed"
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"

View File

@ -2,19 +2,16 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@ -35,38 +32,13 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
try:
return get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):

View File

@ -15,6 +15,7 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@ -31,6 +32,7 @@ from controllers.service_api.schema import (
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@ -248,6 +250,8 @@ class CompletionApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@ -403,6 +407,8 @@ class ChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:

View File

@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the API."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."

View File

@ -1,9 +1,10 @@
from collections.abc import Generator
from datetime import datetime
from typing import Any
from uuid import UUID
from flask import request
from pydantic import BaseModel, Field, RootModel
from pydantic import BaseModel, Field, RootModel, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
@ -13,24 +14,20 @@ from controllers.common.errors import FilenameNotExistsError, NoFileUploadedErro
from controllers.common.fields import GeneratedAppResponse
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_model,
register_schema_models,
)
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import PipelineRunError
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
from controllers.service_api.schema import (
event_stream_response,
json_or_event_stream_response,
multipart_file_params,
)
from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params
from controllers.service_api.wraps import DatasetApiResource
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import current_user
from models import Account
from models.dataset import Dataset, Pipeline
@ -84,7 +81,7 @@ class DatasourcePluginResponse(ResponseModel):
datasource_type: str | None = None
title: str | None = None
user_input_variables: list[dict[str, Any]] = Field(default_factory=list)
credentials: list[DatasourceCredentialInfoResponse]
credentials: list[DatasourceCredentialInfoResponse] = Field(default_factory=list)
class DatasourcePluginListResponse(RootModel[list[DatasourcePluginResponse]]):
@ -100,14 +97,22 @@ class PipelineUploadFileResponse(ResponseModel):
created_by: str
created_at: str | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | str | None) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return value
register_schema_model(service_api_ns, DatasourceNodeRunPayload)
register_schema_model(service_api_ns, DatasourcePluginsQuery)
register_schema_model(service_api_ns, PipelineRunApiEntity)
register_schema_models(service_api_ns, DatasourcePluginsQuery)
register_response_schema_models(
service_api_ns,
DatasourceCredentialInfoResponse,
DatasourcePluginResponse,
DatasourcePluginListResponse,
GeneratedAppResponse,
PipelineUploadFileResponse,
)
@ -119,8 +124,8 @@ class DatasourcePluginsApi(DatasetApiResource):
@service_api_ns.doc(
summary="List Datasource Plugins",
description=(
"List the datasource nodes configured in the knowledge pipeline. Each node includes the "
"plugin it uses plus the metadata needed to run it."
"List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses "
"plus the metadata needed to run it."
),
tags=["Knowledge Pipeline"],
responses={
@ -152,14 +157,13 @@ class DatasourcePluginsApi(DatasetApiResource):
if not dataset:
raise NotFound("Dataset not found.")
# Get query parameter to determine published or draft
is_published: bool = request.args.get("is_published", default=True, type=bool)
query = query_params_from_request(DatasourcePluginsQuery)
rag_pipeline_service: RagPipelineService = RagPipelineService()
datasource_plugins: list[dict[Any, Any]] = rag_pipeline_service.get_datasource_plugins(
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=is_published
tenant_id=tenant_id, dataset_id=dataset_id_str, is_published=query.is_published
)
return datasource_plugins, 200
return dump_response(DatasourcePluginListResponse, datasource_plugins), 200
@service_api_ns.route("/datasets/<uuid:dataset_id>/pipeline/datasource/nodes/<string:node_id>/run")
@ -169,8 +173,8 @@ class DatasourceNodeRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Datasource Node",
description=(
"Execute a single datasource node within the knowledge pipeline. Returns a streaming "
"response with the node execution results."
"Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the "
"node execution results."
),
tags=["Knowledge Pipeline"],
responses={
@ -189,11 +193,6 @@ class DatasourceNodeRunApi(DatasetApiResource):
}
)
@service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__])
@service_api_ns.response(
200,
"Datasource node run successfully",
service_api_ns.models[GeneratedAppResponse.__name__],
)
def post(self, tenant_id: str, dataset_id: UUID, node_id: str):
"""Resource for getting datasource plugins."""
dataset_id_str = str(dataset_id)
@ -210,10 +209,11 @@ class DatasourceNodeRunApi(DatasetApiResource):
datasource_node_run_api_entity = DatasourceNodeRunApiEntity.model_validate(
{
**payload.model_dump(exclude_none=True),
"pipeline_id": str(pipeline.id),
"pipeline_id": pipeline.id,
"node_id": node_id,
}
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(
PipelineGenerator.convert_to_event_stream(
rag_pipeline_service.run_datasource_workflow_node(
@ -236,8 +236,8 @@ class PipelineRunApi(DatasetApiResource):
@service_api_ns.doc(
summary="Run Pipeline",
description=(
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and "
"blocking response modes."
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response "
"modes."
),
tags=["Knowledge Pipeline"],
responses={
@ -293,6 +293,7 @@ class PipelineRunApi(DatasetApiResource):
streaming=payload.response_mode == "streaming",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except Exception as ex:
raise PipelineRunError(description=str(ex))
@ -368,4 +369,4 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return serialize_upload_file(upload_file), 201
return dump_response(PipelineUploadFileResponse, upload_file), 201

View File

@ -1,32 +0,0 @@
"""
Serialization helpers for Service API knowledge pipeline endpoints.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from models.model import UploadFile
class UploadFileDict(TypedDict):
id: str
name: str
size: int
extension: str
mime_type: str | None
created_by: str
created_at: str | None
def serialize_upload_file(upload_file: UploadFile) -> UploadFileDict:
return {
"id": upload_file.id,
"name": upload_file.name,
"size": upload_file.size,
"extension": upload_file.extension,
"mime_type": upload_file.mime_type,
"created_by": upload_file.created_by,
"created_at": upload_file.created_at.isoformat() if upload_file.created_at else None,
}

View File

@ -8,8 +8,10 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, AppMode, EndUser
@ -19,7 +21,7 @@ from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService
from . import web_ns
from .error import AppUnavailableError
from .error import AgentNotPublishedError, AppUnavailableError
from .wraps import WebApiResource
logger = logging.getLogger(__name__)
@ -74,12 +76,21 @@ class AppParameterApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app parameters."""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
try:
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
raise AppUnavailableError()
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config

View File

@ -11,6 +11,7 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@ -22,6 +23,7 @@ from controllers.web.error import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from controllers.web.wraps import WebApiResource
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@ -138,6 +140,8 @@ class CompletionApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@ -235,6 +239,8 @@ class ChatApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:

View File

@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the web app."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."

View File

@ -9,6 +9,7 @@ from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from controllers.common.fields import (
AccessTokenData,
AccessTokenResultResponse,
LoginStatusResponse,
SimpleResultDataResponse,
@ -115,9 +116,10 @@ class LoginApi(Resource):
raise AuthenticationFailedError()
token = WebAppAuthService.login(account=account)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return response
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
# this api helps frontend to check whether user is authenticated
@ -136,14 +138,12 @@ class LoginStatusApi(Resource):
)
@web_ns.response(200, "Login status", web_ns.models[LoginStatusResponse.__name__])
def get(self):
app_code = request.args.get("app_code")
user_id = request.args.get("user_id")
query = LoginStatusQuery.model_validate(request.args.to_dict(flat=True))
app_code = query.app_code
user_id = query.user_id
token = extract_webapp_access_token(request)
if not app_code:
return {
"logged_in": bool(token),
"app_logged_in": False,
}
return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json")
app_id = AppService.get_app_id_by_code(app_code)
is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check(
app_id=app_id
@ -165,10 +165,7 @@ class LoginStatusApi(Resource):
except Exception:
app_logged_in = False
return {
"logged_in": user_logged_in,
"app_logged_in": app_logged_in,
}
return LoginStatusResponse(logged_in=user_logged_in, app_logged_in=app_logged_in).model_dump(mode="json")
@web_ns.route("/logout")
@ -183,7 +180,8 @@ class LogoutApi(Resource):
)
@web_ns.response(200, "Logout successful", web_ns.models[SimpleResultResponse.__name__])
def post(self):
response = make_response({"result": "success"})
# response-contract:ignore hand-crafted response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# enterprise SSO sets same site to None in https deployment
# so we need to logout by calling api
clear_webapp_access_token_from_cookie(response, samesite="None")
@ -216,9 +214,8 @@ class EmailCodeLoginSendEmailApi(Resource):
account = WebAppAuthService.get_user_through_email(payload.email)
if account is None:
raise AuthenticationFailedError()
else:
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return {"result": "success", "data": token}
token = WebAppAuthService.send_email_code_login_email(account=account, language=language)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@web_ns.route("/email-code-login/validity")
@ -277,9 +274,10 @@ class EmailCodeLoginApi(Resource):
token = WebAppAuthService.login(account=account)
AccountService.reset_login_error_rate_limit(user_email)
response = make_response({"result": "success", "data": {"access_token": token}})
# set_access_token_to_cookie(request, response, token, samesite="None", httponly=False)
return response
return AccessTokenResultResponse(result="success", data=AccessTokenData(access_token=token)).model_dump(
mode="json"
)
def _log_web_login_failure(*, email: str, reason: LoginFailureReason) -> None:

View File

@ -2,7 +2,7 @@ import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from flask import make_response, request
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import func, select
@ -10,11 +10,12 @@ from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from constants import HEADER_NAME_APP_CODE
from controllers.common.fields import AccessTokenData
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_access_token
from models.enums import EndUserType
@ -28,7 +29,13 @@ class PassportQuery(BaseModel):
register_schema_models(web_ns, PassportQuery)
register_response_schema_models(web_ns, AccessTokenData)
class PassportAccessTokenResponse(ResponseModel):
access_token: str
register_response_schema_models(web_ns, PassportAccessTokenResponse)
@web_ns.route("/passport")
@ -45,7 +52,7 @@ class PassportResource(Resource):
404: "Application or user not found",
}
)
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[AccessTokenData.__name__])
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__])
def get(self):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
@ -59,8 +66,11 @@ class PassportResource(Resource):
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
return dump_response(
PassportAccessTokenResponse,
exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
),
)
# get site from db and check if it is normal
@ -110,12 +120,7 @@ class PassportResource(Resource):
tk = PassportService().issue(payload)
response = make_response(
{
"access_token": tk,
}
)
return response
return dump_response(PassportAccessTokenResponse, {"access_token": tk})
def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None:
@ -206,12 +211,7 @@ def exchange_token_for_existing_web_user(
"exp": exp,
}
token: str = PassportService().issue(payload)
resp = make_response(
{
"access_token": token,
}
)
return resp
return {"access_token": token}
def _exchange_for_public_app_token(app_model, site, token_decoded):
@ -244,12 +244,7 @@ def _exchange_for_public_app_token(app_model, site, token_decoded):
tk = PassportService().issue(payload)
resp = make_response(
{
"access_token": tk,
}
)
return resp
return {"access_token": tk}
def generate_session_id():

View File

@ -133,6 +133,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
stop=app_generate_entity.model_conf.stop,
stream=True,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
)
usage_dict: dict[str, LLMUsage | None] = {}

View File

@ -101,6 +101,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
stop=app_generate_entity.model_conf.stop,
stream=self.stream_tool_call,
callbacks=[],
request_metadata={"app_id": self.app_config.app_id},
)
tool_calls: list[tuple[str, str, dict[str, Any]]] = []

View File

@ -61,10 +61,17 @@ _sensitive_word_avoidance_adapter: TypeAdapter[SensitiveWordAvoidanceConfig] = T
def _normalize_raw(raw: Any) -> Any:
if isinstance(raw, dict):
if raw.get("enabled") is None:
enabled = raw.get("enabled")
if enabled is None:
raw = {**raw, "enabled": False}
elif raw.get("enabled") is True and raw.get("config") is None:
raw = {**raw, "config": {}}
elif enabled is True:
if raw.get("config") is None:
raw = {**raw, "config": {}}
else:
# enabled is False or any falsy value —
# drop extra fields (type, config) so they don't
# violate SensitiveWordAvoidanceDisabledConfig.extra="forbid"
raw = {"enabled": False}
return raw

View File

@ -3,9 +3,9 @@
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
prompt) plus any app-level feature flags (opening statement, follow-up, …)
stored on ``app_model_config`` when present, then reuse the same sub-managers
the chat app type uses.
prompt) plus app-level feature flags from Agent Soul, while preserving any
legacy ``app_model_config`` feature flags when present. Then we reuse the same
sub-managers the chat app type uses.
"""
from typing import Any, cast
@ -21,6 +21,7 @@ from core.app.app_config.entities import (
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
@ -79,12 +80,11 @@ class AgentAppConfigManager(BaseAppConfigManager):
) -> dict[str, Any]:
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
Feature flags (opening statement / follow-up / tts / stt / citations /
moderation / annotation) come from ``app_model_config`` when present
(Q3: stored there), otherwise defaults; model + prompt always come from
Feature flags come from Agent Soul and fill gaps in the legacy
``app_model_config`` when one exists; model + prompt always come from
the Agent Soul (the single source of truth for those).
"""
base: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
base = merge_agent_app_features(agent_soul=agent_soul, app_model_config=app_model_config)
model = agent_soul.model
if model is not None:

View File

@ -0,0 +1,23 @@
from typing import Any
from models.agent_config_entities import AgentSoulConfig
def merge_agent_app_features(
*,
agent_soul: AgentSoulConfig,
app_model_config: Any | None,
) -> dict[str, Any]:
"""Project public Agent App features from legacy config plus Agent Soul.
The hidden backing app may still carry legacy presentation fields such as
opening statements. Agent Soul is the source of truth for Agent-owned
features like file upload, so Soul fields override same-named legacy keys.
"""
features: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True)
features.update(soul_features)
return features
__all__ = ["merge_agent_app_features"]

View File

@ -32,6 +32,7 @@ from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
@ -64,10 +65,6 @@ from services.conversation_service import ConversationService
logger = logging.getLogger(__name__)
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str:
"""Append raw request file references to the backend user prompt."""
if not prompt_file_mappings:
@ -614,6 +611,10 @@ class AgentAppGenerator(MessageBasedAppGenerator):
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
)
return agent, draft.id, config_version_kind, agent_soul
# active_config_is_published tracks whether the editable draft matches the active snapshot.
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published")
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
@ -709,4 +710,4 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return agent, draft, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError", "AgentAppNotPublishedError"]

View File

@ -372,14 +372,14 @@ class _AgentProcessRecorder:
row = MessageAgentThought(
message_id=self._message_id,
message_chain_id=None,
thought=thought,
tool=tool,
thought=thought or "",
tool=tool or "",
tool_labels_str=_tool_labels(tool),
tool_meta_str="{}",
tool_input=tool_input,
observation=None,
tool_input=tool_input or "",
observation="",
tool_process_data=None,
message=None,
message="",
message_token=0,
message_unit_price=Decimal(0),
message_price_unit=Decimal("0.001"),

View File

@ -0,0 +1,6 @@
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
class AgentAppNotPublishedError(AgentAppGeneratorError):
"""Raised when a public Agent App runtime is requested before publish."""

View File

@ -5,6 +5,8 @@ from collections.abc import Generator, Mapping, Sequence
from mimetypes import guess_extension
from typing import TYPE_CHECKING, Any, Union
from sqlalchemy.orm import sessionmaker
from core.app.app_config.entities import ExternalDataVariableEntity, PromptTemplateEntity
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
@ -423,7 +425,9 @@ class AppRunner:
_logger.exception("Failed to save image file")
return
# Create MessageFile record
# Create MessageFile record.
# Use an independent session so this side-effect write does not
# commit or close the caller's request-scoped session.
message_file = MessageFile(
message_id=message_id,
type=FileType.IMAGE,
@ -437,9 +441,8 @@ class AppRunner:
created_by=user_id,
)
db.session.add(message_file)
db.session.commit()
db.session.refresh(message_file)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(message_file)
# Publish QueueMessageFileEvent
queue_manager.publish(

View File

@ -232,6 +232,7 @@ class ChatAppRunner(AppRunner):
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
)
# handle invoke result

View File

@ -193,6 +193,7 @@ class CompletionAppRunner(AppRunner):
model_parameters=application_generate_entity.model_conf.parameters,
stop=stop,
stream=application_generate_entity.stream,
request_metadata={"app_id": app_config.app_id},
)
# handle invoke result

View File

@ -122,7 +122,7 @@ class MessageStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE
id: str
answer: str
from_variable_selector: list[str] | None = None
from_variable_selector: list[str] = Field(default_factory=list)
class MessageAudioStreamResponse(StreamResponse):
@ -151,7 +151,7 @@ class MessageEndStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE_END
id: str
metadata: Mapping[str, object] = Field(default_factory=dict)
files: Sequence[Mapping[str, Any]] | None = None
files: Sequence[Mapping[str, Any]] = Field(default_factory=list)
class MessageFileStreamResponse(StreamResponse):

View File

@ -1,6 +1,6 @@
import logging
import time
from collections.abc import Generator
from collections.abc import Generator, Mapping, Sequence
from threading import Thread
from typing import Any, cast
@ -44,7 +44,7 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.app.task_pipeline.message_file_utils import prepare_file_dict
from core.app.task_pipeline.message_file_utils import MessageFileInfoDict, prepare_file_dict
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.model_manager import ModelInstance
from core.ops.entities.trace_entity import TraceTaskName
@ -466,10 +466,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
:return:
"""
self._task_state.metadata.usage = self._task_state.llm_result.usage
metadata_dict = self._task_state.metadata.model_dump()
metadata_dict = self._task_state.metadata.model_dump(exclude_none=True)
# Fetch files associated with this message
files = None
files: list[MessageFileInfoDict] = []
with Session(db.engine, expire_on_commit=False) as session:
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
@ -492,13 +492,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
file_dict = prepare_file_dict(message_file, upload_files_map)
files_list.append(file_dict)
files = files_list or None
files = files_list
return MessageEndStreamResponse(
task_id=self._application_generate_entity.task_id,
id=self._message_id,
metadata=metadata_dict,
files=files,
files=cast(Sequence[Mapping[str, Any]], files),
)
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
@ -528,11 +528,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
task_id=self._application_generate_entity.task_id,
id=agent_thought.id,
position=agent_thought.position,
thought=agent_thought.thought,
observation=agent_thought.observation,
tool=agent_thought.tool,
thought=agent_thought.thought or "",
observation=agent_thought.observation or "",
tool=agent_thought.tool or "",
tool_labels=agent_thought.tool_labels,
tool_input=agent_thought.tool_input,
tool_input=agent_thought.tool_input or "",
message_files=agent_thought.files,
)

View File

@ -257,7 +257,7 @@ class MessageCycleManager:
task_id=self._application_generate_entity.task_id,
id=message_id,
answer=answer,
from_variable_selector=from_variable_selector,
from_variable_selector=from_variable_selector or [],
event=event_type or StreamEvent.MESSAGE,
)

View File

@ -2,7 +2,7 @@ import logging
from collections.abc import Sequence
from sqlalchemy import select, update
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import InvokeFrom
@ -10,6 +10,7 @@ from core.app.entities.queue_entities import QueueRetrieverResourcesEvent
from core.rag.entities import RetrievalSourceMetadata
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.models.document import Document
from extensions.ext_database import db
from models.dataset import ChildChunk, DatasetQuery, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DatasetQuerySource
@ -46,47 +47,52 @@ class DatasetIndexToolCallbackHandler:
created_by=self._user_id,
)
session.add(dataset_query)
session.commit()
# Use an independent session so this audit-log side effect does
# not commit or close the caller's request-scoped session.
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as independent_session:
independent_session.add(dataset_query)
def on_tool_end(self, documents: list[Document], session: scoped_session):
"""Handle tool end."""
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = session.scalar(child_chunk_stmt)
if child_chunk:
session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
# Use an independent session so hit-count updates do not
# interfere with the caller's request-scoped session.
with Session(db.engine, expire_on_commit=False) as independent_session:
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = independent_session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = independent_session.scalar(child_chunk_stmt)
if child_chunk:
independent_session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
# add hit count to document segment
session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
# add hit count to document segment
independent_session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
session.commit()
independent_session.commit()
# TODO(-LAN-): Improve type check
def return_retriever_resource_info(self, resource: Sequence[RetrievalSourceMetadata]):

View File

@ -3,7 +3,7 @@ from typing import Any
from core.datasource.__base.datasource_plugin import DatasourcePlugin
from core.datasource.entities.datasource_entities import DatasourceProviderEntityWithPlugin, DatasourceProviderType
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.plugin.impl.tool import PluginToolManager
from core.tools.errors import ToolProviderCredentialValidationError
@ -78,11 +78,11 @@ class DatasourcePluginProviderController(ABC):
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfig.Type.SELECT:
elif credential_schema.type == ProviderConfigType.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@ -107,9 +107,9 @@ class DatasourcePluginProviderController(ABC):
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
}:
default_value = str(default_value)

View File

@ -9,7 +9,7 @@ from urllib.parse import urlparse
from pydantic import BaseModel
from configs import dify_config
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.helper import encrypter
from core.helper.provider_cache import NoOpProviderCredentialCache
from core.mcp.types import OAuthClientInformation, OAuthClientMetadata, OAuthTokens
@ -315,7 +315,7 @@ class MCPProviderEntity(BaseModel):
return data
# Create dynamic config only for encrypted fields
config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in encrypted_fields]
config = [BasicProviderConfig(type=ProviderConfigType.SECRET_INPUT, name=key) for key in encrypted_fields]
encrypter_instance, _ = create_provider_encrypter(
tenant_id=self.tenant_id,

View File

@ -165,34 +165,35 @@ class ModelSettings(BaseModel):
model_config = ConfigDict(protected_namespaces=())
class ProviderConfigType(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfigType:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
class BasicProviderConfig(BaseModel):
"""
Base model class for common provider settings like credentials
"""
class Type(StrEnum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT
TEXT_INPUT = CommonParameterType.TEXT_INPUT
SELECT = CommonParameterType.SELECT
BOOLEAN = CommonParameterType.BOOLEAN
APP_SELECTOR = CommonParameterType.APP_SELECTOR
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR
@classmethod
def value_of(cls, value: str) -> ProviderConfig.Type:
"""
Get value of given mode.
:param value: mode value
:return: mode
"""
for mode in cls:
if mode.value == value:
return mode
raise ValueError(f"invalid mode value {value}")
type: Type = Field(..., description="The type of the credentials")
type: ProviderConfigType = Field(..., description="The type of the credentials")
name: str = Field(..., description="The name of the credentials")

View File

@ -13,7 +13,7 @@ from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTr
from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer
from core.helper.code_executor.template_transformer import TemplateTransformer
from core.helper.http_client_pooling import get_pooled_http_client
from graphon.nodes.code.entities import CodeLanguage
from graphon.nodes.code.entities import CodeLanguage as CodeLanguage # noqa: PLC0414
logger = logging.getLogger(__name__)
code_execution_endpoint_url = URL(str(dify_config.CODE_EXECUTION_ENDPOINT))
@ -133,7 +133,9 @@ class CodeExecutor:
return response_code.data.stdout or ""
@classmethod
def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]):
def execute_workflow_code_template(
cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]
) -> dict[str, Any]:
"""
Execute code
:param language: code language

View File

@ -11,7 +11,7 @@ class Jinja2TemplateTransformer(TemplateTransformer):
@classmethod
@override
def transform_response(cls, response: str):
def transform_response(cls, response: str) -> dict[str, Any]:
"""
Transform response to dict
:param response: response

View File

@ -36,14 +36,14 @@ class TemplateTransformer(ABC):
return runner_script, preload_script
@classmethod
def extract_result_str_from_response(cls, response: str):
def extract_result_str_from_response(cls, response: str) -> str:
result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL)
if not result:
raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...")
return result.group(1)
@classmethod
def transform_response(cls, response: str) -> Mapping[str, Any]:
def transform_response(cls, response: str) -> dict[str, Any]:
"""
Transform response to dict
:param response: response
@ -71,7 +71,7 @@ class TemplateTransformer(ABC):
return result
@classmethod
def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]:
def _post_process_result(cls, result: dict[str, Any]) -> dict[str, Any]:
"""
Post-process the result to convert scientific notation strings back to numbers
"""
@ -89,7 +89,7 @@ class TemplateTransformer(ABC):
return [convert_scientific_notation(v) for v in value]
return value
return convert_scientific_notation(result)
return {key: convert_scientific_notation(value) for key, value in result.items()}
@classmethod
@abstractmethod

View File

@ -24,7 +24,7 @@ def upload_dsl(dsl_file_bytes: bytes, filename: str = "template.yaml") -> str:
response.raise_for_status()
data = response.json()
claim_code = data.get("data", {}).get("claim_code")
if not claim_code:
if not isinstance(claim_code, str) or not claim_code:
raise ValueError("Creators Platform did not return a valid claim_code")
return claim_code

View File

@ -45,7 +45,7 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
def runtime_check_credential_policy_compliance(
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
):
) -> None:
if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK:
return
check_credential_policy_compliance(

View File

@ -1,4 +1,7 @@
def download_with_size_limit(url, max_download_size: int, **kwargs):
from typing import Any
def download_with_size_limit(url: str, max_download_size: int, **kwargs: Any) -> bytes:
from core.file import remote_fetcher
response = remote_fetcher.make_request("GET", url, follow_redirects=True, **kwargs)

View File

@ -1,5 +1,7 @@
import base64
from Crypto.PublicKey import RSA
from libs import rsa
@ -11,13 +13,13 @@ def obfuscated_token(token: str) -> str:
return token[:6] + "*" * 12 + token[-2:]
def full_mask_token(token_length=20):
def full_mask_token(token_length: int = 20) -> str:
return "*" * token_length
def encrypt_token(tenant_id: str, token: str):
from extensions.ext_database import db
def encrypt_token(tenant_id: str, token: str) -> str:
from models.account import Tenant
from models.engine import db
if not (tenant := db.session.get(Tenant, tenant_id)):
raise ValueError(f"Tenant with id {tenant_id} not found")
@ -30,15 +32,15 @@ def decrypt_token(tenant_id: str, token: str) -> str:
return rsa.decrypt(base64.b64decode(token), tenant_id)
def batch_decrypt_token(tenant_id: str, tokens: list[str]):
def batch_decrypt_token(tenant_id: str, tokens: list[str]) -> list[str]:
rsa_key, cipher_rsa = rsa.get_decrypt_decoding(tenant_id)
return [rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa) for token in tokens]
def get_decrypt_decoding(tenant_id: str):
def get_decrypt_decoding(tenant_id: str) -> tuple[RSA.RsaKey, object]:
return rsa.get_decrypt_decoding(tenant_id)
def decrypt_token_with_decoding(token: str, rsa_key, cipher_rsa):
def decrypt_token_with_decoding(token: str, rsa_key: RSA.RsaKey, cipher_rsa: object) -> str:
return rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa)

View File

@ -1,5 +1,6 @@
import logging
from collections.abc import Sequence
from typing import Any
from urllib.parse import urlencode
import httpx
@ -21,7 +22,7 @@ def get_plugin_pkg_url(plugin_unique_identifier: str) -> str:
return f"{marketplace_api_url / 'api/v1/plugins/download'}?{query}"
def download_plugin_pkg(plugin_unique_identifier: str):
def download_plugin_pkg(plugin_unique_identifier: str) -> bytes:
return download_with_size_limit(get_plugin_pkg_url(plugin_unique_identifier), dify_config.PLUGIN_MAX_PACKAGE_SIZE)
@ -41,7 +42,7 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]]
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict[str, Any]]:
if not plugin_ids:
return []
@ -55,10 +56,19 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
response.raise_for_status()
data = response.json()
return data.get("data", {}).get("plugins", [])
plugins = data.get("data", {}).get("plugins", [])
if not isinstance(plugins, list):
raise ValueError("Marketplace did not return a valid plugins list")
result: list[dict[str, Any]] = []
for plugin in plugins:
if not isinstance(plugin, dict) or not all(isinstance(key, str) for key in plugin):
raise ValueError("Marketplace did not return a valid plugins list")
result.append(plugin)
return result
def record_install_plugin_event(plugin_unique_identifier: str):
def record_install_plugin_event(plugin_unique_identifier: str) -> None:
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT)
response.raise_for_status()

View File

@ -34,7 +34,7 @@ class ProviderCredentialsCache:
else:
return None
def set(self, credentials: dict[str, Any]):
def set(self, credentials: dict[str, Any]) -> None:
"""
Cache model provider credentials.
@ -43,7 +43,7 @@ class ProviderCredentialsCache:
"""
redis_client.setex(self.cache_key, 86400, json.dumps(credentials))
def delete(self):
def delete(self) -> None:
"""
Delete cached model provider credentials.

View File

@ -20,17 +20,18 @@ def import_module_from_source[T: (str, bytes)](
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
else:
# Refer to: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
# FIXME: mypy does not support the type of spec.loader
spec = importlib.util.spec_from_file_location(module_name, py_file_path) # type: ignore[assignment]
if not spec or not spec.loader:
new_spec = importlib.util.spec_from_file_location(module_name, py_file_path)
if not new_spec or not new_spec.loader:
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
if use_lazy_loader:
# Refer to: https://docs.python.org/3/library/importlib.html#implementing-lazy-imports
spec.loader = importlib.util.LazyLoader(spec.loader)
new_spec.loader = importlib.util.LazyLoader(new_spec.loader)
spec = new_spec
module = importlib.util.module_from_spec(spec)
if not existed_spec:
sys.modules[module_name] = module
spec.loader.exec_module(module)
if spec.loader is not None:
spec.loader.exec_module(module)
return module
except Exception as e:
logger.exception("Failed to load module %s from script file '%s'", module_name, repr(py_file_path))

View File

@ -9,11 +9,11 @@ from extensions.ext_redis import redis_client
class ProviderCredentialsCache(ABC):
"""Base class for provider credentials cache"""
def __init__(self, **kwargs):
def __init__(self, **kwargs: Any) -> None:
self.cache_key = self._generate_cache_key(**kwargs)
@abstractmethod
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
"""Generate cache key based on subclass implementation"""
pass
@ -28,11 +28,11 @@ class ProviderCredentialsCache(ABC):
return None
return None
def set(self, config: dict[str, Any]):
def set(self, config: dict[str, Any]) -> None:
"""Cache provider credentials"""
redis_client.setex(self.cache_key, 86400, json.dumps(config))
def delete(self):
def delete(self) -> None:
"""Delete cached provider credentials"""
redis_client.delete(self.cache_key)
@ -48,7 +48,7 @@ class SingletonProviderCredentialsCache(ProviderCredentialsCache):
)
@override
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
tenant_id = kwargs["tenant_id"]
provider_type = kwargs["provider_type"]
identity_name = kwargs["provider_identity"]
@ -63,7 +63,7 @@ class ToolProviderCredentialsCache(ProviderCredentialsCache):
super().__init__(tenant_id=tenant_id, provider=provider, credential_id=credential_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
tenant_id = kwargs["tenant_id"]
provider = kwargs["provider"]
credential_id = kwargs["credential_id"]
@ -77,10 +77,10 @@ class NoOpProviderCredentialCache:
"""Get cached provider credentials"""
return None
def set(self, config: dict[str, Any]):
def set(self, config: dict[str, Any]) -> None:
"""Cache provider credentials"""
pass
def delete(self):
def delete(self) -> None:
"""Delete cached provider credentials"""
pass

View File

@ -3,7 +3,7 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any, Protocol
from core.entities.provider_entities import BasicProviderConfig
from core.entities.provider_entities import BasicProviderConfig, ProviderConfigType
from core.helper import encrypter
@ -60,7 +60,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "")
data[field_name] = encrypted
@ -81,7 +81,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
if len(data[field_name]) > 6:
data[field_name] = (
@ -112,7 +112,7 @@ class ProviderConfigEncrypter:
fields[credential.name] = credential
for field_name, field in fields.items():
if field.type == BasicProviderConfig.Type.SECRET_INPUT:
if field.type == ProviderConfigType.SECRET_INPUT:
if field_name in data:
with contextlib.suppress(Exception):
# if the value is None or empty string, skip decrypt
@ -125,5 +125,7 @@ class ProviderConfigEncrypter:
return data
def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache):
def create_provider_encrypter(
tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache
) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]:
return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache

View File

@ -37,11 +37,11 @@ class ToolParameterCache:
else:
return None
def set(self, parameters: dict[str, Any]):
def set(self, parameters: dict[str, Any]) -> None:
"""Cache model provider credentials."""
redis_client.setex(self.cache_key, 86400, json.dumps(parameters))
def delete(self):
def delete(self) -> None:
"""
Delete cached model provider credentials.

View File

@ -61,7 +61,7 @@ def get_external_trace_id(request: Any) -> str | None:
return None
def extract_external_trace_id_from_args(args: Mapping[str, Any]):
def extract_external_trace_id_from_args(args: Mapping[str, Any]) -> dict[str, Any]:
"""
Extract 'external_trace_id' from args.

View File

@ -15,6 +15,36 @@ from services.app_generate_service import AppGenerateService
logger = logging.getLogger(__name__)
# Structured tool output (outputSchema + structuredContent) was introduced in MCP 2025-06-18.
STRUCTURED_OUTPUT_MIN_VERSION = "2025-06-18"
def _supports_structured_output(protocol_version: str) -> bool:
"""Return True when the negotiated protocol version supports structured tool output.
MCP protocol versions are YYYY-MM-DD strings, so lexical comparison equals chronological.
"""
return protocol_version >= STRUCTURED_OUTPUT_MIN_VERSION
def negotiate_protocol_version(header_value: str | None, is_initialize: bool) -> str | None:
"""Resolve the negotiated protocol version for an incoming MCP request.
The version is taken from the MCP-Protocol-Version header on post-initialize requests.
Returns the version to use for behavior gating, or None when the client sent an explicit
but unsupported header (the caller should reply with a JSON-RPC INVALID_REQUEST error).
Initialize requests negotiate via the request body, so they always receive
DEFAULT_NEGOTIATED_VERSION and their header is never validated or rejected.
"""
if is_initialize:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
# Treat an absent or empty header as "not specified" -> default version.
if not header_value:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
if header_value not in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
return None
return header_value
class ToolParameterSchemaDict(TypedDict):
type: str
@ -35,6 +65,7 @@ def handle_mcp_request(
mcp_server: AppMCPServer,
end_user: EndUser | None = None,
request_id: int | str = 1,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError:
"""
Handle MCP request and return JSON-RPC response
@ -77,15 +108,24 @@ def handle_mcp_request(
# Dispatch request to appropriate handler based on instance type
match request_root:
case mcp_types.InitializeRequest():
return create_success_response(handle_initialize(mcp_server.description))
return create_success_response(
handle_initialize(mcp_server.description, request_root.params.protocolVersion)
)
case mcp_types.ListToolsRequest():
return create_success_response(
handle_list_tools(
app.name, app.mode, user_input_form, mcp_server.description, mcp_server.parameters_dict
app.name,
app.mode,
user_input_form,
mcp_server.description,
mcp_server.parameters_dict,
protocol_version,
)
)
case mcp_types.CallToolRequest():
return create_success_response(handle_call_tool(session, app, request, user_input_form, end_user))
return create_success_response(
handle_call_tool(session, app, request, user_input_form, end_user, protocol_version)
)
case mcp_types.PingRequest():
return create_success_response(handle_ping())
case _:
@ -104,14 +144,22 @@ def handle_ping() -> mcp_types.EmptyResult:
return mcp_types.EmptyResult()
def handle_initialize(description: str) -> mcp_types.InitializeResult:
"""Handle initialize request"""
def handle_initialize(description: str, requested_version: str | int) -> mcp_types.InitializeResult:
"""Handle initialize request, negotiating the protocol version with the client.
Echoes the client's requested version when the server supports it, otherwise returns the
server's latest supported version (per the MCP lifecycle spec).
"""
negotiated_version: str = mcp_types.SERVER_LATEST_PROTOCOL_VERSION
if isinstance(requested_version, str) and requested_version in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
negotiated_version = requested_version
capabilities = mcp_types.ServerCapabilities(
tools=mcp_types.ToolsCapability(listChanged=False),
)
return mcp_types.InitializeResult(
protocolVersion=mcp_types.SERVER_LATEST_PROTOCOL_VERSION,
protocolVersion=negotiated_version,
capabilities=capabilities,
serverInfo=mcp_types.Implementation(name="Dify", version=dify_config.project.version),
instructions=description,
@ -124,19 +172,23 @@ def handle_list_tools(
user_input_form: list[VariableEntity],
description: str,
parameters_dict: dict[str, str],
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.ListToolsResult:
"""Handle list tools request"""
parameter_schema = build_parameter_schema(app_mode, user_input_form, parameters_dict)
supports_structured = _supports_structured_output(protocol_version)
return mcp_types.ListToolsResult(
tools=[
mcp_types.Tool(
name=app_name,
description=description,
inputSchema=cast(dict[str, Any], parameter_schema),
)
],
# For 2025-06-18+ clients, expose an explicit display title and a permissive output
# schema. Both stay None (and are stripped by exclude_none serialization) for older
# clients, so their tool definition is unchanged.
tool = mcp_types.Tool(
name=app_name,
title=app_name if supports_structured else None,
description=description,
inputSchema=cast(dict[str, Any], parameter_schema),
outputSchema={"type": "object"} if supports_structured else None,
)
return mcp_types.ListToolsResult(tools=[tool])
def handle_call_tool(
@ -145,6 +197,7 @@ def handle_call_tool(
request: mcp_types.ClientRequest,
user_input_form: list[VariableEntity],
end_user: EndUser | None,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.CallToolResult:
"""Handle call tool request"""
request_obj = cast(mcp_types.CallToolRequest, request.root)
@ -163,7 +216,13 @@ def handle_call_tool(
)
answer = extract_answer_from_response(app, response)
return mcp_types.CallToolResult(content=[mcp_types.TextContent(text=answer, type="text")])
structured_content = None
if _supports_structured_output(protocol_version):
structured_content = extract_structured_output(app, response, answer)
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(text=answer, type="text")],
structuredContent=structured_content,
)
def build_parameter_schema(
@ -204,6 +263,29 @@ def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArguments
return {"query": query, "inputs": args_copy}
def extract_structured_output(app: App, response: Any, answer: str) -> dict[str, Any] | None:
"""Build MCP structured tool output (2025-06-18) from the app response.
WORKFLOW mode exposes the raw outputs mapping; chat/agent/completion modes expose the
answer string under an "answer" key. Returns None when no structured output is available.
"""
match app.mode:
case AppMode.WORKFLOW:
if isinstance(response, Mapping):
data = response.get("data")
if isinstance(data, Mapping):
outputs = data.get("outputs")
# All three guards use Mapping for consistency; coerce to a concrete dict
# because structuredContent must be a JSON object (dict[str, Any]).
if isinstance(outputs, Mapping):
return dict(outputs)
return None
case AppMode.ADVANCED_CHAT | AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
return {"answer": answer}
case _:
return None
def extract_answer_from_response(app: App, response: Any) -> str:
"""Extract answer from app generate response"""
answer = ""

View File

@ -22,10 +22,13 @@ for reference.
* Define additional model classes instead of using dictionaries. Do this even if they're
not separate types in the schema.
"""
# Client support both version, not support 2025-06-18 yet.
# Latest protocol version the Dify MCP client negotiates with upstream MCP servers.
LATEST_PROTOCOL_VERSION = "2025-06-18"
# Server support 2024-11-05 to allow claude to use.
SERVER_LATEST_PROTOCOL_VERSION = "2024-11-05"
# Latest protocol version the Dify MCP server advertises to connecting clients.
SERVER_LATEST_PROTOCOL_VERSION = "2025-06-18"
# Protocol versions the Dify MCP server can negotiate down to (e.g. Claude on 2024-11-05).
SERVER_SUPPORTED_PROTOCOL_VERSIONS: frozenset[str] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18"})
# Version assumed when a client omits the MCP-Protocol-Version header on post-initialize requests.
DEFAULT_NEGOTIATED_VERSION = "2025-03-26"
ProgressToken = str | int
Cursor = str

View File

@ -124,6 +124,7 @@ class ModelInstance:
stop: list[str] | None = None,
stream: Literal[True] = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Generator: ...
@overload
@ -135,6 +136,7 @@ class ModelInstance:
stop: list[str] | None = None,
stream: Literal[False] = False,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult: ...
@overload
@ -146,6 +148,7 @@ class ModelInstance:
stop: list[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Union[LLMResult, Generator]: ...
def invoke_llm(
@ -156,6 +159,7 @@ class ModelInstance:
stop: Sequence[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Union[LLMResult, Generator]:
"""
Invoke large language model
@ -166,6 +170,7 @@ class ModelInstance:
:param stop: stop words
:param stream: is stream response
:param callbacks: callbacks
:param request_metadata: optional request metadata
:return: full response or stream response chunk generator result
"""
if not isinstance(self.model_type_instance, LargeLanguageModel):
@ -182,6 +187,7 @@ class ModelInstance:
stop=list(stop) if stop else None,
stream=stream,
callbacks=callbacks,
request_metadata=request_metadata,
),
)

View File

@ -78,7 +78,11 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
if not user_id:
user = EndUserService.get_or_create_end_user(app)
else:
user = cls._get_user(user_id, app)
try:
user = cls._get_user(user_id, app)
except ValueError:
# Plugins such as WeCom Bot pass external sender IDs rather than EndUser UUIDs.
user = EndUserService.get_or_create_end_user(app, user_id=user_id)
conversation_id = conversation_id or ""
@ -232,6 +236,13 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
EndUser.app_id == app.id,
)
user = session.scalar(stmt)
if not user:
stmt = select(EndUser).where(
EndUser.session_id == user_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.id,
)
user = session.scalar(stmt)
if not user:
stmt = select(Account).where(
Account.id == user_id,

View File

@ -5,12 +5,13 @@ from pydantic import BaseModel
from core.plugin.entities.plugin import PluginDeclaration, PluginInstallationSource
class PluginBundleDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class PluginBundleDependencyType(StrEnum):
Github = PluginInstallationSource.Github.value
Marketplace = PluginInstallationSource.Marketplace.value
Package = PluginInstallationSource.Package.value
class PluginBundleDependency(BaseModel):
class Github(BaseModel):
repo_address: str
repo: str
@ -26,5 +27,5 @@ class PluginBundleDependency(BaseModel):
unique_identifier: str
manifest: PluginDeclaration
type: Type
type: PluginBundleDependencyType
value: Github | Marketplace | Package

View File

@ -32,6 +32,7 @@ class EndpointEntity(BasePluginEntity):
entity of an endpoint
"""
# TODO: Confirm daemon masks secret-input settings before endpoint list responses expose them.
settings: dict[str, Any]
tenant_id: str
plugin_id: str

View File

@ -57,11 +57,12 @@ class MCPServerParameterType(StrEnum):
OBJECT = auto()
class PluginParameterAutoGenerate(BaseModel):
class Type(StrEnum):
PROMPT_INSTRUCTION = auto()
class PluginParameterAutoGenerateType(StrEnum):
PROMPT_INSTRUCTION = auto()
type: Type
class PluginParameterAutoGenerate(BaseModel):
type: PluginParameterAutoGenerateType
class PluginParameterTemplate(BaseModel):

View File

@ -166,12 +166,13 @@ class PluginEntity(PluginInstallation):
return self
class PluginDependency(BaseModel):
class Type(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class PluginDependencyType(StrEnum):
Github = PluginInstallationSource.Github
Marketplace = PluginInstallationSource.Marketplace
Package = PluginInstallationSource.Package
class PluginDependency(BaseModel):
class Github(BaseModel):
repo: str
version: str
@ -194,7 +195,7 @@ class PluginDependency(BaseModel):
plugin_unique_identifier: str
version: str | None = None
type: Type
type: PluginDependencyType
value: Github | Marketplace | Package
current_identifier: str | None = None

View File

@ -228,7 +228,7 @@ class CredentialType(enum.StrEnum):
OAUTH2 = "oauth2"
UNAUTHORIZED = "unauthorized"
def get_name(self):
def get_name(self) -> str:
if self == CredentialType.API_KEY:
return "API KEY"
elif self == CredentialType.OAUTH2:

View File

@ -27,10 +27,12 @@ _POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling"
class PluginModelClient(BasePluginClient):
@staticmethod
def _dispatch_payload(*, user_id: str | None, data: dict[str, Any]) -> dict[str, Any]:
def _dispatch_payload(*, user_id: str | None, data: dict[str, Any], app_id: str | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"data": data}
if user_id is not None:
payload["user_id"] = user_id
if app_id is not None:
payload["app_id"] = app_id
return payload
def fetch_model_providers(self, tenant_id: str) -> Sequence[PluginModelProviderEntity]:
@ -166,6 +168,7 @@ class PluginModelClient(BasePluginClient):
tools: list[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: bool = True,
app_id: str | None = None,
) -> Generator[LLMResultChunk, None, None]:
"""
Invoke llm
@ -188,6 +191,7 @@ class PluginModelClient(BasePluginClient):
"stop": stop,
"stream": stream,
},
app_id=app_id,
)
),
headers={

View File

@ -317,21 +317,39 @@ class PluginModelRuntime(ModelRuntime):
stream: bool,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator[LLMResultChunk, None, None]:
del request_metadata
app_id = request_metadata.get("app_id") if request_metadata else None
if not isinstance(app_id, str):
app_id = None
plugin_id, provider_name = self._split_provider(provider)
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
)
if app_id is None:
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
)
else:
result = self.client.invoke_llm(
tenant_id=self.tenant_id,
user_id=self.user_id,
plugin_id=plugin_id,
provider=provider_name,
model=model,
credentials=credentials,
model_parameters=model_parameters,
prompt_messages=list(prompt_messages),
tools=tools,
stop=list(stop) if stop else None,
stream=stream,
app_id=app_id,
)
if stream:
return result

View File

@ -92,6 +92,7 @@ class PluginService:
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX = "plugin_model_providers_remote_debug:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
@ -117,6 +118,10 @@ class PluginService:
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
@classmethod
def _get_plugin_model_providers_remote_debug_cache_key(cls, tenant_id: str) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX}{tenant_id}"
@staticmethod
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
"""
@ -259,6 +264,111 @@ class PluginService:
except (RedisError, RuntimeError):
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:
remote_model_plugins = sorted(
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
for plugin in plugins
if plugin.source == PluginInstallationSource.Remote
)
if not remote_model_plugins:
return None
return "\n".join(remote_model_plugins)
@classmethod
def _load_cached_remote_model_plugin_marker(cls, tenant_id: str) -> str | None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
cached_marker = redis_client.get(cache_key)
except (RedisError, RuntimeError):
logger.warning("Failed to read remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
return None
if cached_marker is None:
return None
if isinstance(cached_marker, bytes):
try:
return cached_marker.decode()
except UnicodeDecodeError:
logger.warning(
"Invalid remote debug model plugin marker for tenant %s; deleting cache marker.",
tenant_id,
exc_info=True,
)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
if isinstance(cached_marker, str):
return cached_marker
logger.warning("Invalid remote debug model plugin marker for tenant %s; deleting cache marker.", tenant_id)
try:
redis_client.delete(cache_key)
except (RedisError, RuntimeError):
logger.warning(
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
tenant_id,
exc_info=True,
)
return None
@classmethod
def _store_cached_remote_model_plugin_marker(cls, tenant_id: str, marker: str | None) -> None:
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
try:
if marker is None:
redis_client.delete(cache_key)
else:
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, marker)
except (RedisError, RuntimeError):
logger.warning("Failed to cache remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _load_cached_plugin_model_provider_plugin_ids(cls, tenant_id: str) -> set[str] | None:
"""Return plugin ids represented by the current provider cache, or None when no usable cache exists."""
generation = cls._load_plugin_model_providers_generation(tenant_id)
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
if cached_providers is None:
return None
plugin_ids: set[str] = set()
for provider in cached_providers:
last_slash = provider.provider.rfind("/")
if last_slash > 0:
plugin_ids.add(provider.provider[:last_slash])
return plugin_ids
@classmethod
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
cls,
tenant_id: str,
plugins: Sequence[PluginEntity],
) -> bool:
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
if remote_model_plugin_marker is None:
return cached_remote_model_plugin_marker is not None
if remote_model_plugin_marker != cached_remote_model_plugin_marker:
return True
remote_model_plugin_ids = {
plugin.plugin_id for plugin in plugins if plugin.source == PluginInstallationSource.Remote
}
cached_plugin_ids = cls._load_cached_plugin_model_provider_plugin_ids(tenant_id)
if cached_plugin_ids is None:
return False
return not remote_model_plugin_ids.issubset(cached_plugin_ids)
@classmethod
@contextmanager
def _plugin_model_providers_refresh_lock(
@ -571,7 +681,21 @@ class PluginService:
This keeps pagination usable before category is persisted on installation rows.
"""
manager = PluginInstaller()
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
if category == PluginCategory.Model:
should_invalidate_model_provider_cache = (
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
tenant_id,
plugins.list,
)
)
if should_invalidate_model_provider_cache:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
remote_model_plugin_marker = PluginService._get_remote_model_plugin_cache_marker(plugins.list)
PluginService._store_cached_remote_model_plugin_marker(tenant_id, remote_model_plugin_marker)
return plugins
@staticmethod
def _normalize_endpoint_count(value: object) -> int:

View File

@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Any
from core.entities.provider_entities import ProviderConfig
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import (
ToolProviderEntity,
@ -71,11 +71,11 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
if not credential_schema.required and credentials[credential_name] is None:
continue
if credential_schema.type in {ProviderConfig.Type.SECRET_INPUT, ProviderConfig.Type.TEXT_INPUT}:
if credential_schema.type in {ProviderConfigType.SECRET_INPUT, ProviderConfigType.TEXT_INPUT}:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
elif credential_schema.type == ProviderConfig.Type.SELECT:
elif credential_schema.type == ProviderConfigType.SELECT:
if not isinstance(credentials[credential_name], str):
raise ToolProviderCredentialValidationError(f"credential {credential_name} should be string")
@ -100,9 +100,9 @@ class ToolProviderController[ToolProviderEntityT: ToolProviderEntity, ToolProvid
default_value = credential_schema.default
# parse default value into the correct type
if credential_schema.type in {
ProviderConfig.Type.SECRET_INPUT,
ProviderConfig.Type.TEXT_INPUT,
ProviderConfig.Type.SELECT,
ProviderConfigType.SECRET_INPUT,
ProviderConfigType.TEXT_INPUT,
ProviderConfigType.SELECT,
}:
default_value = str(default_value)

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