Compare commits

...
Author SHA1 Message Date
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
7210f856c9 fix: pipeline template render (#36168)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 09:11:18 +00:00
Coding On StarGitHubCodingOnStarautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ebcc1200a3 feat(MessageLogModal): refactor modal structure and improve tab handling (#36169)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:50:21 +00:00
林玮 (Jade Lin)GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e660d7af38 fix(api): gracefully handle credential fetch failures in rag pipeline (#36165)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:27:19 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d9ccfcbc6e fix: fix delete logs failed (#36151)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 08:02:31 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a9bcec013f feat: allow disabling run time cred check (#36031)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 07:12:10 +00:00
EvanYaoandGitHub aeb7687e2c fix: add null check in get_recommend_app_detail before accessing result['id'] (#36153) 2026-05-14 06:42:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9355d36718 chore(deps): bump urllib3 from 2.6.3 to 2.7.0 in /dify-agent (#36160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 06:41:22 +00:00
非法操作andGitHub a03ee828a3 fix: get recommend_app categories should not re-order it (#36161) 2026-05-14 06:36:28 +00:00
Coding On StarGitHubCodingOnStarautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7066372892 feat(workflow): enhance workflow run callbacks with additional data tracking (#36149)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 06:20:12 +00:00
盐粒 YanliGitHubCopilotautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
55f95dbc36 feat(agent): init agent server (#36087)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 06:04:44 +00:00
JoelandGitHub 8b40de3c4e chore: enchance notify link ui (#36155) 2026-05-14 06:03:44 +00:00
chaririandGitHub af4b9bfa8f chore: Remove pyright in favor of pyrefly (#36154) 2026-05-14 05:49:08 +00:00
b9e3130388 chore: drop unnecessary | None on LLMError and Mail.send (#36147)
Co-authored-by: Brian Wang <20699847+BrianWang1990@users.noreply.github.com>
2026-05-14 03:22:00 +00:00
mehmet turacandGitHub 12d33652b6 fix(errors): clean unnecessary | None in error classes (#36135) 2026-05-14 03:21:41 +00:00
wangxiaoleiandGitHub fe8cf2aff4 fix: fix pydantic union type error (#36134) 2026-05-14 02:54:23 +00:00
非法操作andGitHub d1d190374d fix: credit pool access outside flask context (#36143) 2026-05-14 02:45:53 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e1be4e6aa8 chore(deps): bump langsmith from 0.7.31 to 0.8.0 in /api (#36142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 02:36:02 +00:00
Xiyuan ChenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
301a470e7a fix: isolate Langfuse v3 SDK TracerProvider to prevent cross-task interference (#36136)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-14 01:46:23 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91251ad5a5 chore(deps): bump authlib from 1.6.11 to 1.6.12 in /api (#36121)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 14:56:20 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
3f6644a615 chore(i18n): sync translations with en-US (#36115)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-13 09:20:47 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5edc682c4a fix(web): refine account avatar interactions (#36111)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 08:36:04 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
13c00ecfc4 chore(deps): bump ujson from 5.12.0 to 5.12.1 in /api (#36112)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 08:34:32 +00:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9d545144ce chore: remove obsolete admin console routes (#35637)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 08:08:50 +00:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
2afa39cdcb fix: knowledge hit-testing render failed. (#36106)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 07:31:38 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
bb1c883be4 ci: Update pyrefly dependency version to 1.0.0 (#36100)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 07:13:59 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
03861bcee3 refactor: stabilize selector preview cards (#36105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 06:16:26 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>crazywoola
c34fc429ae fix: use Trans component for delete app confirmation text on editing page (#36092)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-05-13 06:08:59 +00:00
Stephen ZhouandGitHub d110112863 chore: update deps (#36091) 2026-05-13 03:51:21 +00:00
934a20e745 fix: restore tracing after HITL workflow resume (#36064)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-13 03:23:32 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7e56a244a8 fix: fix plugin miss version and checksum (#36083)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-13 01:39:27 +00:00
6facd9360c chore: some match case (#36080)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 14:45:58 +00:00
a18d7f51eb fix: fixed relative (#36078)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-12 11:05:57 +00:00
JoelGitHubYansong Zhangautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
680ef077ae chore: admin also has the permission of changing role (#36069)
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-12 10:15:05 +00:00
c26be9d3f4 fix: redirect unauthorized dataset access to /datasets for knowledge editors (#36073)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-05-12 09:38:49 +00:00
302 changed files with 21734 additions and 4390 deletions
View File
+1 -1
View File
@@ -250,5 +250,5 @@ scripts/stress-test/reports/
# Code Agent Folder
.qoder/*
.context/*
.eslintcache
+1
View File
@@ -9,6 +9,7 @@ The codebase is split into:
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
- **Docker deployment** (`/docker`): Containerized deployment configurations
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
## Backend Workflow
+8 -9
View File
@@ -83,16 +83,15 @@ lint:
@echo "✅ Linting complete"
type-check:
@echo "📝 Running type checks (basedpyright + pyrefly + mypy)..."
@./dev/basedpyright-check $(PATH_TO_CHECK)
@./dev/pyrefly-check-local
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "📝 Running type checks (pyrefly + mypy)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Type checks complete"
type-check-core:
@echo "📝 Running core type checks (basedpyright + mypy)..."
@./dev/basedpyright-check $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "📝 Running core type checks (pyrefly + mypy)..."
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
@echo "✅ Core type checks complete"
test:
@@ -153,8 +152,8 @@ help:
@echo " make format - Format code with ruff"
@echo " make check - Check code with ruff"
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (basedpyright, mypy)"
@echo " make type-check - Run type checks (pyrefly, mypy)"
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
@echo ""
@echo "Docker Build Targets:"
+1 -1
View File
@@ -99,7 +99,7 @@ The scripts resolve paths relative to their location, so you can run them from a
./dev/reformat # Run all formatters and linters
uv run ruff check --fix ./ # Fix linting issues
uv run ruff format ./ # Format code
uv run basedpyright . # Type checking
uv run pyrefly check # Type checking
```
## Generate TS stub
+1 -1
View File
@@ -117,7 +117,7 @@ def create_flask_app_with_configs() -> DifyApp:
logger.warning("Failed to add trace headers to response", exc_info=True)
return response
# Capture the decorator's return value to avoid pyright reportUnusedFunction
# Capture the decorator return values so static checkers do not treat the hooks as unused.
_ = before_request
_ = add_trace_headers
+3 -3
View File
@@ -185,9 +185,9 @@ 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) # pyright: ignore[reportPrivateUsage]
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) # pyright: ignore[reportPrivateUsage]
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) # pyright: ignore[reportPrivateUsage]
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)
else:
notion_plugin_unique_identifier = None
firecrawl_plugin_unique_identifier = None
+6
View File
@@ -23,6 +23,12 @@ class EnterpriseFeatureConfig(BaseSettings):
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
)
ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: bool = Field(
default=False,
description="If disabled, credential policy check is only performed when saving workflows."
"This helps gain runtime performance by trading off consistency.",
)
class EnterpriseTelemetryConfig(BaseSettings):
"""
+19 -17
View File
@@ -1,5 +1,5 @@
import os
from typing import Any, Literal, TypedDict
from typing import Any, Literal, TypedDict, cast
from urllib.parse import parse_qsl, quote_plus
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
@@ -50,28 +50,30 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig
from .vdb.vikingdb_config import VikingDBConfig
from .vdb.weaviate_config import WeaviateConfig
_VALID_STORAGE_TYPE = Literal[
"opendal",
"s3",
"aliyun-oss",
"azure-blob",
"baidu-obs",
"clickzetta-volume",
"google-storage",
"huawei-obs",
"oci-storage",
"tencent-cos",
"volcengine-tos",
"supabase",
"local",
]
class StorageConfig(BaseSettings):
STORAGE_TYPE: Literal[
"opendal",
"s3",
"aliyun-oss",
"azure-blob",
"baidu-obs",
"clickzetta-volume",
"google-storage",
"huawei-obs",
"oci-storage",
"tencent-cos",
"volcengine-tos",
"supabase",
"local",
] = Field(
STORAGE_TYPE: _VALID_STORAGE_TYPE = Field(
description="Type of storage to use."
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
default="opendal",
default=cast(_VALID_STORAGE_TYPE, "opendal"),
)
STORAGE_LOCAL_PATH: str = Field(
+1 -3
View File
@@ -33,7 +33,6 @@ for module_name in RESOURCE_MODULES:
# Ensure resource modules are imported so route decorators are evaluated.
# Import other controllers
from . import (
admin,
apikey,
extension,
feature,
@@ -117,7 +116,7 @@ from .explore import (
saved_message,
trial,
)
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
from .socketio import workflow as socketio_workflow
# Import tag controllers
from .tag import tags
@@ -142,7 +141,6 @@ api.add_namespace(console_ns)
__all__ = [
"account",
"activate",
"admin",
"advanced_prompt_template",
"agent",
"agent_providers",
+1 -404
View File
@@ -1,64 +1,11 @@
import csv
import io
from collections.abc import Callable
from functools import wraps
from typing import cast
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from werkzeug.exceptions import Unauthorized
from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import only_edition_cloud
from core.db.session_factory import session_factory
from extensions.ext_database import db
from libs.token import extract_access_token
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
from services.billing_service import BillingService, LangContentDict
class InsertExploreAppPayload(BaseModel):
app_id: str = Field(...)
desc: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
custom_disclaimer: str | None = None
language: str = Field(...)
category: str = Field(...)
position: int = Field(...)
can_trial: bool = Field(default=False)
trial_limit: int = Field(default=0)
@field_validator("language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
class InsertExploreBannerPayload(BaseModel):
category: str = Field(...)
title: str = Field(...)
description: str = Field(...)
img_src: str = Field(..., alias="img-src")
language: str = Field(default="en-US")
link: str = Field(...)
sort: int = Field(...)
@field_validator("language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
model_config = {"populate_by_name": True}
register_schema_models(console_ns, InsertExploreAppPayload, InsertExploreBannerPayload)
def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@@ -76,353 +23,3 @@ def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return view(*args, **kwargs)
return decorated
@console_ns.route("/admin/insert-explore-apps")
class InsertExploreAppListApi(Resource):
@console_ns.doc("insert_explore_app")
@console_ns.doc(description="Insert or update an app in the explore list")
@console_ns.expect(console_ns.models[InsertExploreAppPayload.__name__])
@console_ns.response(200, "App updated successfully")
@console_ns.response(201, "App inserted successfully")
@console_ns.response(404, "App not found")
@only_edition_cloud
@admin_required
def post(self):
payload = InsertExploreAppPayload.model_validate(console_ns.payload)
app = db.session.execute(select(App).where(App.id == payload.app_id)).scalar_one_or_none()
if not app:
raise NotFound(f"App '{payload.app_id}' is not found")
site = app.site
if not site:
desc = payload.desc or ""
copy_right = payload.copyright or ""
privacy_policy = payload.privacy_policy or ""
custom_disclaimer = payload.custom_disclaimer or ""
else:
desc = site.description or payload.desc or ""
copy_right = site.copyright or payload.copyright or ""
privacy_policy = site.privacy_policy or payload.privacy_policy or ""
custom_disclaimer = site.custom_disclaimer or payload.custom_disclaimer or ""
with session_factory.create_session() as session:
recommended_app = session.execute(
select(RecommendedApp).where(RecommendedApp.app_id == payload.app_id)
).scalar_one_or_none()
if not recommended_app:
recommended_app = RecommendedApp(
app_id=app.id,
description=desc,
copyright=copy_right,
privacy_policy=privacy_policy,
custom_disclaimer=custom_disclaimer,
language=payload.language,
category=payload.category,
position=payload.position,
)
db.session.add(recommended_app)
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
return {"result": "success"}, 201
else:
recommended_app.description = desc
recommended_app.copyright = copy_right
recommended_app.privacy_policy = privacy_policy
recommended_app.custom_disclaimer = custom_disclaimer
recommended_app.language = payload.language
recommended_app.category = payload.category
recommended_app.position = payload.position
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
return {"result": "success"}, 200
@console_ns.route("/admin/insert-explore-apps/<uuid:app_id>")
class InsertExploreAppApi(Resource):
@console_ns.doc("delete_explore_app")
@console_ns.doc(description="Remove an app from the explore list")
@console_ns.doc(params={"app_id": "Application ID to remove"})
@console_ns.response(204, "App removed successfully")
@only_edition_cloud
@admin_required
def delete(self, app_id: UUID):
with session_factory.create_session() as session:
recommended_app = session.execute(
select(RecommendedApp).where(RecommendedApp.app_id == str(app_id))
).scalar_one_or_none()
if not recommended_app:
return {"result": "success"}, 204
with session_factory.create_session() as session:
app = session.execute(select(App).where(App.id == recommended_app.app_id)).scalar_one_or_none()
if app:
app.is_public = False
with session_factory.create_session() as session:
installed_apps = (
session.execute(
select(InstalledApp).where(
InstalledApp.app_id == recommended_app.app_id,
InstalledApp.tenant_id != InstalledApp.app_owner_tenant_id,
)
)
.scalars()
.all()
)
for installed_app in installed_apps:
session.delete(installed_app)
trial_app = session.execute(
select(TrialApp).where(TrialApp.app_id == recommended_app.app_id)
).scalar_one_or_none()
if trial_app:
session.delete(trial_app)
db.session.delete(recommended_app)
db.session.commit()
return {"result": "success"}, 204
@console_ns.route("/admin/insert-explore-banner")
class InsertExploreBannerApi(Resource):
@console_ns.doc("insert_explore_banner")
@console_ns.doc(description="Insert an explore banner")
@console_ns.expect(console_ns.models[InsertExploreBannerPayload.__name__])
@console_ns.response(201, "Banner inserted successfully")
@only_edition_cloud
@admin_required
def post(self):
payload = InsertExploreBannerPayload.model_validate(console_ns.payload)
banner = ExporleBanner(
content={
"category": payload.category,
"title": payload.title,
"description": payload.description,
"img-src": payload.img_src,
},
link=payload.link,
sort=payload.sort,
language=payload.language,
)
db.session.add(banner)
db.session.commit()
return {"result": "success"}, 201
@console_ns.route("/admin/delete-explore-banner/<uuid:banner_id>")
class DeleteExploreBannerApi(Resource):
@console_ns.doc("delete_explore_banner")
@console_ns.doc(description="Delete an explore banner")
@console_ns.doc(params={"banner_id": "Banner ID to delete"})
@console_ns.response(204, "Banner deleted successfully")
@only_edition_cloud
@admin_required
def delete(self, banner_id):
banner = db.session.execute(select(ExporleBanner).where(ExporleBanner.id == banner_id)).scalar_one_or_none()
if not banner:
raise NotFound(f"Banner '{banner_id}' is not found")
db.session.delete(banner)
db.session.commit()
return {"result": "success"}, 204
class LangContentPayload(BaseModel):
lang: str = Field(..., description="Language tag: 'zh' | 'en' | 'jp'")
title: str = Field(...)
subtitle: str | None = Field(default=None)
body: str = Field(...)
title_pic_url: str | None = Field(default=None)
class UpsertNotificationPayload(BaseModel):
notification_id: str | None = Field(default=None, description="Omit to create; supply UUID to update")
contents: list[LangContentPayload] = Field(..., min_length=1)
start_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-01T00:00:00Z")
end_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-20T23:59:59Z")
frequency: str = Field(default="once", description="'once' | 'every_page_load'")
status: str = Field(default="active", description="'active' | 'inactive'")
class BatchAddNotificationAccountsPayload(BaseModel):
notification_id: str = Field(...)
user_email: list[str] = Field(..., description="List of account email addresses")
register_schema_models(console_ns, UpsertNotificationPayload, BatchAddNotificationAccountsPayload)
@console_ns.route("/admin/upsert_notification")
class UpsertNotificationApi(Resource):
@console_ns.doc("upsert_notification")
@console_ns.doc(
description=(
"Create or update an in-product notification. "
"Supply notification_id to update an existing one; omit it to create a new one. "
"Pass at least one language variant in contents (zh / en / jp)."
)
)
@console_ns.expect(console_ns.models[UpsertNotificationPayload.__name__])
@console_ns.response(200, "Notification upserted successfully")
@only_edition_cloud
@admin_required
def post(self):
payload = UpsertNotificationPayload.model_validate(console_ns.payload)
result = BillingService.upsert_notification(
contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents],
frequency=payload.frequency,
status=payload.status,
notification_id=payload.notification_id,
start_time=payload.start_time,
end_time=payload.end_time,
)
return {"result": "success", "notification_id": result.get("notificationId")}, 200
@console_ns.route("/admin/batch_add_notification_accounts")
class BatchAddNotificationAccountsApi(Resource):
@console_ns.doc("batch_add_notification_accounts")
@console_ns.doc(
description=(
"Register target accounts for a notification by email address. "
'JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. '
"File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) "
"plus a 'notification_id' field. "
"Emails that do not match any account are silently skipped."
)
)
@console_ns.response(200, "Accounts added successfully")
@only_edition_cloud
@admin_required
def post(self):
from models.account import Account
if "file" in request.files:
notification_id = request.form.get("notification_id", "").strip()
if not notification_id:
raise BadRequest("notification_id is required.")
emails = self._parse_emails_from_file()
else:
payload = BatchAddNotificationAccountsPayload.model_validate(console_ns.payload)
notification_id = payload.notification_id
emails = payload.user_email
if not emails:
raise BadRequest("No valid email addresses provided.")
# Resolve emails → account IDs in chunks to avoid large IN-clause
account_ids: list[str] = []
chunk_size = 500
for i in range(0, len(emails), chunk_size):
chunk = emails[i : i + chunk_size]
rows = db.session.execute(select(Account.id, Account.email).where(Account.email.in_(chunk))).all()
account_ids.extend(str(row.id) for row in rows)
if not account_ids:
raise BadRequest("None of the provided emails matched an existing account.")
# Send to dify-saas in batches of 1000
total_count = 0
batch_size = 1000
for i in range(0, len(account_ids), batch_size):
batch = account_ids[i : i + batch_size]
result = BillingService.batch_add_notification_accounts(
notification_id=notification_id,
account_ids=batch,
)
total_count += result.get("count", 0)
return {
"result": "success",
"emails_provided": len(emails),
"accounts_matched": len(account_ids),
"count": total_count,
}, 200
@staticmethod
def _parse_emails_from_file() -> list[str]:
"""Parse email addresses from an uploaded CSV or TXT file."""
file = request.files["file"]
if not file.filename:
raise BadRequest("Uploaded file has no filename.")
filename_lower = file.filename.lower()
if not filename_lower.endswith((".csv", ".txt")):
raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.")
try:
content = file.stream.read().decode("utf-8")
except UnicodeDecodeError:
try:
file.stream.seek(0)
content = file.stream.read().decode("gbk")
except UnicodeDecodeError:
raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.")
emails: list[str] = []
if filename_lower.endswith(".csv"):
reader = csv.reader(io.StringIO(content))
for row in reader:
for cell in row:
cell = cell.strip()
if cell:
emails.append(cell)
else:
for line in content.splitlines():
line = line.strip()
if line:
emails.append(line)
# Deduplicate while preserving order
seen: set[str] = set()
unique_emails: list[str] = []
for email in emails:
if email.lower() not in seen:
seen.add(email.lower())
unique_emails.append(email)
return unique_emails
@@ -39,11 +39,8 @@ class HitTestingPayload(BaseModel):
class DatasetsHitTestingBase:
@staticmethod
def _normalize_hit_testing_query(query: Any) -> str:
"""Return the user-visible query string from legacy and current response shapes."""
if isinstance(query, str):
return query
def _extract_hit_testing_query(query: Any) -> str:
"""Return the query string from the service response shape."""
if isinstance(query, dict):
content = query.get("content")
if isinstance(content, str):
@@ -52,15 +49,15 @@ class DatasetsHitTestingBase:
raise ValueError("Invalid hit testing query response")
@staticmethod
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
"""Coerce nullable collection fields into lists before response validation."""
def _prepare_hit_testing_records(records: Any) -> list[dict[str, Any]]:
"""Ensure collection fields match the API schema before response validation."""
if not isinstance(records, list):
return []
raise ValueError("Invalid hit testing records response")
normalized_records: list[dict[str, Any]] = []
for record in records:
if not isinstance(record, dict):
continue
raise ValueError("Invalid hit testing record response")
normalized_record = dict(record)
segment = normalized_record.get("segment")
@@ -118,8 +115,8 @@ class DatasetsHitTestingBase:
limit=10,
)
return {
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
"query": DatasetsHitTestingBase._extract_hit_testing_query(response.get("query")),
"records": DatasetsHitTestingBase._prepare_hit_testing_records(
marshal(response.get("records", []), hit_testing_record_fields)
),
}
+18 -2
View File
@@ -70,6 +70,12 @@ register_schema_models(
)
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@@ -110,6 +116,8 @@ class MemberInviteEmailApi(Resource):
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
# Check workspace permission for member invitations
from libs.workspace_permission import check_workspace_member_invite_permission
@@ -208,6 +216,8 @@ class MemberUpdateRoleApi(Resource):
current_user, _ = current_account_with_tenant()
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
member = db.session.get(Account, str(member_id))
if not member:
abort(404)
@@ -215,11 +225,17 @@ class MemberUpdateRoleApi(Resource):
try:
assert member is not None, "Member not found"
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
return {"code": "forbidden", "message": str(e)}, 403
except services.errors.account.MemberNotInTenantError as e:
return {"code": "member-not-found", "message": str(e)}, 404
except services.errors.account.RoleAlreadyAssignedError as e:
return {"code": "role-already-assigned", "message": str(e)}, 400
except Exception as e:
raise ValueError(str(e))
# todo: 403
return {"result": "success"}
@@ -253,7 +253,20 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
):
"""
Resume a paused advanced chat execution.
``trace_manager`` is transient and excluded from generate-entity serialization,
so resumed executions rebuild it here before persistence layers receive the entity.
"""
if application_generate_entity.trace_manager is None:
application_generate_entity = application_generate_entity.model_copy(
update={
"trace_manager": TraceQueueManager(
app_id=app_model.id,
user_id=user.id if isinstance(user, Account) else user.session_id,
)
}
)
return self._generate(
workflow=workflow,
user=user,
@@ -178,7 +178,7 @@ class AgentChatAppConfigManager(BaseAppConfigManager):
if not isinstance(agent_mode, dict):
raise ValueError("agent_mode must be of object type")
# FIXME(-LAN-): Cast needed due to basedpyright limitation with dict type narrowing
# FIXME(-LAN-): Cast needed because static checkers do not narrow this dict value.
agent_mode = cast(dict[str, Any], agent_mode)
if "enabled" not in agent_mode or not agent_mode["enabled"]:
@@ -253,7 +253,20 @@ class WorkflowAppGenerator(BaseAppGenerator):
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
"""
Resume a paused workflow execution using the persisted runtime state.
``trace_manager`` is transient and excluded from generate-entity serialization,
so resumed executions rebuild it here before persistence layers receive the entity.
"""
if application_generate_entity.trace_manager is None:
application_generate_entity = application_generate_entity.model_copy(
update={
"trace_manager": TraceQueueManager(
app_id=app_model.id,
user_id=user.id if isinstance(user, Account) else user.session_id,
)
}
)
return self._generate(
app_model=app_model,
workflow=workflow,
+4 -4
View File
@@ -171,9 +171,9 @@ class ProviderConfiguration(BaseModel):
current_credential_id = self.custom_configuration.provider.current_credential_id
if current_credential_id:
from core.helper.credential_utils import check_credential_policy_compliance
from core.helper.credential_utils import runtime_check_credential_policy_compliance
check_credential_policy_compliance(
runtime_check_credential_policy_compliance(
credential_id=current_credential_id,
provider=self.provider.provider,
credential_type=PluginCredentialType.MODEL,
@@ -182,9 +182,9 @@ class ProviderConfiguration(BaseModel):
# no current credential id, check all available credentials
if self.custom_configuration.provider:
for credential_configuration in self.custom_configuration.provider.available_credentials:
from core.helper.credential_utils import check_credential_policy_compliance
from core.helper.credential_utils import runtime_check_credential_policy_compliance
check_credential_policy_compliance(
runtime_check_credential_policy_compliance(
credential_id=credential_configuration.credential_id,
provider=self.provider.provider,
credential_type=PluginCredentialType.MODEL,
+2 -2
View File
@@ -1,9 +1,9 @@
class LLMError(ValueError):
"""Base class for all LLM exceptions."""
description: str | None = None
description: str = ""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
self.description = description
+11
View File
@@ -2,6 +2,7 @@
Credential utility functions for checking credential existence and policy compliance.
"""
from configs import dify_config
from core.entities import PluginCredentialType
@@ -39,6 +40,16 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
return False
def runtime_check_credential_policy_compliance(
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
):
if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK:
return
check_credential_policy_compliance(
credential_id=credential_id, provider=provider, credential_type=credential_type, check_existence=check_existence
)
def check_credential_policy_compliance(
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
) -> None:
+1 -1
View File
@@ -435,7 +435,7 @@ class LLMGenerator:
stream=False,
)
# Runtime type check since pyright has issues with the overload
# Runtime type check for overload narrowing.
if not isinstance(result, LLMResult):
raise TypeError("Expected LLMResult when stream=False")
response = result
+16 -2
View File
@@ -2,7 +2,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel, model_validator
from pydantic.networks import AnyUrl, UrlConstraints
"""
@@ -173,7 +173,21 @@ class JSONRPCError(BaseModel):
class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]):
pass
@model_validator(mode="before")
@classmethod
def _select_message_type(
cls, value: Any
) -> JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError | Any:
if isinstance(value, dict):
if "result" in value:
return JSONRPCResponse.model_validate(value)
if "error" in value:
return JSONRPCError.model_validate(value)
if "method" in value:
if "id" in value:
return JSONRPCRequest.model_validate(value)
return JSONRPCNotification.model_validate(value)
return value
class EmptyResult(Result):
+4 -4
View File
@@ -391,10 +391,10 @@ class ModelInstance:
# Additional policy compliance check as fallback (in case fetch_next didn't catch it)
try:
from core.helper.credential_utils import check_credential_policy_compliance
from core.helper.credential_utils import runtime_check_credential_policy_compliance
if lb_config.credential_id:
check_credential_policy_compliance(
runtime_check_credential_policy_compliance(
credential_id=lb_config.credential_id,
provider=self.provider,
credential_type=PluginCredentialType.MODEL,
@@ -630,10 +630,10 @@ class LBModelManager:
# Check policy compliance for the selected configuration
try:
from core.helper.credential_utils import check_credential_policy_compliance
from core.helper.credential_utils import runtime_check_credential_policy_compliance
if config.credential_id:
check_credential_policy_compliance(
runtime_check_credential_policy_compliance(
credential_id=config.credential_id,
provider=self._provider,
credential_type=PluginCredentialType.MODEL,
+33 -29
View File
@@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform):
prompt_messages: list[PromptMessage] = []
for prompt_item in prompt_template:
raw_prompt = prompt_item.text
if prompt_item.edition_type == "basic" or not prompt_item.edition_type:
if self.with_variable_tmpl:
vp = VariablePool.empty()
for k, v in inputs.items():
if k.startswith("#"):
vp.add(k[1:-1].split("."), v)
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
prompt = vp.convert_template(raw_prompt).text
else:
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
prompt_inputs = self._set_context_variable(
context=context, parser=parser, prompt_inputs=prompt_inputs
)
prompt = parser.format(prompt_inputs)
elif prompt_item.edition_type == "jinja2":
prompt = raw_prompt
prompt_inputs = inputs
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
else:
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
if prompt_item.role == PromptMessageRole.USER:
prompt_messages.append(UserPromptMessage(content=prompt))
elif prompt_item.role == PromptMessageRole.SYSTEM and prompt:
prompt_messages.append(SystemPromptMessage(content=prompt))
elif prompt_item.role == PromptMessageRole.ASSISTANT:
prompt_messages.append(AssistantPromptMessage(content=prompt))
edition_type = prompt_item.edition_type or "basic"
match edition_type:
case "basic":
if self.with_variable_tmpl:
vp = VariablePool.empty()
for k, v in inputs.items():
if k.startswith("#"):
vp.add(k[1:-1].split("."), v)
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
prompt = vp.convert_template(raw_prompt).text
else:
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
prompt_inputs = self._set_context_variable(
context=context, parser=parser, prompt_inputs=prompt_inputs
)
prompt = parser.format(prompt_inputs)
case "jinja2":
prompt = raw_prompt
prompt_inputs = inputs
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
case _:
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
match prompt_item.role:
case PromptMessageRole.USER:
prompt_messages.append(UserPromptMessage(content=prompt))
case PromptMessageRole.SYSTEM:
if prompt:
prompt_messages.append(SystemPromptMessage(content=prompt))
case PromptMessageRole.ASSISTANT:
prompt_messages.append(AssistantPromptMessage(content=prompt))
case PromptMessageRole.TOOL:
pass
if query and memory_config and memory_config.query_prompt_template:
parser = PromptTemplateParser(
+30 -29
View File
@@ -183,34 +183,35 @@ class ExtractProcessor:
return extractor.extract()
elif extract_setting.datasource_type == DatasourceType.WEBSITE:
assert extract_setting.website_info is not None, "website_info is required"
if extract_setting.website_info.provider == "firecrawl":
extractor = FirecrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
elif extract_setting.website_info.provider == "watercrawl":
extractor = WaterCrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
elif extract_setting.website_info.provider == "jinareader":
extractor = JinaReaderWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
else:
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
match extract_setting.website_info.provider:
case "firecrawl":
extractor = FirecrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case "watercrawl":
extractor = WaterCrawlWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case "jinareader":
extractor = JinaReaderWebExtractor(
url=extract_setting.website_info.url,
job_id=extract_setting.website_info.job_id,
tenant_id=extract_setting.website_info.tenant_id,
mode=extract_setting.website_info.mode,
only_main_content=extract_setting.website_info.only_main_content,
)
return extractor.extract()
case _:
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
else:
raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}")
@@ -19,7 +19,7 @@ class UnstructuredWordExtractor(BaseExtractor):
def extract(self) -> list[Document]:
from unstructured.__version__ import __version__ as __unstructured_version__
from unstructured.file_utils.filetype import ( # pyright: ignore[reportPrivateImportUsage]
from unstructured.file_utils.filetype import (
FileType,
detect_filetype,
)
@@ -27,7 +27,7 @@ class UnstructuredWordExtractor(BaseExtractor):
unstructured_version = tuple(int(x) for x in __unstructured_version__.split("."))
# check the file extension
try:
import magic # noqa: F401 # pyright: ignore[reportUnusedImport]
import magic # noqa: F401
is_doc = detect_filetype(self._file_path) == FileType.DOC
except ImportError:
@@ -28,10 +28,10 @@ class FunctionCallMultiDatasetRouter:
SystemPromptMessage(content="You are a helpful AI assistant."),
UserPromptMessage(content=query),
]
result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType]
result: LLMResult = model_instance.invoke_llm( # pyrefly: ignore[no-matching-overload]
prompt_messages=prompt_messages,
tools=dataset_tools,
stream=False, # pyright: ignore[reportArgumentType]
stream=False,
model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500},
)
usage = result.usage or LLMUsage.empty_usage()
+2 -2
View File
@@ -264,9 +264,9 @@ class ToolManager:
if builtin_provider is None:
raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found")
from core.helper.credential_utils import check_credential_policy_compliance
from core.helper.credential_utils import runtime_check_credential_policy_compliance
check_credential_policy_compliance(
runtime_check_credential_policy_compliance(
credential_id=builtin_provider.id,
provider=provider_id,
credential_type=PluginCredentialType.TOOL,
+4 -4
View File
@@ -11,14 +11,14 @@ from dify_app import DifyApp
def init_app(app: DifyApp):
@app.after_request
def after_request(response): # pyright: ignore[reportUnusedFunction]
def after_request(response):
"""Add Version headers to the response."""
response.headers.add("X-Version", dify_config.project.version)
response.headers.add("X-Env", dify_config.DEPLOY_ENV)
return response
@app.route("/health")
def health(): # pyright: ignore[reportUnusedFunction]
def health():
return Response(
json.dumps({"pid": os.getpid(), "status": "ok", "version": dify_config.project.version}),
status=200,
@@ -27,7 +27,7 @@ def init_app(app: DifyApp):
@app.route("/threads")
@admin_required
def threads(): # pyright: ignore[reportUnusedFunction]
def threads():
num_threads = threading.active_count()
threads = threading.enumerate()
@@ -53,7 +53,7 @@ def init_app(app: DifyApp):
@app.route("/db-pool-stat")
@admin_required
def pool_stat(): # pyright: ignore[reportUnusedFunction]
def pool_stat():
from extensions.ext_database import db
engine = db.engine
+1 -1
View File
@@ -33,7 +33,7 @@ def _setup_gevent_compatibility():
return
@event.listens_for(Pool, "reset")
def _safe_reset(dbapi_connection, connection_record, reset_state): # pyright: ignore[reportUnusedFunction]
def _safe_reset(dbapi_connection, connection_record, reset_state):
if reset_state.terminate_only:
return
+1 -1
View File
@@ -2,4 +2,4 @@ from dify_app import DifyApp
def init_app(app: DifyApp):
from events import event_handlers # noqa: F401 # pyright: ignore[reportUnusedImport]
from events import event_handlers # noqa: F401
+1 -1
View File
@@ -67,7 +67,7 @@ class Mail:
case _:
raise ValueError(f"Unsupported mail type {mail_type}")
def send(self, to: str, subject: str, html: str, from_: str | None = None):
def send(self, to: str, subject: str, html: str, from_: str = ""):
if not self._client:
raise ValueError("Mail client is not initialized")
+5 -1
View File
@@ -1,5 +1,9 @@
from typing import Any, cast
import socketio # type: ignore[reportMissingTypeStubs]
from configs import dify_config
sio = socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS)
# TODO: FIXME(chariri) - Casting to any because app_factory attaches the
# current app as the `app` attribute on this - Bad.
sio = cast(Any, socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS))
+1 -1
View File
@@ -198,7 +198,7 @@ class AliyunLogStore:
)
# Append Dify identification to the existing user agent
original_user_agent = self.client._user_agent # pyright: ignore[reportPrivateUsage]
original_user_agent = self.client._user_agent
dify_version = dify_config.project.version
enhanced_user_agent = f"Dify,Dify-{dify_version},{original_user_agent}"
self.client.set_user_agent(enhanced_user_agent)
-165
View File
@@ -340,116 +340,6 @@ Check if activation token is valid
| ---- | ----------- | ------ |
| 200 | Success | [ActivationCheckResponse](#activationcheckresponse) |
### /admin/batch_add_notification_accounts
#### POST
##### Description
Register target accounts for a notification by email address. JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) plus a 'notification_id' field. Emails that do not match any account are silently skipped.
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Accounts added successfully |
### /admin/delete-explore-banner/{banner_id}
#### DELETE
##### Description
Delete an explore banner
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| banner_id | path | Banner ID to delete | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 204 | Banner deleted successfully |
### /admin/insert-explore-apps
#### POST
##### Description
Insert or update an app in the explore list
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [InsertExploreAppPayload](#insertexploreapppayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | App updated successfully |
| 201 | App inserted successfully |
| 404 | App not found |
### /admin/insert-explore-apps/{app_id}
#### DELETE
##### Description
Remove an app from the explore list
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID to remove | Yes | string |
##### Responses
| Code | Description |
| ---- | ----------- |
| 204 | App removed successfully |
### /admin/insert-explore-banner
#### POST
##### Description
Insert an explore banner
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [InsertExploreBannerPayload](#insertexplorebannerpayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 201 | Banner inserted successfully |
### /admin/upsert_notification
#### POST
##### Description
Create or update an in-product notification. Supply notification_id to update an existing one; omit it to create a new one. Pass at least one language variant in contents (zh / en / jp).
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| payload | body | | Yes | [UpsertNotificationPayload](#upsertnotificationpayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Notification upserted successfully |
### /all-workspaces
#### GET
@@ -10731,13 +10621,6 @@ AppMCPServer Status Enum
| ---- | ---- | ----------- | -------- |
| text | string | Transcribed text from audio | Yes |
#### BatchAddNotificationAccountsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| notification_id | string | | Yes |
| user_email | [ string ] | List of account email addresses | Yes |
#### BatchImportPayload
| Name | Type | Description | Required |
@@ -12233,33 +12116,6 @@ Form input types.
| model_type | [ModelType](#modeltype) | | Yes |
| provider | | | No |
#### InsertExploreAppPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| can_trial | boolean | | No |
| category | string | | Yes |
| copyright | | | No |
| custom_disclaimer | | | No |
| desc | | | No |
| language | string | | Yes |
| position | integer | | Yes |
| privacy_policy | | | No |
| trial_limit | integer | | No |
#### InsertExploreBannerPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| category | string | | Yes |
| description | string | | Yes |
| img-src | string | | Yes |
| language | string | | No |
| link | string | | Yes |
| sort | integer | | Yes |
| title | string | | Yes |
#### InstallPermission
| Name | Type | Description | Required |
@@ -12370,16 +12226,6 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| LLMMode | string | Enum class for large language model mode. | |
#### LangContentPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| body | string | | Yes |
| lang | string | Language tag: 'zh' \| 'en' \| 'jp' | Yes |
| subtitle | | | No |
| title | string | | Yes |
| title_pic_url | | | No |
#### LegacyEndpointUpdatePayload
| Name | Type | Description | Required |
@@ -13937,17 +13783,6 @@ Tag type
| video_file_size_limit | integer | | Yes |
| workflow_file_upload_limit | integer | | Yes |
#### UpsertNotificationPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| contents | [ [LangContentPayload](#langcontentpayload) ] | | Yes |
| end_time | | RFC3339, e.g. 2026-03-20T23:59:59Z | No |
| frequency | string | 'once' \| 'every_page_load' | No |
| notification_id | | Omit to create; supply UUID to update | No |
| start_time | | RFC3339, e.g. 2026-03-01T00:00:00Z | No |
| status | string | 'active' \| 'inactive' | No |
#### UserAction
User action configuration.
@@ -13,6 +13,8 @@ from langfuse.api import (
TraceBody,
)
from langfuse.api.commons.types.usage import Usage
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from sqlalchemy.orm import sessionmaker
from core.ops.base_trace_instance import BaseTraceInstance
@@ -52,13 +54,40 @@ class LangFuseDataTrace(BaseTraceInstance):
langfuse_config: LangfuseConfig,
):
super().__init__(langfuse_config)
# Isolated TracerProvider prevents the langfuse v3 SDK from attaching its
# SpanProcessor to the global OpenTelemetry TracerProvider, which would
# otherwise siphon every Flask/Celery/SQLAlchemy span in the process into
# this tenant's Langfuse project. See langfuse upgrade guide v2 -> v3.
self._tracer_provider: TracerProvider | None = TracerProvider(
resource=Resource.create({"service.name": "dify-langfuse-app-trace"}),
)
self.langfuse_client = Langfuse(
public_key=langfuse_config.public_key,
secret_key=langfuse_config.secret_key,
host=langfuse_config.host,
tracer_provider=self._tracer_provider,
)
self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
def close(self) -> None:
"""Flush and shut down the isolated TracerProvider.
Called explicitly when the trace instance is evicted from the cache, or
implicitly via ``__del__`` on garbage collection. Idempotent.
"""
provider = getattr(self, "_tracer_provider", None)
if provider is None:
return
try:
provider.shutdown()
except Exception:
logger.debug("Failed to shut down Langfuse TracerProvider", exc_info=True)
finally:
self._tracer_provider = None
def __del__(self) -> None:
self.close()
@staticmethod
def _get_completion_start_time(
start_time: datetime | None, time_to_first_token: float | int | None
@@ -50,20 +50,92 @@ def trace_instance(langfuse_config, monkeypatch: pytest.MonkeyPatch):
def test_init(langfuse_config, monkeypatch: pytest.MonkeyPatch):
from opentelemetry.sdk.trace import TracerProvider
mock_langfuse = MagicMock()
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse)
monkeypatch.setenv("FILES_URL", "http://test.url")
instance = LangFuseDataTrace(langfuse_config)
mock_langfuse.assert_called_once_with(
public_key=langfuse_config.public_key,
secret_key=langfuse_config.secret_key,
host=langfuse_config.host,
)
mock_langfuse.assert_called_once()
kwargs = mock_langfuse.call_args.kwargs
assert kwargs["public_key"] == langfuse_config.public_key
assert kwargs["secret_key"] == langfuse_config.secret_key
assert kwargs["host"] == langfuse_config.host
assert isinstance(kwargs["tracer_provider"], TracerProvider)
assert kwargs["tracer_provider"] is instance._tracer_provider
assert instance.file_base_url == "http://test.url"
def test_init_passes_isolated_tracer_provider_to_langfuse(langfuse_config, monkeypatch: pytest.MonkeyPatch):
"""Regression test for langfuse v3 SDK side effect.
Without an explicit ``tracer_provider=`` kwarg, the Langfuse v3 SDK
attaches a ``LangfuseSpanProcessor`` to the *global* OpenTelemetry
TracerProvider siphoning every Flask / Celery / SQLAlchemy span in the
process into the tenant's Langfuse project. See langfuse upgrade-path
docs (v2 -> v3) and GitHub discussion #9136.
The fix is to construct an isolated ``TracerProvider`` and pass it via
``tracer_provider=`` so the SDK never touches the global one.
"""
from opentelemetry import trace as otel_trace_api
from opentelemetry.sdk.trace import TracerProvider
captured: dict[str, object] = {}
def fake_langfuse(**kwargs):
captured.update(kwargs)
return MagicMock()
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", fake_langfuse)
instance = LangFuseDataTrace(langfuse_config)
# 1. tracer_provider kwarg must be supplied (drives the no-pollution branch
# in langfuse.LangfuseResourceManager._init_tracer_provider).
assert "tracer_provider" in captured, (
"Langfuse() must receive an explicit tracer_provider=; without it the "
"v3 SDK attaches its SpanProcessor to the global OTEL TracerProvider."
)
passed_provider = captured["tracer_provider"]
assert isinstance(passed_provider, TracerProvider)
assert passed_provider is instance._tracer_provider
# 2. The instance's provider must not be the global one.
global_provider = otel_trace_api.get_tracer_provider()
assert passed_provider is not global_provider
def test_close_shuts_down_tracer_provider(langfuse_config, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
instance = LangFuseDataTrace(langfuse_config)
provider = instance._tracer_provider
provider_shutdown = MagicMock()
monkeypatch.setattr(provider, "shutdown", provider_shutdown)
instance.close()
provider_shutdown.assert_called_once()
assert instance._tracer_provider is None
def test_close_is_idempotent(langfuse_config, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
instance = LangFuseDataTrace(langfuse_config)
provider_shutdown = MagicMock()
monkeypatch.setattr(instance._tracer_provider, "shutdown", provider_shutdown)
instance.close()
instance.close()
provider_shutdown.assert_called_once()
def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
methods = [
"workflow_trace",
@@ -94,29 +94,29 @@ class PatchedCoreComponents(TypedDict):
def _add_stub_modules(monkeypatch: pytest.MonkeyPatch) -> None:
"""Drop fake metric modules into sys.modules so the client imports resolve."""
metrics_module = types.ModuleType("opentelemetry.sdk.metrics")
metrics_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics"))
metrics_module.Histogram = DummyHistogram
metrics_module.MeterProvider = DummyMeterProvider
monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics", metrics_module)
metrics_export_module = types.ModuleType("opentelemetry.sdk.metrics.export")
metrics_export_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics.export"))
metrics_export_module.AggregationTemporality = AggregationTemporality
metrics_export_module.PeriodicExportingMetricReader = DummyMetricReader
monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics.export", metrics_export_module)
grpc_module = types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter")
grpc_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter"))
grpc_module.OTLPMetricExporter = DummyGrpcMetricExporter
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.grpc.metric_exporter", grpc_module)
http_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter")
http_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter"))
http_module.OTLPMetricExporter = DummyHttpMetricExporter
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.http.metric_exporter", http_module)
http_json_module = types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter")
http_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter"))
http_json_module.OTLPMetricExporter = DummyJsonMetricExporter
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.http.json.metric_exporter", http_json_module)
legacy_json_module = types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter")
legacy_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter"))
legacy_json_module.OTLPMetricExporter = DummyJsonMetricExporter
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.json.metric_exporter", legacy_json_module)
@@ -2,7 +2,7 @@ import json
from typing import Any, TypedDict
import chromadb
from chromadb import QueryResult, Settings # pyright: ignore[reportPrivateImportUsage]
from chromadb import QueryResult, Settings
from pydantic import BaseModel
from configs import dify_config
@@ -166,8 +166,8 @@ class ChromaVectorFactory(AbstractVectorFactory):
config=ChromaConfig(
host=dify_config.CHROMA_HOST or "",
port=dify_config.CHROMA_PORT,
tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, # pyright: ignore[reportPrivateImportUsage]
database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, # pyright: ignore[reportPrivateImportUsage]
tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT,
database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE,
auth_provider=dify_config.CHROMA_AUTH_PROVIDER,
auth_credentials=dify_config.CHROMA_AUTH_CREDENTIALS,
),
@@ -59,7 +59,7 @@ class CouchbaseVector(BaseVector):
auth = PasswordAuthenticator(config.user, config.password)
options = ClusterOptions(auth)
self._cluster = Cluster(config.connection_string, options) # pyright: ignore[reportArgumentType]
self._cluster = Cluster(config.connection_string, options)
self._bucket = self._cluster.bucket(config.bucket_name)
self._scope = self._bucket.scope(config.scope_name)
self._bucket_name = config.bucket_name
@@ -306,7 +306,7 @@ class CouchbaseVector(BaseVector):
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
top_k = kwargs.get("top_k", 4)
try:
CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyright: ignore[reportCallIssue]
CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyrefly: ignore[bad-argument-count]
search_iter = self._scope.search(
self._collection_name + "_search", CBrequest, SearchOptions(limit=top_k, fields=["*"])
)
@@ -471,7 +471,7 @@ class QdrantVector(BaseVector):
def _reload_if_needed(self):
if isinstance(self._client, QdrantLocal):
self._client._load() # pyright: ignore[reportPrivateUsage]
self._client._load()
@classmethod
def _document_from_scored_point(
+3 -4
View File
@@ -22,7 +22,6 @@ dependencies = [
"redis[hiredis]>=7.4.0",
"sendgrid>=6.12.5",
"sseclient-py>=1.8.0",
# Stable: production-proven, cap below the next major
"aliyun-log-python-sdk>=0.9.44,<1.0.0",
"azure-identity>=1.25.3,<2.0.0",
@@ -42,8 +41,8 @@ dependencies = [
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
"readabilipy>=0.3.0,<1.0.0",
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"dify-agent",
"fastopenapi[flask]~=0.7.0",
"graphon~=0.3.1",
"httpx-sse~=0.4.0",
@@ -60,6 +59,7 @@ members = ["providers/vdb/*", "providers/trace/*"]
exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"]
[tool.uv.sources]
dify-agent = { path = "../dify-agent" }
dify-vdb-alibabacloud-mysql = { workspace = true }
dify-vdb-analyticdb = { workspace = true }
dify-vdb-baidu = { workspace = true }
@@ -118,7 +118,6 @@ dev = [
"dotenv-linter>=0.7.0",
"faker>=40.15.0",
"lxml-stubs>=0.5.1",
"basedpyright>=1.39.3",
"ruff>=0.15.12",
"pytest>=9.0.3",
"pytest-benchmark>=5.2.3",
@@ -175,7 +174,7 @@ dev = [
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"pyrefly>=0.64.0",
"pyrefly>=1.0.0",
"xinference-client>=2.7.0",
]
-58
View File
@@ -1,58 +0,0 @@
{
"include": ["."],
"exclude": [
"tests/",
".venv",
"migrations/",
"core/rag",
"providers/vdb/",
"providers/trace/*/tests",
],
"typeCheckingMode": "strict",
"allowedUntypedLibraries": [
"fastopenapi",
"flask_restx",
"flask_login",
"opentelemetry.instrumentation.celery",
"opentelemetry.instrumentation.flask",
"opentelemetry.instrumentation.httpx",
"opentelemetry.instrumentation.requests",
"opentelemetry.instrumentation.sqlalchemy",
"opentelemetry.instrumentation.redis",
"langfuse",
"cloudscraper",
"readabilipy",
"pypandoc",
"pypdfium2",
"webvtt",
"flask_compress",
"oss2",
"baidubce.auth.bce_credentials",
"baidubce.bce_client_configuration",
"baidubce.services.bos.bos_client",
"clickzetta",
"google.cloud",
"obs",
"qcloud_cos",
"tos",
"gmpy2",
"sendgrid",
"sendgrid.helpers.mail",
"holo_search_sdk.types",
"dify_vdb_qdrant",
"dify_vdb_tidb_on_qdrant"
],
"reportUnknownMemberType": "hint",
"reportUnknownParameterType": "hint",
"reportUnknownArgumentType": "hint",
"reportUnknownVariableType": "hint",
"reportUnknownLambdaType": "hint",
"reportMissingParameterType": "hint",
"reportMissingTypeArgument": "hint",
"reportUnnecessaryComparison": "hint",
"reportUnnecessaryIsInstance": "hint",
"reportUnnecessaryTypeIgnoreComment": "hint",
"reportAttributeAccessIssue": "hint",
"pythonVersion": "3.12",
"pythonPlatform": "All"
}
+18 -3
View File
@@ -1280,8 +1280,8 @@ class TenantService:
"""Check member permission"""
perms = {
"add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
"remove": [TenantAccountRole.OWNER],
"update": [TenantAccountRole.OWNER],
"remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
"update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
}
if action not in {"add", "remove", "update"}:
raise InvalidActionError("Invalid action.")
@@ -1299,6 +1299,15 @@ class TenantService:
if not ta_operator or ta_operator.role not in perms[action]:
raise NoPermissionError(f"No permission to {action} member.")
if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member:
ta_member = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
.limit(1)
)
if ta_member and ta_member.role == TenantAccountRole.OWNER:
raise NoPermissionError(f"No permission to {action} member.")
@staticmethod
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
"""Remove member from tenant.
@@ -1370,6 +1379,7 @@ class TenantService:
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
"""Update member role"""
TenantService.check_member_permission(tenant, operator, member, "update")
new_tenant_role = TenantAccountRole(new_role)
target_member_join = db.session.scalar(
select(TenantAccountJoin)
@@ -1380,6 +1390,11 @@ class TenantService:
if not target_member_join:
raise MemberNotInTenantError("Member not in tenant.")
operator_role = TenantService.get_user_role(operator, tenant)
target_role = TenantAccountRole(target_member_join.role)
if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}):
raise NoPermissionError("No permission to update member.")
if target_member_join.role == new_role:
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
@@ -1394,7 +1409,7 @@ class TenantService:
current_owner_join.role = TenantAccountRole.ADMIN
# Update the role of the target member
target_member_join.role = TenantAccountRole(new_role)
target_member_join.role = new_tenant_role
db.session.commit()
@staticmethod
@@ -35,6 +35,21 @@ logger = logging.getLogger(__name__)
class ClearFreePlanTenantExpiredLogs:
@staticmethod
def _serialize_record(record: object) -> dict[str, object]:
if hasattr(record, "to_dict"):
return record.to_dict() # type: ignore[no-any-return]
table = getattr(record, "__table__", None)
columns = getattr(table, "columns", None)
if columns is None:
raise TypeError(f"Unsupported record type for serialization: {type(record)!r}")
record_dict: dict[str, object] = {}
for column in columns:
record_dict[column.name] = getattr(record, column.name)
return record_dict
@classmethod
def _clear_message_related_tables(cls, session: Session, tenant_id: str, batch_message_ids: list[str]):
"""
@@ -77,14 +92,7 @@ class ClearFreePlanTenantExpiredLogs:
record_data = []
for record in records:
try:
if hasattr(record, "to_dict"):
record_data.append(record.to_dict())
else:
# if record doesn't have to_dict method, we need to transform it to dict manually
record_dict = {}
for column in record.__table__.columns:
record_dict[column.name] = getattr(record, column.name)
record_data.append(record_dict)
record_data.append(cls._serialize_record(record))
except Exception:
logger.exception("Failed to transform %s record: %s", table_name, record.id)
continue
@@ -222,7 +230,12 @@ class ClearFreePlanTenantExpiredLogs:
f"{tenant_id}/workflow_node_executions/{datetime.datetime.now().strftime('%Y-%m-%d')}"
f"-{time.time()}.json",
json.dumps(
jsonable_encoder(workflow_node_executions),
jsonable_encoder(
[
cls._serialize_record(workflow_node_execution)
for workflow_node_execution in workflow_node_executions
]
),
).encode("utf-8"),
)
+5 -4
View File
@@ -1,9 +1,10 @@
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from core.errors.error import QuotaExceededError
from extensions.ext_database import db
from models import TenantCreditPool
@@ -41,7 +42,7 @@ class CreditPoolService:
@classmethod
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
"""get tenant credit pool"""
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
with session_factory.get_session_maker().begin() as session:
return session.scalar(
select(TenantCreditPool)
.where(
@@ -76,7 +77,7 @@ class CreditPoolService:
return 0
try:
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
if not pool:
raise QuotaExceededError("Credit pool not found")
@@ -108,7 +109,7 @@ class CreditPoolService:
return 0
try:
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
with session_factory.get_session_maker().begin() as session:
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
if not pool:
logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, pool_type)
+1 -1
View File
@@ -750,7 +750,7 @@ class DatasetService:
knowledge_index_node_data["embedding_model_provider"] = dataset.embedding_model_provider
knowledge_index_node_data["retrieval_model"] = dataset.retrieval_model
knowledge_index_node_data["chunk_structure"] = dataset.chunk_structure
knowledge_index_node_data["indexing_technique"] = dataset.indexing_technique # pyright: ignore[reportAttributeAccessIssue]
knowledge_index_node_data["indexing_technique"] = dataset.indexing_technique
knowledge_index_node_data["keyword_number"] = dataset.keyword_number
knowledge_index_node_data["summary_index_setting"] = dataset.summary_index_setting
node["data"] = knowledge_index_node_data
+132 -67
View File
@@ -49,6 +49,94 @@ class DatasourceProviderService:
def __init__(self) -> None:
self.provider_manager = PluginDatasourceManager()
@staticmethod
def _should_refresh_credentials(datasource_provider: DatasourceProvider, now: int | None = None) -> bool:
current_time = int(time.time()) if now is None else now
if datasource_provider.expires_at == -1:
return False
return (datasource_provider.expires_at - 60) < current_time
def _refresh_datasource_credentials(
self,
tenant_id: str,
provider: str,
plugin_id: str,
datasource_provider: DatasourceProvider,
current_user: Any,
) -> tuple[dict[str, Any], int]:
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
provider_name = datasource_provider_id.provider_name
credential_id = getattr(datasource_provider, "id", None)
credential_name = getattr(datasource_provider, "name", None)
logger.info(
"Refreshing datasource credentials for provider %s",
provider_name,
extra={
"tenant_id": tenant_id,
"plugin_id": datasource_provider_id.plugin_id,
"provider": provider_name,
"credential_id": credential_id,
"credential_name": credential_name,
"expires_at": datasource_provider.expires_at,
},
)
decrypted_credentials = self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
datasource_provider=datasource_provider,
plugin_id=plugin_id,
provider=provider,
)
redirect_uri = (
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{datasource_provider_id}/datasource/callback"
)
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
try:
refreshed_credentials = OAuthHandler().refresh_credentials(
tenant_id=tenant_id,
user_id=current_user.id,
plugin_id=datasource_provider_id.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
except Exception as exc:
message = (
f"Failed to refresh datasource credentials for provider {provider_name}"
f" (credential: {credential_name or credential_id or 'unknown'})"
)
logger.exception(
message,
extra={
"tenant_id": tenant_id,
"plugin_id": datasource_provider_id.plugin_id,
"provider": provider_name,
"credential_id": credential_id,
"credential_name": credential_name,
},
)
raise ValueError(f"{message}: {exc}") from exc
encrypted_credentials = self.encrypt_datasource_provider_credentials(
tenant_id=tenant_id,
raw_credentials=refreshed_credentials.credentials,
provider=provider,
plugin_id=plugin_id,
datasource_provider=datasource_provider,
)
logger.info(
"Refreshed datasource credentials for provider %s",
provider_name,
extra={
"tenant_id": tenant_id,
"plugin_id": datasource_provider_id.plugin_id,
"provider": provider_name,
"credential_id": credential_id,
"credential_name": credential_name,
"expires_at": refreshed_credentials.expires_at,
},
)
return encrypted_credentials, refreshed_credentials.expires_at
def remove_oauth_custom_client_params(self, tenant_id: str, datasource_provider_id: DatasourceProviderID):
"""
remove oauth custom client params
@@ -108,7 +196,10 @@ class DatasourceProviderService:
credential_id: str | None = None,
) -> dict[str, Any]:
"""
get credential by id
Return decrypted datasource credentials.
If the stored credential is expired or about to expire, this method refreshes
it through plugin-daemon and persists the refreshed credential before returning.
"""
with sessionmaker(bind=db.engine).begin() as session:
if credential_id:
@@ -130,39 +221,17 @@ class DatasourceProviderService:
)
if not datasource_provider:
return {}
# refresh the credentials
if datasource_provider.expires_at != -1 and (datasource_provider.expires_at - 60) < int(time.time()):
if self._should_refresh_credentials(datasource_provider):
current_user = get_current_user()
decrypted_credentials = self.decrypt_datasource_provider_credentials(
encrypted_credentials, expires_at = self._refresh_datasource_credentials(
tenant_id=tenant_id,
datasource_provider=datasource_provider,
plugin_id=plugin_id,
provider=provider,
)
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
provider_name = datasource_provider_id.provider_name
redirect_uri = (
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/"
f"{datasource_provider_id}/datasource/callback"
)
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
refreshed_credentials = OAuthHandler().refresh_credentials(
tenant_id=tenant_id,
user_id=current_user.id,
plugin_id=datasource_provider_id.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials(
tenant_id=tenant_id,
raw_credentials=refreshed_credentials.credentials,
provider=provider,
plugin_id=plugin_id,
datasource_provider=datasource_provider,
current_user=current_user,
)
datasource_provider.expires_at = refreshed_credentials.expires_at
datasource_provider.encrypted_credentials = encrypted_credentials
datasource_provider.expires_at = expires_at
return self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
@@ -178,7 +247,10 @@ class DatasourceProviderService:
plugin_id: str,
) -> list[dict[str, Any]]:
"""
get all datasource credentials by provider
Return all decrypted datasource credentials for a provider.
Expired credentials are refreshed independently. A failed credential refresh is
logged and skipped so one broken authorization does not block other credentials.
"""
with sessionmaker(bind=db.engine).begin() as session:
datasource_providers = session.scalars(
@@ -193,46 +265,39 @@ class DatasourceProviderService:
if not datasource_providers:
return []
current_user = get_current_user()
# refresh the credentials
real_credentials_list = []
for datasource_provider in datasource_providers:
decrypted_credentials = self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
datasource_provider=datasource_provider,
plugin_id=plugin_id,
provider=provider,
)
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
provider_name = datasource_provider_id.provider_name
redirect_uri = (
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/"
f"{datasource_provider_id}/datasource/callback"
)
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
refreshed_credentials = OAuthHandler().refresh_credentials(
tenant_id=tenant_id,
user_id=current_user.id,
plugin_id=datasource_provider_id.plugin_id,
provider=provider_name,
redirect_uri=redirect_uri,
system_credentials=system_credentials or {},
credentials=decrypted_credentials,
)
datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials(
tenant_id=tenant_id,
raw_credentials=refreshed_credentials.credentials,
provider=provider,
plugin_id=plugin_id,
datasource_provider=datasource_provider,
)
datasource_provider.expires_at = refreshed_credentials.expires_at
real_credentials = self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
datasource_provider=datasource_provider,
plugin_id=plugin_id,
provider=provider,
)
real_credentials_list.append(real_credentials)
try:
if self._should_refresh_credentials(datasource_provider):
encrypted_credentials, expires_at = self._refresh_datasource_credentials(
tenant_id=tenant_id,
provider=provider,
plugin_id=plugin_id,
datasource_provider=datasource_provider,
current_user=current_user,
)
datasource_provider.encrypted_credentials = encrypted_credentials
datasource_provider.expires_at = expires_at
real_credentials = self.decrypt_datasource_provider_credentials(
tenant_id=tenant_id,
datasource_provider=datasource_provider,
plugin_id=plugin_id,
provider=provider,
)
real_credentials_list.append(real_credentials)
except Exception:
logger.exception(
"Skipping datasource credentials for provider %s after refresh or decrypt failure",
provider,
extra={
"tenant_id": tenant_id,
"plugin_id": plugin_id,
"provider": provider,
"credential_id": getattr(datasource_provider, "id", None),
"credential_name": getattr(datasource_provider, "name", None),
"expires_at": getattr(datasource_provider, "expires_at", None),
},
)
return real_credentials_list
+1 -1
View File
@@ -1,3 +1,3 @@
class BaseServiceError(ValueError):
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
self.description = description
+5 -5
View File
@@ -6,7 +6,7 @@ from services.errors.base import BaseServiceError
class EnterpriseServiceError(BaseServiceError):
"""Base exception for enterprise service errors."""
def __init__(self, description: str | None = None, status_code: int | None = None):
def __init__(self, description: str = "", status_code: int | None = None):
super().__init__(description)
self.status_code = status_code
@@ -20,26 +20,26 @@ class EnterpriseAPIError(EnterpriseServiceError):
class EnterpriseAPINotFoundError(EnterpriseServiceError):
"""Enterprise API returned 404 Not Found."""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
super().__init__(description, status_code=404)
class EnterpriseAPIForbiddenError(EnterpriseServiceError):
"""Enterprise API returned 403 Forbidden."""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
super().__init__(description, status_code=403)
class EnterpriseAPIUnauthorizedError(EnterpriseServiceError):
"""Enterprise API returned 401 Unauthorized."""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
super().__init__(description, status_code=401)
class EnterpriseAPIBadRequestError(EnterpriseServiceError):
"""Enterprise API returned 400 Bad Request."""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
super().__init__(description, status_code=400)
+2 -2
View File
@@ -1,9 +1,9 @@
class InvokeError(Exception):
"""Base class for all LLM exceptions."""
description: str | None = None
description: str = ""
def __init__(self, description: str | None = None):
def __init__(self, description: str = ""):
self.description = description
def __str__(self):
+57 -1
View File
@@ -3,6 +3,8 @@ import logging
import time
from typing import Any, TypedDict, cast
from sqlalchemy import select
from core.app.app_config.entities import ModelConfig
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
from core.rag.index_processor.constant.query_type import QueryType
@@ -13,6 +15,7 @@ from extensions.ext_database import db
from graphon.model_runtime.entities import LLMMode
from models import Account
from models.dataset import Dataset, DatasetQuery
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DatasetQuerySource
logger = logging.getLogger(__name__)
@@ -41,6 +44,59 @@ class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False):
class HitTestingService:
@staticmethod
def _dump_dataset_document(document: DatasetDocument) -> dict[str, Any]:
return {
"id": document.id,
"data_source_type": document.data_source_type,
"name": document.name,
"doc_type": document.doc_type,
"doc_metadata": document.doc_metadata,
}
@classmethod
def _dump_retrieval_records(cls, records: list[Any]) -> list[dict[str, Any]]:
dumped_records = [record.model_dump() for record in records]
document_ids = {
segment.get("document_id")
for record in dumped_records
if isinstance(record, dict)
for segment in [record.get("segment")]
if isinstance(segment, dict) and segment.get("document_id")
}
if not document_ids:
return dumped_records
documents = {
document.id: cls._dump_dataset_document(document)
for document in db.session.scalars(
select(DatasetDocument).where(DatasetDocument.id.in_(document_ids))
).all()
}
records_with_documents: list[dict[str, Any]] = []
missing_document_ids: set[str] = set()
for record in dumped_records:
segment = record.get("segment")
if not isinstance(segment, dict):
records_with_documents.append(record)
continue
document_id = segment.get("document_id")
if document_id in documents:
segment["document"] = documents[document_id]
records_with_documents.append(record)
elif document_id:
missing_document_ids.add(document_id)
if missing_document_ids:
logger.warning(
"Skipping hit-testing records with missing documents, document_ids=%s",
sorted(missing_document_ids),
)
return records_with_documents
@classmethod
def retrieve(
cls,
@@ -174,7 +230,7 @@ class HitTestingService:
"query": {
"content": query,
},
"records": [record.model_dump() for record in records],
"records": cls._dump_retrieval_records(records),
}
@classmethod
+2 -2
View File
@@ -1,6 +1,6 @@
import json
import logging
from typing import Any, TypedDict
from typing import Any, TypedDict, cast
from sqlalchemy import or_, select
@@ -620,7 +620,7 @@ class ModelLoadBalancingService:
for key, value in credentials.items():
if key in provider_credential_secret_variables:
credentials[key] = encrypter.encrypt_token(tenant_id, value)
credentials[key] = encrypter.encrypt_token(tenant_id, cast(str, value))
return credentials
@@ -100,6 +100,13 @@ class CheckDependenciesPendingData(BaseModel):
class RagPipelineDslService:
"""Import, export, and inspect RAG pipeline DSL using the caller-owned session.
Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when
generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in
the same context manager.
"""
def __init__(self, session: Session):
self._session = session
@@ -325,7 +332,7 @@ class RagPipelineDslService:
type=CollectionBindingType.DATASET,
)
self._session.add(dataset_collection_binding)
self._session.commit()
self._session.flush()
dataset_collection_binding_id = dataset_collection_binding.id
dataset.collection_binding_id = dataset_collection_binding_id
dataset.embedding_model = knowledge_configuration.embedding_model
@@ -337,7 +344,7 @@ class RagPipelineDslService:
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
dataset.pipeline_id = pipeline.id
self._session.add(dataset)
self._session.commit()
self._session.flush()
dataset_id = dataset.id
if not dataset_id:
raise ValueError("DSL is not valid, please check the Knowledge Index node.")
@@ -462,7 +469,7 @@ class RagPipelineDslService:
type=CollectionBindingType.DATASET,
)
self._session.add(dataset_collection_binding)
self._session.commit()
self._session.flush()
dataset_collection_binding_id = dataset_collection_binding.id
dataset.collection_binding_id = dataset_collection_binding_id
dataset.embedding_model = knowledge_configuration.embedding_model
@@ -474,7 +481,7 @@ class RagPipelineDslService:
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
dataset.pipeline_id = pipeline.id
self._session.add(dataset)
self._session.commit()
self._session.flush()
dataset_id = dataset.id
if not dataset_id:
raise ValueError("DSL is not valid, please check the Knowledge Index node.")
@@ -585,7 +592,7 @@ class RagPipelineDslService:
pipeline.id = str(uuid4())
self._session.add(pipeline)
self._session.commit()
self._session.flush()
# save dependencies
if dependencies:
redis_client.setex(
@@ -627,8 +634,8 @@ class RagPipelineDslService:
workflow.environment_variables = environment_variables
workflow.conversation_variables = conversation_variables
workflow.rag_pipeline_variables = rag_pipeline_variables_list
# commit db session changes
self._session.commit()
# Keep transaction ownership with the caller while materializing IDs and constraint checks before returning.
self._session.flush()
return pipeline
@@ -1,7 +1,11 @@
import logging
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
from core.plugin.impl.datasource import PluginDatasourceManager
from services.datasource_provider_service import DatasourceProviderService
logger = logging.getLogger(__name__)
class RagPipelineManageService:
@staticmethod
@@ -15,9 +19,21 @@ class RagPipelineManageService:
datasources = manager.fetch_datasource_providers(tenant_id)
for datasource in datasources:
datasource_provider_service = DatasourceProviderService()
credentials = datasource_provider_service.get_datasource_credentials(
tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id
)
if credentials:
datasource.is_authorized = True
try:
credentials = datasource_provider_service.get_datasource_credentials(
tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id
)
if credentials:
datasource.is_authorized = True
except Exception:
logger.exception(
"Skipping datasource credentials for provider %s after refresh or decrypt failure",
datasource.provider,
extra={
"tenant_id": tenant_id,
"plugin_id": datasource.plugin_id,
"provider": datasource.provider,
},
)
return datasources
@@ -13,7 +13,10 @@ logger = logging.getLogger(__name__)
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
"""
Retrieval recommended app from dify official
Retrieval recommended app from dify official.
The remote `/apps` payload is already curated for display, including category order.
Keep the response order intact so Explore matches the template service.
"""
def get_recommend_app_detail(self, app_id: str):
@@ -64,8 +67,4 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}")
result: dict[str, Any] = response.json()
if "categories" in result:
result["categories"] = sorted(result["categories"])
return result
+3 -1
View File
@@ -47,7 +47,9 @@ class RecommendedAppService:
"""
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result: dict[str, Any] = retrieval_instance.get_recommend_app_detail(app_id)
result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id)
if result is None:
return None
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
@@ -228,7 +228,7 @@ class AppMessageExportService:
Message.conversation_id,
Message.query,
Message.answer,
Message._inputs, # pyright: ignore[reportPrivateUsage]
Message._inputs,
Message.message_metadata,
Message.created_at,
)
@@ -352,6 +352,32 @@ class TestRecommendedAppServiceTrialFeatures:
assert result["id"] == app_id
assert result["can_trial"] is has_trial_app
def test_get_detail_returns_none_when_not_found_and_trial_enabled(
self,
db_session_with_containers: Session,
monkeypatch: pytest.MonkeyPatch,
):
"""Regression: accessing result['id'] when result is None must not crash."""
retrieval_instance = MagicMock()
retrieval_instance.get_recommend_app_detail.return_value = None
retrieval_factory = MagicMock(return_value=retrieval_instance)
monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False)
monkeypatch.setattr(
service_module.RecommendAppRetrievalFactory,
"get_recommend_app_factory",
MagicMock(return_value=retrieval_factory),
)
monkeypatch.setattr(
service_module.FeatureService,
"get_system_features",
MagicMock(return_value=SimpleNamespace(enable_trial_app=True)),
)
result = RecommendedAppService.get_recommend_app_detail("nonexistent")
assert result is None
retrieval_instance.get_recommend_app_detail.assert_called_once_with("nonexistent")
def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session):
app_id = str(uuid.uuid4())
account_id = str(uuid.uuid4())
@@ -130,7 +130,7 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CODE_EXECUTION_ENDPOINT", "http://127.0.0.1:8194/")
# Disable `.env` loading to ensure test stability across environments
flask_app.config.from_mapping(DifyConfig(_env_file=None).model_dump()) # pyright: ignore
flask_app.config.from_mapping(DifyConfig(_env_file=None).model_dump())
config = flask_app.config
# configs read from pydantic-settings
@@ -120,7 +120,7 @@ class TestParseArgs:
class TestPerformHitTesting:
def test_success(self, dataset):
response = {
"query": "hello",
"query": {"content": "hello"},
"records": [],
}
@@ -134,7 +134,7 @@ class TestPerformHitTesting:
assert result["query"] == "hello"
assert result["records"] == []
def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
def test_success_prepares_nullable_list_fields(self, dataset):
response = {
"query": {"content": "hello"},
"records": [
@@ -170,6 +170,18 @@ class TestPerformHitTesting:
}
]
def test_invalid_query_response_raises_value_error(self):
with pytest.raises(ValueError, match="Invalid hit testing query response"):
DatasetsHitTestingBase._extract_hit_testing_query("hello")
def test_invalid_records_response_raises_value_error(self):
with pytest.raises(ValueError, match="Invalid hit testing records response"):
DatasetsHitTestingBase._prepare_hit_testing_records({"records": []})
def test_invalid_record_response_raises_value_error(self):
with pytest.raises(ValueError, match="Invalid hit testing record response"):
DatasetsHitTestingBase._prepare_hit_testing_records(["record"])
def test_index_not_initialized(self, dataset):
with patch.object(
HitTestingService,
@@ -1,880 +0,0 @@
"""Final working unit tests for admin endpoints - tests business logic directly."""
import uuid
from unittest.mock import Mock, PropertyMock, patch
import pytest
from pytest_mock import MockerFixture
from werkzeug.exceptions import NotFound, Unauthorized
from controllers.console.admin import (
DeleteExploreBannerApi,
InsertExploreAppApi,
InsertExploreAppListApi,
InsertExploreAppPayload,
InsertExploreBannerApi,
InsertExploreBannerPayload,
)
from models.model import App, InstalledApp, RecommendedApp
@pytest.fixture(autouse=True)
def bypass_only_edition_cloud(mocker: MockerFixture):
"""
Bypass only_edition_cloud decorator by setting EDITION to "CLOUD".
"""
mocker.patch(
"controllers.console.wraps.dify_config.EDITION",
new="CLOUD",
)
@pytest.fixture
def mock_admin_auth(mocker: MockerFixture):
"""
Provide valid admin authentication for controller tests.
"""
mocker.patch(
"controllers.console.admin.dify_config.ADMIN_API_KEY",
"test-admin-key",
)
mocker.patch(
"controllers.console.admin.extract_access_token",
return_value="test-admin-key",
)
@pytest.fixture
def mock_console_payload(mocker: MockerFixture):
payload = {
"app_id": str(uuid.uuid4()),
"language": "en-US",
"category": "Productivity",
"position": 1,
}
mocker.patch(
"flask_restx.namespace.Namespace.payload",
new_callable=PropertyMock,
return_value=payload,
)
return payload
@pytest.fixture
def mock_banner_payload(mocker: MockerFixture):
mocker.patch(
"flask_restx.namespace.Namespace.payload",
new_callable=PropertyMock,
return_value={
"title": "Test Banner",
"description": "Banner description",
"img-src": "https://example.com/banner.png",
"link": "https://example.com",
"sort": 1,
"category": "homepage",
},
)
@pytest.fixture
def mock_session_factory(mocker: MockerFixture):
mock_session = Mock()
mock_session.execute = Mock()
mock_session.add = Mock()
mock_session.commit = Mock()
mocker.patch(
"controllers.console.admin.session_factory.create_session",
return_value=Mock(
__enter__=lambda s: mock_session,
__exit__=Mock(return_value=False),
),
)
class TestDeleteExploreBannerApi:
def setup_method(self):
self.api = DeleteExploreBannerApi()
def test_delete_banner_not_found(self, mocker: MockerFixture, mock_admin_auth):
mocker.patch(
"controllers.console.admin.db.session.execute",
return_value=Mock(scalar_one_or_none=lambda: None),
)
with pytest.raises(NotFound, match="is not found"):
self.api.delete(uuid.uuid4())
def test_delete_banner_success(self, mocker: MockerFixture, mock_admin_auth):
mock_banner = Mock()
mocker.patch(
"controllers.console.admin.db.session.execute",
return_value=Mock(scalar_one_or_none=lambda: mock_banner),
)
mocker.patch("controllers.console.admin.db.session.delete")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.delete(uuid.uuid4())
assert status == 204
assert response["result"] == "success"
class TestInsertExploreBannerApi:
def setup_method(self):
self.api = InsertExploreBannerApi()
def test_insert_banner_success(self, mocker: MockerFixture, mock_admin_auth, mock_banner_payload):
mocker.patch("controllers.console.admin.db.session.add")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 201
assert response["result"] == "success"
def test_banner_payload_valid_language(self):
payload = {
"title": "Test Banner",
"description": "Banner description",
"img-src": "https://example.com/banner.png",
"link": "https://example.com",
"sort": 1,
"category": "homepage",
"language": "en-US",
}
model = InsertExploreBannerPayload.model_validate(payload)
assert model.language == "en-US"
def test_banner_payload_invalid_language(self):
payload = {
"title": "Test Banner",
"description": "Banner description",
"img-src": "https://example.com/banner.png",
"link": "https://example.com",
"sort": 1,
"category": "homepage",
"language": "invalid-lang",
}
with pytest.raises(ValueError, match="invalid-lang is not a valid language"):
InsertExploreBannerPayload.model_validate(payload)
class TestInsertExploreAppApiDelete:
def setup_method(self):
self.api = InsertExploreAppApi()
def test_delete_when_not_in_explore(self, mocker: MockerFixture, mock_admin_auth):
mocker.patch(
"controllers.console.admin.session_factory.create_session",
return_value=Mock(
__enter__=lambda s: s,
__exit__=Mock(return_value=False),
execute=lambda *_: Mock(scalar_one_or_none=lambda: None),
),
)
response, status = self.api.delete(uuid.uuid4())
assert status == 204
assert response["result"] == "success"
def test_delete_when_in_explore_with_trial_app(self, mocker: MockerFixture, mock_admin_auth):
"""Test deleting an app from explore that has a trial app."""
app_id = uuid.uuid4()
mock_recommended = Mock(spec=RecommendedApp)
mock_recommended.app_id = "app-123"
mock_app = Mock(spec=App)
mock_app.is_public = True
mock_trial = Mock()
# Mock session context manager and its execute
mock_session = Mock()
mock_session.execute = Mock()
mock_session.delete = Mock()
# Set up side effects for execute calls
mock_session.execute.side_effect = [
Mock(scalar_one_or_none=lambda: mock_recommended),
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalars=Mock(return_value=Mock(all=lambda: []))),
Mock(scalar_one_or_none=lambda: mock_trial),
]
mocker.patch(
"controllers.console.admin.session_factory.create_session",
return_value=Mock(
__enter__=lambda s: mock_session,
__exit__=Mock(return_value=False),
),
)
mocker.patch("controllers.console.admin.db.session.delete")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.delete(app_id)
assert status == 204
assert response["result"] == "success"
assert mock_app.is_public is False
def test_delete_with_installed_apps(self, mocker: MockerFixture, mock_admin_auth):
"""Test deleting an app that has installed apps in other tenants."""
app_id = uuid.uuid4()
mock_recommended = Mock(spec=RecommendedApp)
mock_recommended.app_id = "app-123"
mock_app = Mock(spec=App)
mock_app.is_public = True
mock_installed_app = Mock(spec=InstalledApp)
# Mock session
mock_session = Mock()
mock_session.execute = Mock()
mock_session.delete = Mock()
mock_session.execute.side_effect = [
Mock(scalar_one_or_none=lambda: mock_recommended),
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalars=Mock(return_value=Mock(all=lambda: [mock_installed_app]))),
Mock(scalar_one_or_none=lambda: None),
]
mocker.patch(
"controllers.console.admin.session_factory.create_session",
return_value=Mock(
__enter__=lambda s: mock_session,
__exit__=Mock(return_value=False),
),
)
mocker.patch("controllers.console.admin.db.session.delete")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.delete(app_id)
assert status == 204
assert mock_session.delete.called
class TestInsertExploreAppListApi:
def setup_method(self):
self.api = InsertExploreAppListApi()
def test_app_not_found(self, mocker: MockerFixture, mock_admin_auth, mock_console_payload):
mocker.patch(
"controllers.console.admin.db.session.execute",
return_value=Mock(scalar_one_or_none=lambda: None),
)
with pytest.raises(NotFound, match="is not found"):
self.api.post()
def test_create_recommended_app(
self,
mocker: MockerFixture,
mock_admin_auth,
mock_console_payload,
):
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = None
mock_app.tenant_id = "tenant"
mock_app.is_public = False
# db.session.execute → fetch App
mocker.patch(
"controllers.console.admin.db.session.execute",
return_value=Mock(scalar_one_or_none=lambda: mock_app),
)
# session_factory.create_session → recommended_app lookup
mock_session = Mock()
mock_session.execute = Mock(return_value=Mock(scalar_one_or_none=lambda: None))
mocker.patch(
"controllers.console.admin.session_factory.create_session",
return_value=Mock(
__enter__=lambda s: mock_session,
__exit__=Mock(return_value=False),
),
)
mocker.patch("controllers.console.admin.db.session.add")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 201
assert response["result"] == "success"
assert mock_app.is_public is True
def test_update_recommended_app(
self, mocker: MockerFixture, mock_admin_auth, mock_console_payload, mock_session_factory
):
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = None
mock_app.is_public = False
mock_recommended = Mock(spec=RecommendedApp)
mocker.patch(
"controllers.console.admin.db.session.execute",
side_effect=[
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalar_one_or_none=lambda: mock_recommended),
],
)
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 200
assert response["result"] == "success"
assert mock_app.is_public is True
def test_site_data_overrides_payload(
self,
mocker: MockerFixture,
mock_admin_auth,
mock_console_payload,
mock_session_factory,
):
site = Mock()
site.description = "Site Desc"
site.copyright = "Site Copyright"
site.privacy_policy = "Site Privacy"
site.custom_disclaimer = "Site Disclaimer"
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = site
mock_app.tenant_id = "tenant"
mock_app.is_public = False
mocker.patch(
"controllers.console.admin.db.session.execute",
side_effect=[
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalar_one_or_none=lambda: None),
Mock(scalar_one_or_none=lambda: None),
],
)
commit_spy = mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 200
assert response["result"] == "success"
assert mock_app.is_public is True
commit_spy.assert_called_once()
def test_create_trial_app_when_can_trial_enabled(
self,
mocker: MockerFixture,
mock_admin_auth,
mock_console_payload,
mock_session_factory,
):
mock_console_payload["can_trial"] = True
mock_console_payload["trial_limit"] = 5
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = None
mock_app.tenant_id = "tenant"
mock_app.is_public = False
mocker.patch(
"controllers.console.admin.db.session.execute",
side_effect=[
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalar_one_or_none=lambda: None),
Mock(scalar_one_or_none=lambda: None),
],
)
add_spy = mocker.patch("controllers.console.admin.db.session.add")
mocker.patch("controllers.console.admin.db.session.commit")
self.api.post()
assert any(call.args[0].__class__.__name__ == "TrialApp" for call in add_spy.call_args_list)
def test_update_recommended_app_with_trial(
self,
mocker: MockerFixture,
mock_admin_auth,
mock_console_payload,
mock_session_factory,
):
"""Test updating a recommended app when trial is enabled."""
mock_console_payload["can_trial"] = True
mock_console_payload["trial_limit"] = 10
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = None
mock_app.is_public = False
mock_app.tenant_id = "tenant-123"
mock_recommended = Mock(spec=RecommendedApp)
mocker.patch(
"controllers.console.admin.db.session.execute",
side_effect=[
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalar_one_or_none=lambda: mock_recommended),
Mock(scalar_one_or_none=lambda: None),
],
)
add_spy = mocker.patch("controllers.console.admin.db.session.add")
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 200
assert response["result"] == "success"
assert mock_app.is_public is True
def test_update_recommended_app_without_trial(
self,
mocker: MockerFixture,
mock_admin_auth,
mock_console_payload,
mock_session_factory,
):
"""Test updating a recommended app without trial enabled."""
mock_app = Mock(spec=App)
mock_app.id = "app-id"
mock_app.site = None
mock_app.is_public = False
mock_recommended = Mock(spec=RecommendedApp)
mocker.patch(
"controllers.console.admin.db.session.execute",
side_effect=[
Mock(scalar_one_or_none=lambda: mock_app),
Mock(scalar_one_or_none=lambda: mock_recommended),
],
)
mocker.patch("controllers.console.admin.db.session.commit")
response, status = self.api.post()
assert status == 200
assert response["result"] == "success"
assert mock_app.is_public is True
class TestInsertExploreAppPayload:
"""Test InsertExploreAppPayload validation."""
def test_valid_payload(self):
"""Test creating payload with valid data."""
payload_data = {
"app_id": str(uuid.uuid4()),
"desc": "Test app description",
"copyright": "© 2024 Test Company",
"privacy_policy": "https://example.com/privacy",
"custom_disclaimer": "Custom disclaimer text",
"language": "en-US",
"category": "Productivity",
"position": 1,
}
payload = InsertExploreAppPayload.model_validate(payload_data)
assert payload.app_id == payload_data["app_id"]
assert payload.desc == payload_data["desc"]
assert payload.copyright == payload_data["copyright"]
assert payload.privacy_policy == payload_data["privacy_policy"]
assert payload.custom_disclaimer == payload_data["custom_disclaimer"]
assert payload.language == payload_data["language"]
assert payload.category == payload_data["category"]
assert payload.position == payload_data["position"]
def test_minimal_payload(self):
"""Test creating payload with only required fields."""
payload_data = {
"app_id": str(uuid.uuid4()),
"language": "en-US",
"category": "Productivity",
"position": 1,
}
payload = InsertExploreAppPayload.model_validate(payload_data)
assert payload.app_id == payload_data["app_id"]
assert payload.desc is None
assert payload.copyright is None
assert payload.privacy_policy is None
assert payload.custom_disclaimer is None
assert payload.language == payload_data["language"]
assert payload.category == payload_data["category"]
assert payload.position == payload_data["position"]
def test_invalid_language(self):
"""Test payload with invalid language code."""
payload_data = {
"app_id": str(uuid.uuid4()),
"language": "invalid-lang",
"category": "Productivity",
"position": 1,
}
with pytest.raises(ValueError, match="invalid-lang is not a valid language"):
InsertExploreAppPayload.model_validate(payload_data)
class TestAdminRequiredDecorator:
"""Test admin_required decorator."""
def setup_method(self):
"""Set up test fixtures."""
# Mock dify_config
self.dify_config_patcher = patch("controllers.console.admin.dify_config")
self.mock_dify_config = self.dify_config_patcher.start()
self.mock_dify_config.ADMIN_API_KEY = "test-admin-key"
# Mock extract_access_token
self.token_patcher = patch("controllers.console.admin.extract_access_token")
self.mock_extract_token = self.token_patcher.start()
def teardown_method(self):
"""Clean up test fixtures."""
self.dify_config_patcher.stop()
self.token_patcher.stop()
def test_admin_required_success(self):
"""Test successful admin authentication."""
from controllers.console.admin import admin_required
@admin_required
def test_view():
return {"success": True}
self.mock_extract_token.return_value = "test-admin-key"
result = test_view()
assert result["success"] is True
def test_admin_required_invalid_token(self):
"""Test admin_required with invalid token."""
from controllers.console.admin import admin_required
@admin_required
def test_view():
return {"success": True}
self.mock_extract_token.return_value = "wrong-key"
with pytest.raises(Unauthorized, match="API key is invalid"):
test_view()
def test_admin_required_no_api_key_configured(self):
"""Test admin_required when no API key is configured."""
from controllers.console.admin import admin_required
self.mock_dify_config.ADMIN_API_KEY = None
@admin_required
def test_view():
return {"success": True}
with pytest.raises(Unauthorized, match="API key is invalid"):
test_view()
def test_admin_required_missing_authorization_header(self):
"""Test admin_required with missing authorization header."""
from controllers.console.admin import admin_required
@admin_required
def test_view():
return {"success": True}
self.mock_extract_token.return_value = None
with pytest.raises(Unauthorized, match="Authorization header is missing"):
test_view()
class TestExploreAppBusinessLogicDirect:
"""Test the core business logic of explore app management directly."""
def test_data_fusion_logic(self):
"""Test the data fusion logic between payload and site data."""
# Test cases for different data scenarios
test_cases = [
{
"name": "site_data_overrides_payload",
"payload": {"desc": "Payload desc", "copyright": "Payload copyright"},
"site": {"description": "Site desc", "copyright": "Site copyright"},
"expected": {
"desc": "Site desc",
"copyright": "Site copyright",
"privacy_policy": "",
"custom_disclaimer": "",
},
},
{
"name": "payload_used_when_no_site",
"payload": {"desc": "Payload desc", "copyright": "Payload copyright"},
"site": None,
"expected": {
"desc": "Payload desc",
"copyright": "Payload copyright",
"privacy_policy": "",
"custom_disclaimer": "",
},
},
{
"name": "empty_defaults_when_no_data",
"payload": {},
"site": None,
"expected": {"desc": "", "copyright": "", "privacy_policy": "", "custom_disclaimer": ""},
},
]
for case in test_cases:
# Simulate the data fusion logic
payload_desc = case["payload"].get("desc")
payload_copyright = case["payload"].get("copyright")
payload_privacy_policy = case["payload"].get("privacy_policy")
payload_custom_disclaimer = case["payload"].get("custom_disclaimer")
if case["site"]:
site_desc = case["site"].get("description")
site_copyright = case["site"].get("copyright")
site_privacy_policy = case["site"].get("privacy_policy")
site_custom_disclaimer = case["site"].get("custom_disclaimer")
# Site data takes precedence
desc = site_desc or payload_desc or ""
copyright = site_copyright or payload_copyright or ""
privacy_policy = site_privacy_policy or payload_privacy_policy or ""
custom_disclaimer = site_custom_disclaimer or payload_custom_disclaimer or ""
else:
# Use payload data or empty defaults
desc = payload_desc or ""
copyright = payload_copyright or ""
privacy_policy = payload_privacy_policy or ""
custom_disclaimer = payload_custom_disclaimer or ""
result = {
"desc": desc,
"copyright": copyright,
"privacy_policy": privacy_policy,
"custom_disclaimer": custom_disclaimer,
}
assert result == case["expected"], f"Failed test case: {case['name']}"
def test_app_visibility_logic(self):
"""Test that apps are made public when added to explore list."""
# Create a mock app
mock_app = Mock(spec=App)
mock_app.is_public = False
# Simulate the business logic
mock_app.is_public = True
assert mock_app.is_public is True
def test_recommended_app_creation_logic(self):
"""Test the creation of RecommendedApp objects."""
app_id = str(uuid.uuid4())
payload_data = {
"app_id": app_id,
"desc": "Test app description",
"copyright": "© 2024 Test Company",
"privacy_policy": "https://example.com/privacy",
"custom_disclaimer": "Custom disclaimer",
"language": "en-US",
"category": "Productivity",
"position": 1,
}
# Simulate the creation logic
recommended_app = Mock(spec=RecommendedApp)
recommended_app.app_id = payload_data["app_id"]
recommended_app.description = payload_data["desc"]
recommended_app.copyright = payload_data["copyright"]
recommended_app.privacy_policy = payload_data["privacy_policy"]
recommended_app.custom_disclaimer = payload_data["custom_disclaimer"]
recommended_app.language = payload_data["language"]
recommended_app.category = payload_data["category"]
recommended_app.position = payload_data["position"]
# Verify the data
assert recommended_app.app_id == app_id
assert recommended_app.description == "Test app description"
assert recommended_app.copyright == "© 2024 Test Company"
assert recommended_app.privacy_policy == "https://example.com/privacy"
assert recommended_app.custom_disclaimer == "Custom disclaimer"
assert recommended_app.language == "en-US"
assert recommended_app.category == "Productivity"
assert recommended_app.position == 1
def test_recommended_app_update_logic(self):
"""Test the update logic for existing RecommendedApp objects."""
mock_recommended_app = Mock(spec=RecommendedApp)
update_data = {
"desc": "Updated description",
"copyright": "© 2024 Updated",
"language": "fr-FR",
"category": "Tools",
"position": 2,
}
# Simulate the update logic
mock_recommended_app.description = update_data["desc"]
mock_recommended_app.copyright = update_data["copyright"]
mock_recommended_app.language = update_data["language"]
mock_recommended_app.category = update_data["category"]
mock_recommended_app.position = update_data["position"]
# Verify the updates
assert mock_recommended_app.description == "Updated description"
assert mock_recommended_app.copyright == "© 2024 Updated"
assert mock_recommended_app.language == "fr-FR"
assert mock_recommended_app.category == "Tools"
assert mock_recommended_app.position == 2
def test_app_not_found_error_logic(self):
"""Test error handling when app is not found."""
app_id = str(uuid.uuid4())
# Simulate app lookup returning None
found_app = None
# Test the error condition
if not found_app:
with pytest.raises(NotFound, match=f"App '{app_id}' is not found"):
raise NotFound(f"App '{app_id}' is not found")
def test_recommended_app_not_found_error_logic(self):
"""Test error handling when recommended app is not found for deletion."""
app_id = str(uuid.uuid4())
# Simulate recommended app lookup returning None
found_recommended_app = None
# Test the error condition
if not found_recommended_app:
with pytest.raises(NotFound, match=f"App '{app_id}' is not found in the explore list"):
raise NotFound(f"App '{app_id}' is not found in the explore list")
def test_database_session_usage_patterns(self):
"""Test the expected database session usage patterns."""
# Mock session usage patterns
mock_session = Mock()
# Test session.add pattern
mock_recommended_app = Mock(spec=RecommendedApp)
mock_session.add(mock_recommended_app)
mock_session.commit()
# Verify session was used correctly
mock_session.add.assert_called_once_with(mock_recommended_app)
mock_session.commit.assert_called_once()
# Test session.delete pattern
mock_recommended_app_to_delete = Mock(spec=RecommendedApp)
mock_session.delete(mock_recommended_app_to_delete)
mock_session.commit()
# Verify delete pattern
mock_session.delete.assert_called_once_with(mock_recommended_app_to_delete)
def test_payload_validation_integration(self):
"""Test payload validation in the context of the business logic."""
# Test valid payload
valid_payload_data = {
"app_id": str(uuid.uuid4()),
"desc": "Test app description",
"language": "en-US",
"category": "Productivity",
"position": 1,
}
# This should succeed
payload = InsertExploreAppPayload.model_validate(valid_payload_data)
assert payload.app_id == valid_payload_data["app_id"]
# Test invalid payload
invalid_payload_data = {
"app_id": str(uuid.uuid4()),
"language": "invalid-lang", # This should fail validation
"category": "Productivity",
"position": 1,
}
# This should raise an exception
with pytest.raises(ValueError, match="invalid-lang is not a valid language"):
InsertExploreAppPayload.model_validate(invalid_payload_data)
class TestExploreAppDataHandling:
"""Test specific data handling scenarios."""
def test_uuid_validation(self):
"""Test UUID validation and handling."""
# Test valid UUID
valid_uuid = str(uuid.uuid4())
# This should be a valid UUID
assert uuid.UUID(valid_uuid) is not None
# Test invalid UUID
invalid_uuid = "not-a-valid-uuid"
# This should raise a ValueError
with pytest.raises(ValueError):
uuid.UUID(invalid_uuid)
def test_language_validation(self):
"""Test language validation against supported languages."""
from constants.languages import supported_language
# Test supported language
assert supported_language("en-US") == "en-US"
assert supported_language("fr-FR") == "fr-FR"
# Test unsupported language
with pytest.raises(ValueError, match="invalid-lang is not a valid language"):
supported_language("invalid-lang")
def test_response_formatting(self):
"""Test API response formatting."""
# Test success responses
create_response = {"result": "success"}
update_response = {"result": "success"}
delete_response = None # 204 No Content returns None
assert create_response["result"] == "success"
assert update_response["result"] == "success"
assert delete_response is None
# Test status codes
create_status = 201 # Created
update_status = 200 # OK
delete_status = 204 # No Content
assert create_status == 201
assert update_status == 200
assert delete_status == 204
@@ -308,12 +308,7 @@ class TestWorkspaceListApi:
method = unwrap(api.get)
tenant = MagicMock(id="t1", name="T", status="active", created_at=naive_utc_now())
paginate_result = MagicMock(
items=[tenant],
has_next=False,
total=1,
)
paginate_result = MagicMock(items=[tenant], has_next=False, total=1)
with (
app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 20}),
@@ -329,25 +324,12 @@ class TestWorkspaceListApi:
api = WorkspaceListApi()
method = unwrap(api.get)
tenant = MagicMock(
id="t1",
name="T",
status="active",
created_at=naive_utc_now(),
)
paginate_result = MagicMock(
items=[tenant],
has_next=True,
total=10,
)
tenant = MagicMock(id="t1", name="T", status="active", created_at=naive_utc_now())
paginate_result = MagicMock(items=[tenant], has_next=True, total=10)
with (
app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 1}),
patch(
"controllers.console.workspace.workspace.db.paginate",
return_value=paginate_result,
),
patch("controllers.console.workspace.workspace.db.paginate", return_value=paginate_result),
):
result, status = method(api)
@@ -103,7 +103,7 @@ class TestHitTestingApiPost:
mock_dataset_svc.get_dataset.return_value = mock_dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_hit_svc.retrieve.return_value = {"query": "test query", "records": []}
mock_hit_svc.retrieve.return_value = {"query": {"content": "test query"}, "records": []}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = []
@@ -149,7 +149,7 @@ class TestHitTestingApiPost:
"score_threshold": 0.8,
}
mock_hit_svc.retrieve.return_value = {"query": "complex query", "records": []}
mock_hit_svc.retrieve.return_value = {"query": {"content": "complex query"}, "records": []}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = []
@@ -194,7 +194,7 @@ class TestHitTestingApiPost:
mock_dataset_svc.get_dataset.return_value = mock_dataset
mock_dataset_svc.check_dataset_permission.return_value = None
mock_hit_svc.retrieve.return_value = {"query": "filtered query", "records": []}
mock_hit_svc.retrieve.return_value = {"query": {"content": "filtered query"}, "records": []}
mock_hit_svc.hit_testing_args_check.return_value = None
mock_marshal.return_value = []
@@ -232,7 +232,7 @@ class TestHitTestingApiPost:
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
def test_post_normalizes_legacy_query_and_nullable_list_fields(
def test_post_prepares_nullable_list_fields(
self,
mock_current_user,
mock_dataset_svc,
@@ -241,7 +241,7 @@ class TestHitTestingApiPost:
mock_ns,
app,
):
"""Test service API normalizes legacy query shape and nullable list fields."""
"""Test service API prepares nullable list fields from marshalled records."""
dataset_id = str(uuid.uuid4())
tenant_id = str(uuid.uuid4())
@@ -197,6 +197,7 @@ class TestAdvancedChatAppGeneratorInternals:
def test_resume_delegates_to_generate(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user")
application_generate_entity = AdvancedChatAppGenerateEntity.model_construct(
task_id="task",
app_config=self._build_app_config(),
@@ -207,22 +208,25 @@ class TestAdvancedChatAppGeneratorInternals:
stream=True,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=None,
trace_manager=existing_trace_manager,
workflow_run_id="run-id",
)
captured: dict[str, object] = {}
captured_entity: AdvancedChatAppGenerateEntity | None = None
captured_graph_runtime_state: object | None = None
def _fake_generate(**kwargs):
captured.update(kwargs)
return {"resumed": True}
nonlocal captured_entity, captured_graph_runtime_state
captured_entity = kwargs["application_generate_entity"]
captured_graph_runtime_state = kwargs["graph_runtime_state"]
return SimpleNamespace(resumed=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
result = generator.resume(
app_model=SimpleNamespace(),
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(),
user=SimpleNamespace(),
user=SimpleNamespace(id="end-user-id", session_id="session-id"),
conversation=SimpleNamespace(id="conversation-id"),
message=SimpleNamespace(id="message-id"),
application_generate_entity=application_generate_entity,
@@ -232,8 +236,10 @@ class TestAdvancedChatAppGeneratorInternals:
pause_state_config=None,
)
assert result == {"resumed": True}
assert captured["graph_runtime_state"] is not None
assert result.resumed is True
assert captured_entity is not None
assert captured_entity.trace_manager is existing_trace_manager
assert captured_graph_runtime_state is not None
def test_single_iteration_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
@@ -1243,3 +1249,119 @@ class TestAdvancedChatAppGeneratorInternals:
)
assert captured["application_generate_entity"].parent_message_id == UUID_NIL
class TestAdvancedChatAppGeneratorResume:
@staticmethod
def _build_app_config() -> WorkflowUIBasedAppConfig:
return WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.ADVANCED_CHAT,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
application_generate_entity = AdvancedChatAppGenerateEntity.model_construct(
task_id="task",
app_config=self._build_app_config(),
file_upload_config=None,
conversation_id="conversation-id",
inputs={},
query="hello",
files=[],
parent_message_id="parent-message-id",
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=None,
workflow_run_id="run-id",
)
DummyTraceQueueManager = type(
"_DummyTraceQueueManager",
(TraceQueueManager,),
{
"__init__": lambda self, app_id=None, user_id=None: (
setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id)
)
},
)
monkeypatch.setattr(
"core.app.apps.advanced_chat.app_generator.TraceQueueManager",
DummyTraceQueueManager,
)
captured_entity: AdvancedChatAppGenerateEntity | None = None
def _fake_generate(**kwargs):
nonlocal captured_entity
captured_entity = kwargs["application_generate_entity"]
return SimpleNamespace(ok=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
result = generator.resume(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(),
user=SimpleNamespace(id="end-user-id", session_id="session-id"),
conversation=SimpleNamespace(id="conversation-id"),
message=SimpleNamespace(id="message-id"),
application_generate_entity=application_generate_entity,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=SimpleNamespace(),
)
assert result.ok is True
assert captured_entity is not None
trace_manager = captured_entity.trace_manager
assert isinstance(trace_manager, DummyTraceQueueManager)
assert trace_manager.app_id == "app-id"
assert trace_manager.user_id == "session-id"
def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch):
generator = AdvancedChatAppGenerator()
existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user")
application_generate_entity = AdvancedChatAppGenerateEntity.model_construct(
task_id="task",
app_config=self._build_app_config(),
file_upload_config=None,
conversation_id="conversation-id",
inputs={},
query="hello",
files=[],
parent_message_id="parent-message-id",
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=existing_trace_manager,
workflow_run_id="run-id",
)
captured_entity: AdvancedChatAppGenerateEntity | None = None
def _fake_generate(**kwargs):
nonlocal captured_entity
captured_entity = kwargs["application_generate_entity"]
return SimpleNamespace(ok=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
result = generator.resume(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(),
user=SimpleNamespace(id="end-user-id", session_id="session-id"),
conversation=SimpleNamespace(id="conversation-id"),
message=SimpleNamespace(id="message-id"),
application_generate_entity=application_generate_entity,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=SimpleNamespace(),
)
assert result.ok is True
assert captured_entity is not None
assert captured_entity.trace_manager is existing_trace_manager
@@ -228,7 +228,11 @@ def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture):
app_model=SimpleNamespace(mode="workflow"),
workflow=SimpleNamespace(),
user=SimpleNamespace(),
application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API),
application_generate_entity=SimpleNamespace(
stream=False,
invoke_from=InvokeFrom.SERVICE_API,
trace_manager=SimpleNamespace(),
),
graph_runtime_state=resumed_state,
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
@@ -270,7 +274,11 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture):
user=SimpleNamespace(),
conversation=SimpleNamespace(id="conv"),
message=SimpleNamespace(id="msg"),
application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API),
application_generate_entity=SimpleNamespace(
stream=False,
invoke_from=InvokeFrom.SERVICE_API,
trace_manager=SimpleNamespace(),
),
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
graph_runtime_state=resumed_state,
@@ -99,7 +99,7 @@ def test_resume_delegates_to_generate(mocker: MockerFixture):
generator = WorkflowAppGenerator()
mock_generate = mocker.patch.object(generator, "_generate", return_value="ok")
application_generate_entity = SimpleNamespace(stream=False, invoke_from="debugger")
application_generate_entity = SimpleNamespace(stream=False, invoke_from="debugger", trace_manager=MagicMock())
runtime_state = MagicMock(name="runtime-state")
pause_config = MagicMock(name="pause-config")
@@ -186,3 +186,114 @@ class TestWorkflowAppGeneratorGenerate:
)
assert result == {"ok": True}
class TestWorkflowAppGeneratorResume:
def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.MonkeyPatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=None,
workflow_execution_id="run-id",
call_depth=0,
)
DummyTraceQueueManager = type(
"_DummyTraceQueueManager",
(TraceQueueManager,),
{
"__init__": lambda self, app_id=None, user_id=None: (
setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id)
)
},
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.TraceQueueManager",
DummyTraceQueueManager,
)
captured_entity: WorkflowAppGenerateEntity | None = None
def _fake_generate(**kwargs):
nonlocal captured_entity
captured_entity = kwargs["application_generate_entity"]
return SimpleNamespace(ok=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
result = generator.resume(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(),
user=SimpleNamespace(id="end-user-id", session_id="session-id"),
application_generate_entity=application_generate_entity,
graph_runtime_state=SimpleNamespace(),
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
)
assert result.ok is True
assert captured_entity is not None
trace_manager = captured_entity.trace_manager
assert isinstance(trace_manager, DummyTraceQueueManager)
assert trace_manager.app_id == "app-id"
assert trace_manager.user_id == "session-id"
def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user")
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=existing_trace_manager,
workflow_execution_id="run-id",
call_depth=0,
)
captured_entity: WorkflowAppGenerateEntity | None = None
def _fake_generate(**kwargs):
nonlocal captured_entity
captured_entity = kwargs["application_generate_entity"]
return SimpleNamespace(ok=True)
monkeypatch.setattr(generator, "_generate", _fake_generate)
result = generator.resume(
app_model=SimpleNamespace(id="app-id"),
workflow=SimpleNamespace(),
user=SimpleNamespace(id="end-user-id", session_id="session-id"),
application_generate_entity=application_generate_entity,
graph_runtime_state=SimpleNamespace(),
workflow_execution_repository=SimpleNamespace(),
workflow_node_execution_repository=SimpleNamespace(),
)
assert result.ok is True
assert captured_entity is not None
assert captured_entity.trace_manager is existing_trace_manager
@@ -1,8 +1,12 @@
from collections.abc import Iterator
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.app.llm.quota import (
@@ -21,6 +25,13 @@ from models.enums import ProviderQuotaType as ModelProviderQuotaType
from models.provider import Provider, ProviderType
@contextmanager
def _patched_credit_pool_session_factory(engine: Engine) -> Iterator[None]:
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
yield
def test_ensure_llm_quota_available_for_model_raises_when_system_model_is_exhausted() -> None:
provider_configuration = SimpleNamespace(
using_provider_type=ProviderType.SYSTEM,
@@ -148,7 +159,7 @@ def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining
with (
patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager),
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_credit_pool_session_factory(engine),
):
deduct_llm_quota_for_model(
tenant_id="tenant-id",
@@ -34,6 +34,17 @@ def test_sse_message_id_coercion():
assert msg.root.jsonrpc == expected.root.jsonrpc
def test_sse_message_without_id_stays_notification():
"""Test that method messages without an ID still parse as notifications."""
json_message = '{"jsonrpc": "2.0", "method": "ping", "params": null}'
msg = types.JSONRPCMessage.model_validate_json(json_message)
assert isinstance(msg.root, types.JSONRPCNotification)
assert msg.root.method == "ping"
assert msg.root.jsonrpc == "2.0"
class MockSSEClient:
"""Mock SSE client for testing."""
@@ -122,7 +122,7 @@ class TestValidateResult:
parameters=[
ParameterConfig(
name="status",
type="select", # pyright: ignore[reportArgumentType]
type="select",
description="Status",
required=True,
options=["active", "inactive"],
@@ -1,8 +1,12 @@
from collections.abc import Iterator
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
from sqlalchemy import create_engine, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
@@ -11,6 +15,13 @@ from models import TenantCreditPool
from models.provider import ProviderType
@contextmanager
def _patched_credit_pool_session_factory(engine: Engine) -> Iterator[None]:
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
yield
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None:
engine = create_engine("sqlite:///:memory:")
TenantCreditPool.__table__.create(engine)
@@ -54,7 +65,7 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_credit_pool_session_factory(engine),
patch.object(update_provider_when_message_created, "_execute_provider_updates"),
):
update_provider_when_message_created.handle(
+6 -4
View File
@@ -1,6 +1,8 @@
from typing import cast
from unittest.mock import MagicMock
import pytest
from flask import Request
from werkzeug.wrappers import Response
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN
@@ -16,8 +18,8 @@ class MockRequest:
def test_extract_access_token():
def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]):
return MockRequest(headers, cookies, args)
def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]) -> Request:
return cast(Request, MockRequest(headers, cookies, args))
test_cases = [
(_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123", "123"),
@@ -27,8 +29,8 @@ def test_extract_access_token():
(_mock_request({}, {COOKIE_NAME_WEBAPP_ACCESS_TOKEN: "123"}, {}), None, "123"),
]
for request, expected_console, expected_webapp in test_cases:
assert extract_access_token(request) == expected_console # pyright: ignore[reportArgumentType]
assert extract_webapp_access_token(request) == expected_webapp # pyright: ignore[reportArgumentType]
assert extract_access_token(request) == expected_console
assert extract_webapp_access_token(request) == expected_webapp
def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch: pytest.MonkeyPatch):
@@ -259,6 +259,60 @@ workflow:
if result.status == ImportStatus.FAILED:
print(f"DEBUG: {result.error}")
assert result.status == ImportStatus.COMPLETED
session.commit.assert_not_called()
session.flush.assert_called()
def test_import_rag_pipeline_flushes_new_collection_binding_without_commit(mocker) -> None:
yaml_content = """
version: 0.1.0
kind: rag_pipeline
rag_pipeline:
name: Test Pipeline
workflow:
graph:
nodes:
- data:
type: knowledge-index
"""
pipeline = Mock(id="p1", description="desc", is_published=False)
pipeline.name = "Test Pipeline"
mocker.patch.object(RagPipelineDslService, "_create_or_update_pipeline", return_value=pipeline)
config_mock = Mock()
config_mock.indexing_technique = "high_quality"
config_mock.embedding_model = "m"
config_mock.embedding_model_provider = "p"
config_mock.chunk_structure = "text_model"
config_mock.retrieval_model.model_dump.return_value = {}
config_mock.summary_index_setting = None
mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.KnowledgeConfiguration.model_validate",
return_value=config_mock,
)
dataset_mock = Mock(id="d1")
binding_mock = Mock(id="b1")
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock)
binding_cls = mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.DatasetCollectionBinding",
return_value=binding_mock,
)
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock())
session = cast(MagicMock, Mock())
session.scalar.return_value = None
session.scalars.return_value.all.return_value = []
service = RagPipelineDslService(session=cast(Session, session))
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content=yaml_content)
assert result.status == ImportStatus.COMPLETED
binding_cls.assert_called_once()
assert dataset_mock.collection_binding_id == "b1"
session.commit.assert_not_called()
assert session.flush.call_count >= 2
def test_import_rag_pipeline_pending_version(mocker) -> None:
@@ -338,6 +392,67 @@ workflow:
assert result.dataset_id == "d1"
def test_confirm_import_flushes_new_collection_binding_without_commit(mocker) -> None:
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData
yaml_content = """
version: 0.1.0
kind: rag_pipeline
rag_pipeline:
name: Test Pipeline
workflow:
graph:
nodes:
- data:
type: knowledge-index
"""
pending = RagPipelinePendingData(import_mode="yaml-content", yaml_content=yaml_content, pipeline_id="p1")
mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get",
return_value=pending.model_dump_json(),
)
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.delete")
pipeline = Mock(id="p1", description="desc")
pipeline.name = "Test Pipeline"
pipeline.retrieve_dataset.return_value = None
mocker.patch.object(RagPipelineDslService, "_create_or_update_pipeline", return_value=pipeline)
config_mock = Mock()
config_mock.indexing_technique = "high_quality"
config_mock.embedding_model = "m"
config_mock.embedding_model_provider = "p"
config_mock.chunk_structure = "text_model"
config_mock.retrieval_model.model_dump.return_value = {}
config_mock.summary_index_setting = None
mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.KnowledgeConfiguration.model_validate",
return_value=config_mock,
)
dataset_mock = Mock(id="d1")
binding_mock = Mock(id="b1")
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock)
binding_cls = mocker.patch(
"services.rag_pipeline.rag_pipeline_dsl_service.DatasetCollectionBinding",
return_value=binding_mock,
)
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock())
session = cast(MagicMock, Mock())
session.scalar.side_effect = [pipeline, None]
service = RagPipelineDslService(session=cast(Session, session))
account = Mock(id="u1", current_tenant_id="t1")
result = service.confirm_import(account=account, import_id="imp-1")
assert result.status == ImportStatus.COMPLETED
binding_cls.assert_called_once()
assert dataset_mock.collection_binding_id == "b1"
session.commit.assert_not_called()
assert session.flush.call_count >= 2
# --- _extract_dependencies_from_workflow_graph all types ---
@@ -421,6 +536,8 @@ def test_create_or_update_pipeline_create_new(mocker) -> None:
assert result == pipeline_instance
session.add.assert_called()
session.commit.assert_not_called()
session.flush.assert_called()
# --- export_rag_pipeline_dsl comprehensive ---
@@ -85,7 +85,7 @@ class TestFetchFromDifyOfficial:
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
def test_apps_returns_sorted_categories_on_200(self, mock_get, mock_config):
def test_apps_preserves_remote_categories_order_on_200(self, mock_get, mock_config):
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
mock_response = MagicMock(status_code=200)
mock_response.json.return_value = {
@@ -96,7 +96,7 @@ class TestFetchFromDifyOfficial:
result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
assert result["categories"] == ["agent", "chat", "writing"]
assert result["categories"] == ["writing", "agent", "chat"]
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
@@ -13,6 +13,7 @@ from services.errors.account import (
AccountPasswordError,
AccountRegisterError,
CurrentPasswordIncorrectError,
NoPermissionError,
)
@@ -817,8 +818,8 @@ class TestTenantService:
# Mock the database queries in update_member_role method
with patch("services.account_service.db") as mock_db:
# scalar calls: permission check, target member lookup
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join]
# scalar calls: permission check, target member lookup, operator role lookup
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
# Execute test
TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
@@ -827,6 +828,65 @@ class TestTenantService:
assert mock_target_join.role == "admin"
self._assert_database_operations_called(mock_db)
def test_admin_can_update_admin_member_role(self):
"""Test admin can update another non-owner member, including an admin."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
assert mock_target_join.role == "editor"
self._assert_database_operations_called(mock_db)
def test_admin_cannot_update_owner_member_role(self):
"""Test admin cannot update an owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="owner"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
with pytest.raises(NoPermissionError):
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
def test_admin_cannot_promote_member_to_owner(self):
"""Test admin cannot promote a non-owner member to owner."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
with patch("services.account_service.db") as mock_db:
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
with pytest.raises(NoPermissionError):
TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator)
# ==================== Permission Check Tests ====================
def test_check_member_permission_success(self, mock_db_dependencies):
@@ -864,6 +924,39 @@ class TestTenantService:
"add",
)
def test_admin_can_remove_non_owner_member(self, mock_db_dependencies):
"""Test admin can remove a non-owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="admin"
)
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
def test_admin_cannot_remove_owner_member(self, mock_db_dependencies):
"""Test admin cannot remove an owner member."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-456"
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="operator-123", role="admin"
)
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
tenant_id="tenant-456", account_id="member-789", role="owner"
)
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
with pytest.raises(NoPermissionError):
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
class TestRegisterService:
"""
@@ -304,8 +304,11 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setattr(service_module, "select", fake_select)
# Repositories for workflow node executions and workflow runs
node_execution = SimpleNamespace(id="ne-1")
node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")])
node_repo = MagicMock()
node_repo.get_expired_executions_batch.side_effect = [[SimpleNamespace(id="ne-1")], []]
node_repo.get_expired_executions_batch.side_effect = [[node_execution], []]
node_repo.delete_executions_by_ids.return_value = 1
run_repo = MagicMock()
@@ -329,6 +332,21 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) -
clear_related.assert_called()
def test_serialize_record_falls_back_to_table_columns() -> None:
record = SimpleNamespace(id="ne-1", node_id="node-1")
record.__table__ = SimpleNamespace(
columns=[
SimpleNamespace(name="id"),
SimpleNamespace(name="node_id"),
]
)
assert ClearFreePlanTenantExpiredLogs._serialize_record(record) == {
"id": "ne-1",
"node_id": "node-1",
}
def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object()))
@@ -533,9 +551,14 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte
monkeypatch.setattr(service_module, "select", fake_select)
# Repos: first returns exactly batch items -> no "< batch" break, second returns [] -> hit the len==0 break.
node_execution_1 = SimpleNamespace(id="ne-1")
node_execution_1.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")])
node_execution_2 = SimpleNamespace(id="ne-2")
node_execution_2.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")])
node_repo = MagicMock()
node_repo.get_expired_executions_batch.side_effect = [
[SimpleNamespace(id="ne-1"), SimpleNamespace(id="ne-2")],
[node_execution_1, node_execution_2],
[],
]
node_repo.delete_executions_by_ids.return_value = 2
@@ -1,10 +1,12 @@
from types import SimpleNamespace
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import patch
from uuid import uuid4
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from core.errors.error import QuotaExceededError
from models import TenantCreditPool
@@ -31,15 +33,33 @@ def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engi
return engine, tenant_id, pool_id
@contextmanager
def _patched_session_factory(engine: Engine) -> Iterator[None]:
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
yield
def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None:
with engine.connect() as connection:
return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None:
engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2)
with _patched_session_factory(engine):
pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL)
assert pool is not None
assert pool.tenant_id == tenant_id
assert pool.quota_used == 2
def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
with _patched_session_factory(engine):
deducted_credits = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3)
assert deducted_credits == 3
@@ -55,7 +75,7 @@ def test_check_and_deduct_credits_raises_when_pool_is_missing() -> None:
TenantCreditPool.__table__.create(engine)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
pytest.raises(QuotaExceededError, match="Credit pool not found"),
):
CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=1)
@@ -65,7 +85,7 @@ def test_check_and_deduct_credits_raises_when_pool_is_empty() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
pytest.raises(QuotaExceededError, match="No credits remaining"),
):
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1)
@@ -77,7 +97,7 @@ def test_check_and_deduct_credits_raises_without_partial_deduction_when_insuffic
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
pytest.raises(QuotaExceededError, match="Insufficient credits remaining"),
):
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3)
@@ -89,7 +109,7 @@ def test_check_and_deduct_credits_wraps_unexpected_deduction_errors() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")),
pytest.raises(QuotaExceededError, match="Failed to deduct credits"),
):
@@ -106,7 +126,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None:
engine = create_engine("sqlite:///:memory:")
TenantCreditPool.__table__.create(engine)
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
with _patched_session_factory(engine):
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=1)
assert deducted_credits == 0
@@ -115,7 +135,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None:
def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10)
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
with _patched_session_factory(engine):
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1)
assert deducted_credits == 0
@@ -125,7 +145,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None:
def test_deduct_credits_capped_deducts_only_remaining_balance_when_insufficient() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9)
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
with _patched_session_factory(engine):
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=3)
assert deducted_credits == 1
@@ -136,7 +156,7 @@ def test_deduct_credits_capped_wraps_unexpected_deduction_errors() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")),
pytest.raises(QuotaExceededError, match="Failed to deduct credits"),
):
@@ -149,7 +169,7 @@ def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None:
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
with (
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
_patched_session_factory(engine),
patch.object(CreditPoolService, "_get_locked_pool", side_effect=QuotaExceededError("quota unavailable")),
pytest.raises(QuotaExceededError, match="quota unavailable"),
):
@@ -243,7 +243,7 @@ class TestDatasourceProviderService:
assert service.get_datasource_credentials("t1", "prov", "org/plug") == {}
def test_should_refresh_oauth_tokens_when_expired(self, service, mock_db_session, mock_user):
"""Expired OAuth credential (expires_at near zero) triggers a silent refresh."""
"""Expired OAuth credential (expires_at near zero) triggers a refresh."""
p = MagicMock(spec=DatasourceProvider)
p.auth_type = "oauth2"
p.expires_at = 0 # expired
@@ -256,6 +256,24 @@ class TestDatasourceProviderService:
):
service.get_datasource_credentials("t1", "prov", "org/plug")
def test_should_include_provider_name_when_refresh_fails(self, service, mock_db_session, mock_user):
p = MagicMock(spec=DatasourceProvider)
p.id = "cred-id"
p.name = "Credential"
p.auth_type = "oauth2"
p.expires_at = 0
p.encrypted_credentials = {"tok": "x"}
mock_db_session.scalar.return_value = p
with (
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
patch("services.datasource_provider_service.OAuthHandler") as oauth_handler,
patch.object(service, "get_oauth_client", return_value={"oc": "v"}),
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}),
):
oauth_handler.return_value.refresh_credentials.side_effect = RuntimeError("token endpoint failed")
with pytest.raises(ValueError, match="provider prov"):
service.get_datasource_credentials("t1", "prov", "org/plug")
def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user):
"""API key credentials with expires_at=-1 skip refresh and return directly."""
p = MagicMock(spec=DatasourceProvider)
@@ -307,6 +325,51 @@ class TestDatasourceProviderService:
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
assert len(result) == 1
def test_should_skip_failed_provider_when_refreshing_all_credentials(
self, service, mock_db_session, mock_user, caplog
):
failed_provider = MagicMock(spec=DatasourceProvider)
failed_provider.id = "failed-cred"
failed_provider.name = "Failed"
failed_provider.auth_type = "oauth2"
failed_provider.expires_at = 0
working_provider = MagicMock(spec=DatasourceProvider)
working_provider.id = "working-cred"
working_provider.name = "Working"
working_provider.auth_type = "oauth2"
working_provider.expires_at = 0
mock_db_session.scalars.return_value.all.return_value = [failed_provider, working_provider]
with (
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
patch.object(
service,
"_refresh_datasource_credentials",
side_effect=[ValueError("refresh failed"), ({"t": "enc"}, 9999)],
) as refresh_credentials,
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}),
):
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
assert result == [{"t": "plain"}]
assert refresh_credentials.call_count == 2
assert "Skipping datasource credentials for provider prov" in caplog.text
def test_should_return_valid_credentials_without_refresh_when_getting_all_credentials(
self, service, mock_db_session, mock_user
):
p = MagicMock(spec=DatasourceProvider)
p.auth_type = "oauth2"
p.expires_at = -1
p.encrypted_credentials = {"t": "x"}
mock_db_session.scalars.return_value.all.return_value = [p]
with (
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
patch.object(service, "_refresh_datasource_credentials") as refresh_credentials,
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}),
):
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
assert result == [{"t": "plain"}]
refresh_credentials.assert_not_called()
# -----------------------------------------------------------------------
# update_datasource_provider_name (lines 236-303)
# -----------------------------------------------------------------------
@@ -0,0 +1,88 @@
from unittest.mock import Mock, patch
from services.hit_testing_service import HitTestingService
def _retrieval_record(payload: dict):
record = Mock()
record.model_dump.return_value = payload
return record
def _dataset_document(
document_id: str = "document-1",
name: str = "guide.md",
data_source_type: str = "upload_file",
doc_type: str | None = None,
doc_metadata: dict | None = None,
):
document = Mock()
document.id = document_id
document.name = name
document.data_source_type = data_source_type
document.doc_type = doc_type
document.doc_metadata = doc_metadata
return document
class TestHitTestingServiceDumpRecords:
def test_dump_dataset_document_returns_frontend_required_fields(self):
document = _dataset_document(doc_metadata={"source": "manual"})
assert HitTestingService._dump_dataset_document(document) == {
"id": "document-1",
"data_source_type": "upload_file",
"name": "guide.md",
"doc_type": None,
"doc_metadata": {"source": "manual"},
}
def test_dump_retrieval_records_returns_dumped_records_without_document_ids(self):
record = _retrieval_record({"segment": None, "score": 0.95})
assert HitTestingService._dump_retrieval_records([record]) == [{"segment": None, "score": 0.95}]
def test_dump_retrieval_records_injects_documents_and_keeps_non_segment_records(self):
record_without_segment = _retrieval_record({"segment": None, "score": 0.95})
record_with_document = _retrieval_record(
{
"segment": {
"id": "segment-1",
"document_id": "document-1",
},
"score": 0.9,
}
)
scalars_result = Mock()
scalars_result.all.return_value = [_dataset_document()]
with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result):
result = HitTestingService._dump_retrieval_records([record_without_segment, record_with_document])
assert result[0] == {"segment": None, "score": 0.95}
assert result[1]["segment"]["document"] == {
"id": "document-1",
"data_source_type": "upload_file",
"name": "guide.md",
"doc_type": None,
"doc_metadata": None,
}
def test_dump_retrieval_records_skips_records_with_missing_documents(self, caplog):
record = _retrieval_record(
{
"segment": {
"id": "segment-1",
"document_id": "missing-document",
},
"score": 0.95,
}
)
scalars_result = Mock()
scalars_result.all.return_value = []
with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result):
result = HitTestingService._dump_retrieval_records([record])
assert result == []
assert "Skipping hit-testing records with missing documents" in caplog.text
@@ -0,0 +1,46 @@
"""Unit tests for RecommendedAppService.get_recommend_app_detail null handling.
Regression tests for #36096: accessing result['id'] when the retrieval
returns None causes a TypeError / KeyError in self-hosted mode.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from services.recommended_app_service import RecommendedAppService
class TestGetRecommendAppDetailNullCheck:
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config", autospec=True)
def test_returns_none_when_retrieval_returns_none_and_trial_disabled(
self, mock_config, mock_factory_class, mock_feature_service
):
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_instance = MagicMock()
mock_instance.get_recommend_app_detail.return_value = None
mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)
mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False)
result = RecommendedAppService.get_recommend_app_detail("nonexistent")
assert result is None
@patch("services.recommended_app_service.FeatureService", autospec=True)
@patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True)
@patch("services.recommended_app_service.dify_config", autospec=True)
def test_returns_none_when_retrieval_returns_none_and_trial_enabled(
self, mock_config, mock_factory_class, mock_feature_service
):
"""Regression for #36096: must not crash when result is None and enable_trial_app is True."""
mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote"
mock_instance = MagicMock()
mock_instance.get_recommend_app_detail.return_value = None
mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance)
mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=True)
result = RecommendedAppService.get_recommend_app_detail("nonexistent")
assert result is None
mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent")
Generated
+143 -66
View File
@@ -382,14 +382,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.11"
version = "1.6.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
{ url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" },
]
[[package]]
@@ -470,18 +470,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" },
]
[[package]]
name = "basedpyright"
version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
]
[[package]]
name = "bce-python-sdk"
version = "0.9.71"
@@ -1290,6 +1278,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" },
]
[[package]]
name = "dify-agent"
version = "0.1.0"
source = { directory = "../dify-agent" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-ai-slim" },
{ name = "typing-extensions" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=5" },
{ name = "typing-extensions", specifier = ">=4.12.2" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" },
]
provides-extras = ["server"]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.39.3" },
{ name = "coverage", extras = ["toml"], specifier = ">=7.10.7" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-examples", specifier = ">=0.0.18" },
{ name = "pytest-mock", specifier = ">=3.14.0" },
{ name = "ruff", specifier = ">=0.15.11" },
]
docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2" },
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
{ name = "mkdocs-material", specifier = ">=9.7.0" },
{ name = "mkdocstrings-python", specifier = ">=2.0.0" },
]
[[package]]
name = "dify-api"
version = "1.14.1"
@@ -1301,6 +1332,7 @@ dependencies = [
{ name = "boto3" },
{ name = "celery" },
{ name = "croniter" },
{ name = "dify-agent" },
{ name = "fastopenapi", extra = ["flask"] },
{ name = "flask" },
{ name = "flask-compress" },
@@ -1338,7 +1370,6 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "boto3-stubs" },
{ name = "celery-types" },
{ name = "coverage" },
@@ -1584,6 +1615,7 @@ requires-dist = [
{ name = "boto3", specifier = ">=1.43.6" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
@@ -1621,7 +1653,6 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.39.3" },
{ name = "boto3-stubs", specifier = ">=1.43.2" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
@@ -1632,7 +1663,7 @@ dev = [
{ name = "lxml-stubs", specifier = ">=0.5.1" },
{ name = "mypy", specifier = ">=1.20.2" },
{ name = "pandas-stubs", specifier = ">=3.0.0" },
{ name = "pyrefly", specifier = ">=0.64.0" },
{ name = "pyrefly", specifier = ">=1.0.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-benchmark", specifier = ">=5.2.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@@ -2612,6 +2643,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
]
[[package]]
name = "genai-prices"
version = "0.0.59"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/c8/b61a028b8d8ee286ffab3f9b9f1c9229087184e7d543cea4e349e11375b0/genai_prices-0.0.59.tar.gz", hash = "sha256:3e1c7dcd9b38163589c8cf4a9bcfd286c52ea57a3becdc062a2cbaa8295b08c4", size = 67406, upload-time = "2026-05-07T12:08:40.475Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/f9/4693c127f9fab0a8d39c47c198e378ecafcb043463e6dd73df205eacbc13/genai_prices-0.0.59-py3-none-any.whl", hash = "sha256:88fd8818e6807374e5a5c03f293b574ade5f18a3060622080cdd94a03cf43115", size = 70509, upload-time = "2026-05-07T12:08:39.075Z" },
]
[[package]]
name = "gevent"
version = "26.4.0"
@@ -3003,6 +3047,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
]
[[package]]
name = "griffelib"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
]
[[package]]
name = "grimp"
version = "3.14"
@@ -3607,7 +3660,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.31"
version = "0.7.38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -3620,9 +3673,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c9/b3e54cfcb480876dfe33ecfdd64feeb621a86d9e6f4a6b9eb46851807018/langsmith-0.7.38.tar.gz", hash = "sha256:0db529b768d66c45f22fe959a0af7151342704fefafdecf3c60b14097c14fdb1", size = 4431914, upload-time = "2026-04-29T00:21:42.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bc/a19d0a6d5575c637796675831dbef3555568e84d913f14ec579f92162ffa/langsmith-0.7.38-py3-none-any.whl", hash = "sha256:9c400ad508c0e4edc37bd55987047c6b8aac36ddd55f6096e3806f4d6a100618", size = 392310, upload-time = "2026-04-29T00:21:40.534Z" },
]
[[package]]
@@ -3690,6 +3743,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" },
]
[[package]]
name = "logfire-api"
version = "4.32.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/1b/0c74ad85f977743ba4c589e46e0cb138d6a6e69487830f4e86ebbdb145a3/logfire_api-4.32.1.tar.gz", hash = "sha256:5e8714b2bb5fb5d1f4a4a833941e4ca711b75d2c1f98e76c5ad680fe6991af6a", size = 78788, upload-time = "2026-04-15T14:11:58.788Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ab/d5adeab6253c7ecd5904fc5ef3265859f218610caf4e1e55efe9aff6ac49/logfire_api-4.32.1-py3-none-any.whl", hash = "sha256:4b4c27cf6e27e8e26ef4b22a77f2a2988dd1d07e2d24ee70673ef34b234fb8a5", size = 124394, upload-time = "2026-04-15T14:11:56.157Z" },
]
[[package]]
name = "lxml"
version = "6.1.0"
@@ -4026,22 +4088,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/89/da307731fdbb05a5f640b26de5b8ac0dc463fef059162accfc89e32f73bc/nodejs_wheel_binaries-24.11.1.tar.gz", hash = "sha256:413dfffeadfb91edb4d8256545dea797c237bba9b3faefea973cde92d96bb922", size = 8059, upload-time = "2025-11-18T18:21:58.207Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/5f/be5a4112e678143d4c15264d918f9a2dc086905c6426eb44515cf391a958/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:0e14874c3579def458245cdbc3239e37610702b0aa0975c1dc55e2cb80e42102", size = 55114309, upload-time = "2025-11-18T18:21:21.697Z" },
{ url = "https://files.pythonhosted.org/packages/fa/1c/2e9d6af2ea32b65928c42b3e5baa7a306870711d93c3536cb25fc090a80d/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:c2741525c9874b69b3e5a6d6c9179a6fe484ea0c3d5e7b7c01121c8e5d78b7e2", size = 55285957, upload-time = "2025-11-18T18:21:27.177Z" },
{ url = "https://files.pythonhosted.org/packages/d0/79/35696d7ba41b1bd35ef8682f13d46ba38c826c59e58b86b267458eb53d87/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5ef598101b0fb1c2bf643abb76dfbf6f76f1686198ed17ae46009049ee83c546", size = 59645875, upload-time = "2025-11-18T18:21:33.004Z" },
{ url = "https://files.pythonhosted.org/packages/b4/98/2a9694adee0af72bc602a046b0632a0c89e26586090c558b1c9199b187cc/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cde41d5e4705266688a8d8071debf4f8a6fcea264c61292782672ee75a6905f9", size = 60140941, upload-time = "2025-11-18T18:21:37.228Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d6/573e5e2cba9d934f5f89d0beab00c3315e2e6604eb4df0fcd1d80c5a07a8/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:78bc5bb889313b565df8969bb7423849a9c7fc218bf735ff0ce176b56b3e96f0", size = 61644243, upload-time = "2025-11-18T18:21:43.325Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e6/643234d5e94067df8ce8d7bba10f3804106668f7a1050aeb10fdd226ead4/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c79a7e43869ccecab1cae8183778249cceb14ca2de67b5650b223385682c6239", size = 62225657, upload-time = "2025-11-18T18:21:47.708Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/2fb05127102a80225cab7a75c0e9edf88a0a1b79f912e1e36c7c1aaa8f4e/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_amd64.whl", hash = "sha256:10197b1c9c04d79403501766f76508b0dac101ab34371ef8a46fcf51773497d0", size = 41322308, upload-time = "2025-11-18T18:21:51.347Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b7/bc0cdbc2cc3a66fcac82c79912e135a0110b37b790a14c477f18e18d90cd/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_arm64.whl", hash = "sha256:376b9ea1c4bc1207878975dfeb604f7aa5668c260c6154dcd2af9d42f7734116", size = 39026497, upload-time = "2025-11-18T18:21:54.634Z" },
]
[[package]]
name = "numba"
version = "0.65.0"
@@ -5099,6 +5145,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-ai-slim"
version = "1.94.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "genai-prices" },
{ name = "griffelib" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pydantic-graph" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/0b/ce4992e0e29ba81ba48d5bba955c53b72e2cda3636f9b6417386ae7e45f7/pydantic_ai_slim-1.94.0.tar.gz", hash = "sha256:7d7b1d6aec4d0fd31533a4ef5848863e8513ec75e82910296247a08b737aa828", size = 640338, upload-time = "2026-05-12T07:03:55.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/dd/b104641c2af7044a788b1071159679aa755e5c9281f110fdc54b4729117b/pydantic_ai_slim-1.94.0-py3-none-any.whl", hash = "sha256:f47cf89c61ef45a48dd575a8b32707edfec2b33ef7af80aa069bde1ce3fb6795", size = 805546, upload-time = "2026-05-12T07:03:46.535Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
@@ -5141,6 +5205,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
]
[[package]]
name = "pydantic-graph"
version = "1.94.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "logfire-api" },
{ name = "pydantic" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/80/c41aa5ccf7104eba172ff45e617967b0075b3fa34ab76cd3795d7d62334a/pydantic_graph-1.94.0.tar.gz", hash = "sha256:8f991c05d412c9d12d6560c1e131de48bfde12ebd27a0b196440620210f2d52c", size = 59252, upload-time = "2026-05-12T07:03:58.26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/7e/edcc6177d89174024bca1fb85044bec4855b96f4b724a310638283dfc8c5/pydantic_graph-1.94.0-py3-none-any.whl", hash = "sha256:c6e285abbc8a55d1b65162c238006913edd1ef05e63a29401a580e51f798503e", size = 73063, upload-time = "2026-05-12T07:03:50.651Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
@@ -5362,19 +5441,19 @@ wheels = [
[[package]]
name = "pyrefly"
version = "0.64.0"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/99/923622d7b52ef84e83f357b19bd08dff063ccc5f4472b003105e1f308d93/pyrefly-0.64.0.tar.gz", hash = "sha256:fbfcdb0031adadc340b6c64cb41c6094c95349ee952fe3d4c143866add829172", size = 5678516, upload-time = "2026-05-06T17:28:44.056Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/3a/9045b0097ac58979c7c30a4fa0e673db942d4adbc7b6d439bd54ae58c441/pyrefly-1.0.0.tar.gz", hash = "sha256:5c2b810ffcebd84be71de5df1223651edee951653a66935c6f091e957c452455", size = 5677995, upload-time = "2026-05-12T20:12:46.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/1c/b001b7e84a811dbb3c85e31bd4bfc3edfa3c94438140cd1d6e8c06b7c1df/pyrefly-0.64.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:683b317d8d0e815fb2ad75b7e0fa6c15eed5be4bcbc407dc13312984da3a9c47", size = 13287462, upload-time = "2026-05-06T17:28:19.169Z" },
{ url = "https://files.pythonhosted.org/packages/89/02/1e6fcd311bd7c24aaccc0afb998d584e1fa6c370e1428b4b091103760efe/pyrefly-0.64.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:96913cc4f066a7bd008b9dba8e3951234e92bb8a3a2cb1aea0e274fd2a444c55", size = 12777104, upload-time = "2026-05-06T17:28:22.047Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2b/3f347b8d97c9065d6ace14a22591c8d91e64610e74e0d4f214b3025ebcf7/pyrefly-0.64.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2ae557e1b6a6a5bda844806cae10b212cf84ea786ece10d55083a0321ee1705", size = 37064924, upload-time = "2026-05-06T17:28:24.743Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/0b40175e930a96139a8e9f62a8e1db7f9a5e9df8e6cef08bf280affcb05e/pyrefly-0.64.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d062ac1744346efacd7df23c6bbff662ad29ed495923cb59ede656a306355655", size = 39719832, upload-time = "2026-05-06T17:28:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/0afb4ad02eb67ddb299ff3f7108ceb307e520578b00e900d07f2371423ca/pyrefly-0.64.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6850b305d45121911fbe25ad56497d2e887b387ea50644ba15a8ad2a8cf855f4", size = 37861666, upload-time = "2026-05-06T17:28:31.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1b/f5390f8678433708288afab13f043ddd021a55dba3f665360d2c9396ee04/pyrefly-0.64.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a259925620a84fe87cd30a82643ec524eeef631f0c4ec5af81a21e006c2f5b1", size = 42634235, upload-time = "2026-05-06T17:28:34.405Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/4b66934e375dde3e4d75373b1a94eb7e7c0c0c788e94267641a223930180/pyrefly-0.64.0-py3-none-win32.whl", hash = "sha256:20317f6dd97e22bc508b8dbc537e59b0ab58e384113ee61920c87ed1a6a12f62", size = 12213388, upload-time = "2026-05-06T17:28:37.146Z" },
{ url = "https://files.pythonhosted.org/packages/0a/15/653523d99795041a1be6dadf7a73225317cb2aae4b21e6df57edbce807f0/pyrefly-0.64.0-py3-none-win_amd64.whl", hash = "sha256:e88fc6a83add9b7c2224be0f74df1b0db10b3af856ae30e4e0a90ba3644c712f", size = 13136719, upload-time = "2026-05-06T17:28:39.767Z" },
{ url = "https://files.pythonhosted.org/packages/50/bb/9ea1c26b511b38a3e1eefc1bd3de7d3f65b2bbfdb59295f3244f61564a81/pyrefly-0.64.0-py3-none-win_arm64.whl", hash = "sha256:73744bd95e836abda0d08e9cdcf008142090ae0124c8f8ff477c944b60c0343c", size = 12526050, upload-time = "2026-05-06T17:28:42.077Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c6/90788819bac9c61dd7bacba53b79f3c12d47ccbe5e51b3d6d89f2387e1d2/pyrefly-1.0.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e355a0908555348ed4b9585ef25c76ff566673e345c866c325f1633f44d890b6", size = 13122950, upload-time = "2026-05-12T20:12:20.711Z" },
{ url = "https://files.pythonhosted.org/packages/82/91/a3cf2a1e87d336eaa804a1e6fc93266faf6dc2a97eecdbc7eae289628022/pyrefly-1.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7038efc3a40f8294edee339895633cf22db268c0d434cdbcbefc34f78a9ecc3", size = 12599494, upload-time = "2026-05-12T20:12:23.495Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ab/74d1e11e737e99b1c003ecc5d7d2e846c4ea1f328966bfdbbd0ac63fad0a/pyrefly-1.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da331ca515ed1c08791da2b5f664cf9c1294c48fd802133262e7d5d51e0f4416", size = 12995507, upload-time = "2026-05-12T20:12:25.951Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ac/2df0899f8464c97e5d995f994c97c5cb5b0f58610432aa90d26d924e1db5/pyrefly-1.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c74219d8f3e63cdaa5501a0b21d1c9d37011820f9606728d0ed06f09ae86a878", size = 13947693, upload-time = "2026-05-12T20:12:29.188Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3e/b247c24321e36f04b7d51f9ccf3df93e5009e4b29939524b36ec2e17dc2a/pyrefly-1.0.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0d05543b1bb6ee6d64149eb5d6b2fb15aa72d3962d6a97abca0afaca8b0c131", size = 13925803, upload-time = "2026-05-12T20:12:31.904Z" },
{ url = "https://files.pythonhosted.org/packages/61/16/cfa2d61a4aa1e1f7bca48bb37acd01c6a09db4864b16a54f9587092765ff/pyrefly-1.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1382d5b1fcdb49a4de9f34d112d2bddf290a78ff93ee8149492ad5f1077ddffc", size = 13470398, upload-time = "2026-05-12T20:12:35.302Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2b/6372c7dddb326223e24a46b17efd0d4bd7b4fe22c821e523157577eed2d2/pyrefly-1.0.0-py3-none-win32.whl", hash = "sha256:aa8b5d0e47080e3202a2547b39f7a5a61d2c781c712b3b67884f745ca2c759d2", size = 12222643, upload-time = "2026-05-12T20:12:38.618Z" },
{ url = "https://files.pythonhosted.org/packages/be/ad/1d23be700b6b2ddaeb362360c7145917a8edbbf7240ae428d40541772fce/pyrefly-1.0.0-py3-none-win_amd64.whl", hash = "sha256:c8abcb0f2082e83c890375128f9cff4aa4d3f210b85eea7b3046c1ae764e77f5", size = 13146369, upload-time = "2026-05-12T20:12:41.423Z" },
{ url = "https://files.pythonhosted.org/packages/8c/38/16589134f3012fd097a10dcc85771555f1a5fb76e04b682597180743af30/pyrefly-1.0.0-py3-none-win_arm64.whl", hash = "sha256:d150fa9e40e8392832be81c3bcfc0497c146674ce4d0f8e04e1ec29e775ffb8c", size = 12538326, upload-time = "2026-05-12T20:12:43.996Z" },
]
[[package]]
@@ -7033,26 +7112,24 @@ wheels = [
[[package]]
name = "ujson"
version = "5.12.0"
version = "5.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538, upload-time = "2026-05-05T22:05:01.354Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" },
{ url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" },
{ url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" },
{ url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" },
{ url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" },
{ url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f0/123ffaac17e45ef2b915e3e3303f8f4ea78bb8d42afad828844e08622b1e/ujson-5.12.0-cp312-cp312-win32.whl", hash = "sha256:2a248750abce1c76fbd11b2e1d88b95401e72819295c3b851ec73399d6849b3d", size = 39773, upload-time = "2026-03-11T22:18:28.244Z" },
{ url = "https://files.pythonhosted.org/packages/b5/20/f3bd2b069c242c2b22a69e033bfe224d1d15d3649e6cd7cc7085bb1412ff/ujson-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:1b5c6ceb65fecd28a1d20d1eba9dbfa992612b86594e4b6d47bb580d2dd6bcb3", size = 44040, upload-time = "2026-03-11T22:18:29.236Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/01b5a0bcded14cd2522b218f2edc3533b0fcbccdea01f3e14a2b699071aa/ujson-5.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:9a5fcbe7b949f2e95c47ea8a80b410fcdf2da61c98553b45a4ee875580418b68", size = 38526, upload-time = "2026-03-11T22:18:30.551Z" },
{ url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" },
{ url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/1df8e6217c92e57a1266bf5be750b1dddc126ee96e53fe959d5693503bc6/ujson-5.12.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:8712b61eb1b74a4478cfd1c54f576056199e9f093659334aeb5c4a6b385338e5", size = 44615, upload-time = "2026-03-11T22:19:17.53Z" },
{ url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951, upload-time = "2026-05-05T22:03:31.606Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562, upload-time = "2026-05-05T22:03:32.853Z" },
{ url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448, upload-time = "2026-05-05T22:03:34.143Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608, upload-time = "2026-05-05T22:03:35.386Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113, upload-time = "2026-05-05T22:03:37.555Z" },
{ url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914, upload-time = "2026-05-05T22:03:38.954Z" },
{ url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408, upload-time = "2026-05-05T22:03:40.943Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451, upload-time = "2026-05-05T22:03:42.739Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049, upload-time = "2026-05-05T22:03:44.341Z" },
{ url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330, upload-time = "2026-05-05T22:03:45.828Z" },
{ url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828, upload-time = "2026-05-05T22:03:47.123Z" },
{ url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153, upload-time = "2026-05-05T22:04:40.326Z" },
{ url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352, upload-time = "2026-05-05T22:04:42.005Z" },
{ url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988, upload-time = "2026-05-05T22:04:43.36Z" },
]
[[package]]
-21
View File
@@ -1,21 +0,0 @@
#!/bin/bash
set -x
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
cd "$SCRIPT_DIR/.."
# Get the path argument if provided
PATH_TO_CHECK="$1"
# Determine CPU core count based on OS
CPU_CORES=$(
if [[ "$(uname -s)" == "Darwin" ]]; then
sysctl -n hw.ncpu 2>/dev/null
else
nproc
fi
)
# Run basedpyright checks
uv run --directory api --dev -- basedpyright --threads "$CPU_CORES" $PATH_TO_CHECK
+10 -1
View File
@@ -8,6 +8,15 @@ cd "$REPO_ROOT"
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
target_paths=()
for target_path in "$@"; do
if [[ "$target_path" == api/* ]]; then
target_paths+=("${target_path#api/}")
else
target_paths+=("$target_path")
fi
done
pyrefly_args=(
"--summary=none"
"--use-ignore-files=false"
@@ -26,7 +35,7 @@ fi
tmp_output="$(mktemp)"
set +e
uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" >"$tmp_output" 2>&1
uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" "${target_paths[@]}" >"$tmp_output" 2>&1
pyrefly_status=$?
set -e
+2 -2
View File
@@ -17,5 +17,5 @@ uv run --directory api --dev ruff format ./
# run dotenv-linter linter
uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
# run basedpyright check
dev/basedpyright-check
# run pyrefly check
dev/pyrefly-check-local
+37
View File
@@ -0,0 +1,37 @@
# Dify Agent run server settings template.
# Mirrors dify_agent.server.settings.ServerSettings with the DIFY_AGENT_ prefix.
# This template intentionally covers the current run-server settings only.
# Redis
# Redis connection URL for run records and per-run event streams.
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
# Prefix for Redis run-record and event-stream keys.
DIFY_AGENT_REDIS_PREFIX=dify-agent
# Shutdown and retention
# Seconds to wait for active local runs during graceful shutdown before cancellation.
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
# Seconds to retain Redis run records and per-run event streams (default: 3 days).
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
# Plugin daemon
# Base URL for the Dify plugin daemon used by local runs.
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
# API key sent to the Dify plugin daemon.
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
# Shared plugin-daemon HTTP client timeouts and limits.
# Plugin-daemon HTTP connect timeout in seconds.
DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10
# Plugin-daemon HTTP read timeout in seconds.
DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT=600
# Plugin-daemon HTTP write timeout in seconds.
DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT=30
# Plugin-daemon HTTP connection-pool wait timeout in seconds.
DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT=10
# Maximum total plugin-daemon HTTP connections.
DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS=100
# Maximum idle keep-alive plugin-daemon HTTP connections.
DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS=20
# Keep-alive expiry in seconds for idle plugin-daemon HTTP connections.
DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY=30
+1
View File
@@ -0,0 +1 @@
dify-aio
+184
View File
@@ -0,0 +1,184 @@
# Agent Guide
## Notes for Agent (must-check)
Before changing any source code under this folder, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
Look for:
- The module (file) docstring at the top of a source code file
- Docstrings on classes and functions/methods
- Paragraph/block comments for non-obvious logic
### What to write where
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
- If the class is intentionally stateful, note what state exists and what methods mutate it.
- If concurrency/async assumptions matter, state them explicitly.
- **Function/method docstring**: behavioural contract.
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
- Add examples only when they prevent misuse.
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
### Rules (must follow)
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
- **Before working**
- Read the notes in the area youll touch; treat them as part of the spec.
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
- **During working**
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
- **When finishing**
- Update the notes to reflect what changed, why, and any new edge cases/tests.
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
## Coding Style
This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes.
### Linting & Formatting
- Use Ruff for formatting and linting (follow `.ruff.toml`).
- Keep each line under 120 characters (including spaces).
### Naming Conventions
- Use `snake_case` for variables and functions.
- Use `PascalCase` for classes.
- Use `UPPER_CASE` for constants.
### Typing & Class Layout
- Code should usually include type annotations that match the repos current Python version (avoid untyped public APIs and “mystery” values).
- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless theres a strong reason.
- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`.
- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional).
- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown.
```python
from datetime import datetime
from typing import NotRequired, TypedDict
class UserProfile(TypedDict):
user_id: str
email: str
created_at: datetime
nickname: NotRequired[str]
```
- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance:
```python
from datetime import datetime
class Example:
user_id: str
created_at: datetime
def __init__(self, user_id: str, created_at: datetime) -> None:
self.user_id = user_id
self.created_at = created_at
```
- For dataclasses, prefer `field(default_factory=...)` over `field(init=False)` when a default can be provided declaratively.
- Prefer dataclasses with `slots=True` when defining lightweight data containers:
```python
from dataclasses import dataclass
from datetime import datetime
@dataclass(slots=True)
class Example:
user_id: str
created_at: datetime
```
### General Rules
- Use Pydantic v2 conventions.
- Use `uv` for Python package management in this repo (usually with `--project dify-agent`).
- Use `make typecheck` to run `basedpyright` against `dify-agent/src` and `dify-agent/tests`.
- Keep type checking passing after every edit you make.
- Use `pytest` for all tests in this package.
- When integrating with, implementing, or mocking a dependency, inspect the dependency's source code to confirm its API shape and runtime behavior instead of guessing from names alone.
- Prefer simple functions over small “utility classes” for lightweight helpers.
- Avoid implementing dunder methods unless its clearly needed and matches existing patterns.
- Keep code readable and explicit—avoid clever hacks.
### Testing
- Work in TDD style: write or update a failing test first when changing behavior, then make the implementation pass, then refactor while keeping tests and typecheck green.
- Use `make test` to run the agent pytest suite.
- Keep local tests under `dify-agent/tests/local/`.
- Mirror the `dify-agent/src/` package structure inside `dify-agent/tests/local/` so test locations stay predictable.
#### Local Tests
- Write local tests for stable, externally observable behavior that can run quickly without real external services.
- In this repo, code, comments, docs, and tests are expected to change together. Because of that, a local test is only useful if it would still be correct after an internal refactor that does not change the intended contract.
- Local tests should verify:
- what callers and downstream code can observe and rely on
- how the unit is expected to use its dependencies at the boundary
- how the unit handles dependency success, failure, empty responses, malformed responses, and documented error cases
- documented invariants, error mapping, and output/input shape guarantees
- When asserting dependency interactions, assert only the parts of the request or response that are part of the real boundary contract. Do not over-specify incidental details that callers or dependencies do not rely on.
- It is acceptable to mock dependencies in local tests, but only when the mock represents a real contract, schema, documented behavior, or known regression.
- Tests may use line-scoped type-ignore comments when intentionally exercising runtime validation paths that static typing would normally reject. Keep the ignore on the exact invalid call.
- Do not use local tests to prove real integration, network wiring, serialization, framework configuration, or third-party runtime behavior; cover those in higher-level tests.
- Meaningless local tests include:
- tests that only mirror the current implementation or must be updated whenever internal code changes even though the contract did not change
- tests of private helpers, local variables, temporary state, internal branching, or exact internal call order unless those details are part of the published contract
- tests with mocked dependency behavior that is invented only to make the current implementation pass
- tests that add no value beyond static type checking or linting
### Logging & Errors
- Never use `print`; use a module-level logger:
- `logger = logging.getLogger(__name__)`
- Include tenant/app/workflow identifiers in log context when relevant.
- Raise domain-specific exceptions and translate them into HTTP responses in controllers.
- Log retryable events at `warning`, terminal failures at `error`.
### Pydantic Usage
- Define DTOs with Pydantic v2 models and forbid extras by default.
- Use `@field_validator` / `@model_validator` for domain rules.
Example:
```python
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
class TriggerConfig(BaseModel):
endpoint: HttpUrl
secret: str
model_config = ConfigDict(extra="forbid")
@field_validator("secret")
def ensure_secret_prefix(cls, value: str) -> str:
if not value.startswith("dify_"):
raise ValueError("secret must start with dify_")
return value
```
### Generics & Protocols
- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces).
- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers.
- Validate dynamic inputs at runtime when generics cannot enforce safety alone.
+45
View File
@@ -0,0 +1,45 @@
.DEFAULT_GOAL := help
PROJECT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
.PHONY: help serve dev check fix typecheck test update-examples docs docs-serve
help:
@echo "Dify agent targets:"
@echo " make serve - Run the Dify Agent server"
@echo " make dev - Run the Dify Agent server with reload"
@echo " make check - Run Ruff for dify-agent"
@echo " make fix - Format and fix Ruff issues"
@echo " make typecheck - Run basedpyright for src, examples, and tests"
@echo " make test - Run local tests and docs/example tests"
@echo " make update-examples - Rewrite docs example outputs when needed"
@echo " make docs - Build MkDocs documentation"
@echo " make docs-serve - Serve MkDocs documentation locally"
serve:
@uv --directory "$(PROJECT_DIR)" run --project . --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 8000
dev:
@uv --directory "$(PROJECT_DIR)" run --project . --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 8000 --reload
check:
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check .
fix:
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff format .
@uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check --fix .
typecheck:
@uv --directory "$(PROJECT_DIR)" run --project . basedpyright --level error src examples tests
test:
@uv --directory "$(PROJECT_DIR)" run --project . python -m pytest tests
update-examples:
@uv --directory "$(PROJECT_DIR)" run --project . python -m pytest --update-examples tests/docs/test_examples.py
docs:
@uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs build --no-strict
docs-serve:
@uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs serve --no-strict
+7
View File
@@ -0,0 +1,7 @@
# Dify Agent
Agenton documentation lives in [`docs/agenton/guide/index.md`](docs/agenton/guide/index.md) and
[`docs/agenton/api/index.md`](docs/agenton/api/index.md).
Dify Agent runtime documentation lives in [`docs/dify-agent/index.md`](docs/dify-agent/index.md).
Build all docs with `make docs` from this directory.
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from pathlib import Path
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.files import Files
from mkdocs.structure.pages import Page
from snippets import inject_snippets
DOCS_ROOT = Path(__file__).resolve().parent.parent
def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str:
"""Inject repository snippets before MkDocs renders Markdown."""
relative_path = DOCS_ROOT / page.file.src_uri
return inject_snippets(markdown, relative_path.parent)
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SNIPPET_DIRECTIVE_PATTERN = re.compile(r"^```snippet\s+\{[^}]+\}\s*(?:```|\n```)$", re.MULTILINE)
@dataclass(frozen=True, slots=True)
class SnippetDirective:
path: str
title: str | None = None
fragment: str | None = None
highlight: str | None = None
extra_attrs: dict[str, str] | None = None
@dataclass(frozen=True, slots=True)
class LineRange:
start_line: int
end_line: int
def intersection(self, ranges: list[LineRange]) -> list[LineRange]:
intersections: list[LineRange] = []
for line_range in ranges:
start_line = max(self.start_line, line_range.start_line)
end_line = min(self.end_line, line_range.end_line)
if start_line < end_line:
intersections.append(LineRange(start_line, end_line))
return intersections
@staticmethod
def merge(ranges: list[LineRange]) -> list[LineRange]:
if not ranges:
return []
merged: list[LineRange] = []
for line_range in sorted(ranges, key=lambda item: item.start_line):
if not merged or merged[-1].end_line < line_range.start_line:
merged.append(line_range)
else:
previous = merged[-1]
merged[-1] = LineRange(previous.start_line, max(previous.end_line, line_range.end_line))
return merged
@dataclass(frozen=True, slots=True)
class RenderedSnippet:
content: str
highlights: list[LineRange]
original_range: LineRange
@dataclass(frozen=True, slots=True)
class ParsedFile:
lines: list[str]
sections: dict[str, list[LineRange]]
lines_mapping: dict[int, int]
def render(self, fragment_sections: list[str], highlight_sections: list[str]) -> RenderedSnippet:
fragment_ranges: list[LineRange] = []
if fragment_sections:
for section_name in fragment_sections:
fragment_ranges.extend(_section_ranges(self.sections, section_name))
fragment_ranges = LineRange.merge(fragment_ranges)
else:
fragment_ranges = [LineRange(0, len(self.lines))]
highlight_ranges: list[LineRange] = []
for section_name in highlight_sections:
highlight_ranges.extend(_section_ranges(self.sections, section_name))
highlight_ranges = LineRange.merge(highlight_ranges)
rendered_highlights: list[LineRange] = []
rendered_lines: list[str] = []
last_end_line = 0
current_line = 0
for fragment_range in fragment_ranges:
if fragment_range.start_line > last_end_line:
rendered_lines.append("..." if current_line == 0 else "\n...")
current_line += 1
for highlight_range in fragment_range.intersection(highlight_ranges):
rendered_highlights.append(
LineRange(
highlight_range.start_line - fragment_range.start_line + current_line,
highlight_range.end_line - fragment_range.start_line + current_line,
)
)
for line_number in range(fragment_range.start_line, fragment_range.end_line):
rendered_lines.append(self.lines[line_number])
current_line += 1
last_end_line = fragment_range.end_line
if last_end_line < len(self.lines):
rendered_lines.append("\n...")
return RenderedSnippet(
content="\n".join(rendered_lines),
highlights=LineRange.merge(rendered_highlights),
original_range=LineRange(
self.lines_mapping[fragment_ranges[0].start_line],
self.lines_mapping[fragment_ranges[-1].end_line - 1] + 1,
),
)
def parse_snippet_directive(line: str) -> SnippetDirective | None:
match = re.fullmatch(r"```snippet\s+\{([^}]+)\}\s*(?:```|\n```)", line.strip())
if not match:
return None
attrs = {key: value for key, value in re.findall(r'(\w+)="([^"]*)"', match.group(1))}
if "path" not in attrs:
raise ValueError('Missing required key "path" in snippet directive')
extra_attrs = {key: value for key, value in attrs.items() if key not in {"path", "title", "fragment", "highlight"}}
return SnippetDirective(
path=attrs["path"],
title=attrs.get("title"),
fragment=attrs.get("fragment"),
highlight=attrs.get("highlight"),
extra_attrs=extra_attrs or None,
)
def parse_file_sections(file_path: Path) -> ParsedFile:
input_lines = file_path.read_text(encoding="utf-8").splitlines()
output_lines: list[str] = []
lines_mapping: dict[int, int] = {}
sections: dict[str, list[LineRange]] = {}
section_starts: dict[str, int] = {}
output_line_number = 0
for source_line_number, line in enumerate(input_lines):
section_match = re.search(r"\s*(?:###|///)\s*\[([^]]+)]\s*$", line)
if section_match is None:
output_lines.append(line)
lines_mapping[output_line_number] = source_line_number
output_line_number += 1
continue
line_before_marker = line[: section_match.start()]
for section_name in section_match.group(1).split(","):
section_name = section_name.strip()
if section_name.startswith("/"):
start_line = section_starts.pop(section_name[1:], None)
if start_line is None:
raise ValueError(f"Cannot end unstarted section {section_name!r} at {file_path}")
end_line = output_line_number + 1 if line_before_marker else output_line_number
sections.setdefault(section_name[1:], []).append(LineRange(start_line, end_line))
else:
if section_name in section_starts:
raise ValueError(f"Cannot nest section {section_name!r} at {file_path}")
section_starts[section_name] = output_line_number
if line_before_marker:
output_lines.append(line_before_marker)
lines_mapping[output_line_number] = source_line_number
output_line_number += 1
if section_starts:
raise ValueError(f"Some sections were not finished in {file_path}: {list(section_starts)}")
return ParsedFile(lines=output_lines, sections=sections, lines_mapping=lines_mapping)
def format_highlight_lines(highlight_ranges: list[LineRange]) -> str:
parts: list[str] = []
for highlight_range in highlight_ranges:
start_line = highlight_range.start_line + 1
end_line = highlight_range.end_line
parts.append(str(start_line) if start_line == end_line else f"{start_line}-{end_line}")
return " ".join(parts)
def inject_snippets(markdown: str, relative_path_root: Path) -> str:
def replace_snippet(match: re.Match[str]) -> str:
directive = parse_snippet_directive(match.group(0))
if directive is None:
return match.group(0)
file_path = _resolve_snippet_path(directive.path, relative_path_root)
parsed_file = parse_file_sections(file_path)
rendered = parsed_file.render(
directive.fragment.split() if directive.fragment else [],
directive.highlight.split() if directive.highlight else [],
)
attrs: list[str] = []
title = directive.title or _default_title(file_path, rendered.original_range, bool(directive.fragment))
if title:
attrs.append(f'title="{title}"')
if rendered.highlights:
attrs.append(f'hl_lines="{format_highlight_lines(rendered.highlights)}"')
if directive.extra_attrs:
attrs.extend(f'{key}="{value}"' for key, value in directive.extra_attrs.items())
attrs_text = f" {{{' '.join(attrs)}}}" if attrs else ""
file_extension = file_path.suffix.lstrip(".") or "text"
return f"```{file_extension}{attrs_text}\n{rendered.content}\n```"
return SNIPPET_DIRECTIVE_PATTERN.sub(replace_snippet, markdown)
def _section_ranges(sections: dict[str, list[LineRange]], section_name: str) -> list[LineRange]:
if section_name not in sections:
raise ValueError(f"Unrecognized snippet section {section_name!r}; expected one of {list(sections)}")
return sections[section_name]
def _resolve_snippet_path(path: str, relative_path_root: Path) -> Path:
file_path = (REPO_ROOT / path[1:]).resolve() if path.startswith("/") else (relative_path_root / path).resolve()
if not file_path.exists():
raise FileNotFoundError(f"Snippet file {file_path} not found")
if not file_path.is_relative_to(REPO_ROOT):
raise ValueError(f"Snippet file {file_path} must be inside {REPO_ROOT}")
return file_path
def _default_title(file_path: Path, original_range: LineRange, has_fragment: bool) -> str:
relative_path = file_path.relative_to(REPO_ROOT)
if not has_fragment:
return str(relative_path)
return f"{relative_path} (L{original_range.start_line + 1}-L{original_range.end_line})"
+19
View File
@@ -0,0 +1,19 @@
# Agenton examples
The Agenton examples live under `examples/agenton/agenton_examples` and are kept
importable as a package so documentation can reference real source files.
## Basics
```snippet {path="/examples/agenton/agenton_examples/basics.py"}
```
## Pydantic AI bridge
```snippet {path="/examples/agenton/agenton_examples/pydantic_ai_bridge.py"}
```
## Session snapshots
```snippet {path="/examples/agenton/agenton_examples/session_snapshot.py"}
```
+187
View File
@@ -0,0 +1,187 @@
# Agenton user guide
Agenton composes reusable graph plans from `LayerNode`s and `LayerProvider`s.
The core is state-only: a `Compositor` stores no live layer instances, clients,
cleanup stacks, or run state. Each `Compositor.enter(...)` call creates a fresh
`CompositorRun` with new layer instances, direct dependency bindings, lifecycle
state, and an optional hydrated session snapshot.
## Config and runtime state
- **Graph config** is serializable topology: node `name`, provider `type`,
dependency mappings, and metadata. `LayerNodeConfig` deliberately contains no
layer config.
- **Per-run layer config** is passed to `Compositor.enter(configs=...)` as a
mapping keyed by node name. Providers validate each value with the layer's
`config_type` before any factory runs.
- **Runtime state** is serializable per-layer invocation state on
`layer.runtime_state`. Session snapshots persist only lifecycle state and this
model's JSON-safe data.
- **Live Python resources** such as clients, files, sockets, or process handles
stay outside Agenton core. Own them in application code or integration-specific
context managers that wrap compositor entry.
## Define a config-backed layer
Use a `LayerConfig` model for per-run config and inherit from a typed layer family
so `Layer.__init_subclass__` can infer schemas:
```python {test="skip" lint="skip"}
from dataclasses import dataclass
from pydantic import ConfigDict
from typing_extensions import Self, override
from agenton.layers import LayerConfig, NoLayerDeps, PlainLayer
class GreetingConfig(LayerConfig):
prefix: str
model_config = ConfigDict(extra="forbid")
@dataclass(slots=True)
class GreetingLayer(PlainLayer[NoLayerDeps, GreetingConfig]):
type_id = "example.greeting"
prefix: str
@classmethod
@override
def from_config(cls, config: GreetingConfig) -> Self:
return cls(prefix=config.prefix)
@property
@override
def prefix_prompts(self) -> list[str]:
return [self.prefix]
```
Omitted schema slots default to `EmptyLayerConfig` and `EmptyRuntimeState`.
Lifecycle hooks are no-argument methods on the layer instance; use `self.deps`
for dependencies and `self.runtime_state` for serializable mutable state.
## Live resources
Agenton does not own resource cleanup. Keep live resources in the surrounding
application and pass them to capability methods explicitly:
```python {test="skip" lint="skip"}
@dataclass(slots=True)
class ClientUserLayer(PlainLayer[NoLayerDeps]):
def make_client_user(self, *, http_client: httpx.AsyncClient) -> ClientUser:
return ClientUser(http_client)
compositor = Compositor([LayerNode("client_user", ClientUserLayer)])
async with httpx.AsyncClient() as http_client:
async with compositor.enter() as run:
layer = run.get_layer("client_user", ClientUserLayer)
user = layer.make_client_user(http_client=http_client)
```
This keeps deterministic cleanup at the integration boundary and leaves Agenton
snapshots limited to serializable runtime state.
## Build a compositor
Use providers for config-backed layers and pass per-run config at entry time:
```python {test="skip" lint="skip"}
from agenton.compositor import Compositor, CompositorConfig, LayerNodeConfig, LayerProvider
from agenton_collections.layers.plain import PromptLayer, PromptLayerConfig
providers = (
LayerProvider.from_layer_type(PromptLayer),
LayerProvider.from_layer_type(GreetingLayer),
)
compositor = Compositor.from_config(
CompositorConfig(
layers=[
LayerNodeConfig(name="prompt", type="plain.prompt"),
LayerNodeConfig(name="greeting", type="example.greeting"),
]
),
providers=providers,
)
async with compositor.enter(
configs={
"prompt": PromptLayerConfig(user="Answer with examples."),
"greeting": GreetingConfig(prefix="Hi"),
}
) as run:
prompts = run.prompts
```
Use `LayerProvider.from_factory(...)` when construction needs Python objects or
callables. Provider factories receive only validated config and must return a
fresh layer instance for every invocation. For node-specific construction with
`Compositor.from_config`, pass a `node_providers={"node_name": provider}` mapping
to override the provider selected by type id for that node.
## Dependencies
Layer dependencies bind direct layer instances onto `self.deps` for one run.
Dependency mappings use dependency field names as keys and compositor node names
as values:
```python {test="skip" lint="skip"}
class ModelDeps(LayerDeps):
plugin: PluginLayer
@dataclass(slots=True)
class ModelLayer(PlainLayer[ModelDeps]):
def make_model(self) -> Model:
return self.deps.plugin.make_provider()
```
Optional dependencies are assigned `None` when absent. Missing required
dependencies, unknown dependency keys, and dependency targets with the wrong layer
type fail before lifecycle hooks run.
## System prompts, user prompts, and tools
Layers expose four authoring surfaces:
- `prefix_prompts`: system prompt fragments collected in layer order.
- `suffix_prompts`: system prompt fragments collected in reverse layer order.
- `user_prompts`: user-message fragments collected in layer order.
- `tools`: tool entries collected in layer order.
`PromptLayer` accepts `prefix`, `user`, and `suffix` config fields. Aggregation is
available on the active `CompositorRun` as `run.prompts`, `run.user_prompts`, and
`run.tools`. For pydantic-ai, import
`agenton_collections.transformers.pydantic_ai.PYDANTIC_AI_TRANSFORMERS` and pass
it to `Compositor(...)` or `Compositor.from_config(...)` so tagged layer items are
converted to Pydantic AI prompt, user prompt, and tool values.
## Session snapshot and restore
Core Agenton run slots default to delete-on-exit. Call `run.suspend_on_exit()` or
`run.suspend_layer_on_exit(name)` inside the active context when the next snapshot
should be resumable:
```python {test="skip" lint="skip"}
async with compositor.enter(configs=configs) as run:
run.suspend_on_exit()
snapshot = run.session_snapshot
async with compositor.enter(configs=configs, session_snapshot=snapshot) as restored_run:
restored_layer = restored_run.get_layer("stateful", StatefulLayer)
```
`run.session_snapshot` is populated after context exit. Snapshots include ordered
layer names, non-active lifecycle states, and JSON-safe runtime state only. Active
state is rejected at the DTO boundary, and closed layers cannot be entered again.
To resume, pass the snapshot to a later `Compositor.enter(...)` call with the same
layer names and order.
See also:
- `examples/agenton/agenton_examples/basics.py`
- `examples/agenton/agenton_examples/pydantic_ai_bridge.py`
- `examples/agenton/agenton_examples/session_snapshot.py`
+6
View File
@@ -0,0 +1,6 @@
# Agenton documentation
- [User guide](guide/index.md) explains how to compose layers, register config-backed
plugins, use system/user prompts, and snapshot sessions.
- [API reference](api/index.md) lists the public Agenton classes, methods, and extension
points.
@@ -0,0 +1,25 @@
# Dify Agent examples
These examples live under `examples/dify_agent/dify_agent_examples`. They are
separated from Agenton examples because they depend on Dify Agent runtime services
such as the FastAPI server, Redis, or the plugin daemon.
## Run a Dify plugin-daemon backed model
```snippet {path="/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py"}
```
## Poll run events
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_consumer.py"}
```
## Use the synchronous client
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sync_client.py"}
```
## Stream run events with SSE
```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py"}
```
@@ -0,0 +1,268 @@
# Get started with Dify Agent
This guide walks through the smallest end-to-end path for the current Dify Agent
runtime: install dependencies, configure the server, start it, then use the Python
client to create one plugin-daemon-backed run.
## Prerequisites
Install or prepare:
- Python 3.12 or newer
- `uv`
- Redis
- a reachable Dify plugin daemon
- a plugin/provider already available through that plugin daemon, such as
`langgenius/openai`
## Install dependencies
From the repository root, enter the `dify-agent` package and install all extras
and dependency groups:
```bash
cd dify-agent
uv sync --all-extras --all-groups
```
Only the `server` extra is required to run the API server, but installing all
extras and groups gives you the local test, docs, and server dependencies in one
environment.
## Start Redis
Skip this step if you already have a reachable Redis instance.
```bash
docker run -d \
--name dify-agent-redis \
-p 6379:6379 \
redis:7-alpine
```
## Configure the server
Create or update `.env` in the `dify-agent` directory:
```bash
cat > .env <<'EOF'
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
DIFY_AGENT_REDIS_PREFIX=dify-agent
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key
EOF
```
The minimum settings are:
- `DIFY_AGENT_REDIS_URL`: Redis connection URL used for run records and event
streams.
- `DIFY_AGENT_REDIS_PREFIX`: Redis key prefix for this server.
- `DIFY_AGENT_PLUGIN_DAEMON_URL`: base URL for the Dify plugin daemon.
- `DIFY_AGENT_PLUGIN_DAEMON_API_KEY`: API key sent by the server to the plugin
daemon. In a Dify Docker setup this is usually the value previously configured
as `PLUGIN_DAEMON_KEY`.
See `.example.env` for the full server settings template.
## Start the Dify Agent server
For a normal local server process:
```bash
make serve
```
For development with uvicorn reload:
```bash
make dev
```
Both commands serve the API at:
```text
http://127.0.0.1:8000
```
The equivalent development command is:
```bash
uv run --extra server uvicorn dify_agent.server.app:app \
--host 127.0.0.1 \
--port 8000 \
--reload
```
`ServerSettings` reads `.env` from the current `dify-agent` directory, or from
`dify-agent/.env` when the command is run from the repository root.
## Create a one-file uv script client
In another shell, keep working from the `dify-agent` directory and create this
script. The script depends on the local `dify-agent` package only; it does not
install the server extra because it talks to the already running server through
the public Python client.
```bash
DIFY_AGENT_PACKAGE_URL="$(python3 - <<'PY'
from pathlib import Path
print(Path.cwd().resolve().as_uri())
PY
)"
cat > ./run_dify_agent_client.py <<PY
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "dify-agent @ ${DIFY_AGENT_PACKAGE_URL}",
# ]
# ///
import asyncio
import json
import os
import sys
from typing import Any
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.client import Client
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
)
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
def env(name: str, default: str | None = None) -> str:
value = os.environ.get(name, default)
if value is None or value == "":
raise SystemExit(f"Missing required environment variable: {name}")
return value
def load_credentials() -> dict[str, Any]:
raw = env("DIFY_AGENT_MODEL_CREDENTIALS_JSON")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"DIFY_AGENT_MODEL_CREDENTIALS_JSON must be valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise SystemExit("DIFY_AGENT_MODEL_CREDENTIALS_JSON must be a JSON object")
return data
async def main() -> int:
api_base_url = env("DIFY_AGENT_SERVER_URL", "http://127.0.0.1:8000")
tenant_id = env("DIFY_AGENT_TENANT_ID")
plugin_id = env("DIFY_AGENT_PLUGIN_ID", "langgenius/openai")
user_id = os.environ.get("DIFY_AGENT_USER_ID") or None
model_provider = env("DIFY_AGENT_PROVIDER", "openai")
model_name = env("DIFY_AGENT_MODEL_NAME", "gpt-4o-mini")
model_credentials = load_credentials()
system_prompt = env("DIFY_AGENT_SYSTEM_PROMPT", "You are a concise assistant.")
user_prompt = env("DIFY_AGENT_PROMPT", "Say hello from the Dify Agent client.")
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config=PromptLayerConfig(prefix=system_prompt, user=user_prompt),
),
RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(
tenant_id=tenant_id,
plugin_id=plugin_id,
user_id=user_id,
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
model_provider=model_provider,
model=model_name,
credentials=model_credentials,
),
),
],
),
)
async with Client(base_url=api_base_url, stream_timeout=None) as client:
run = await client.create_run(request)
print(f"created run: {run.run_id}, status={run.status}")
async for event in client.stream_events(run.run_id):
print(event.model_dump_json(indent=2))
if event.type == "run_succeeded":
print("final output:")
print(json.dumps(event.data.output, ensure_ascii=False, indent=2))
return 0
if event.type == "run_failed":
print(f"run failed: {event.data.error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
PY
chmod +x ./run_dify_agent_client.py
```
## Configure the client request and run it
The server-side `.env` controls how Dify Agent reaches the plugin daemon. The
client request controls which tenant/plugin/provider/model and provider
credentials the run uses. Configure those values before executing the script:
```bash
export DIFY_AGENT_SERVER_URL=http://127.0.0.1:8000
export DIFY_AGENT_TENANT_ID=replace-with-tenant-id
export DIFY_AGENT_PLUGIN_ID=langgenius/openai
export DIFY_AGENT_PROVIDER=openai
export DIFY_AGENT_MODEL_NAME=gpt-4o-mini
export DIFY_AGENT_MODEL_CREDENTIALS_JSON='{"api_key":"replace-with-provider-key"}'
export DIFY_AGENT_PROMPT='用一句话介绍 Dify Agent。'
./run_dify_agent_client.py
```
The shape of `DIFY_AGENT_MODEL_CREDENTIALS_JSON` depends on the selected plugin
provider's credential schema. The `{"api_key":"..."}` value above is only an
OpenAI-style example.
## Troubleshooting
If the run fails, check these items first:
1. Redis is running and reachable from the Dify Agent server.
2. The Dify Agent server is listening on `127.0.0.1:8000`.
3. `DIFY_AGENT_PLUGIN_DAEMON_URL` points to the correct plugin daemon.
4. `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` matches the plugin daemon server key.
5. `DIFY_AGENT_PLUGIN_ID`, `DIFY_AGENT_PROVIDER`, and
`DIFY_AGENT_MODEL_NAME` match a provider available through the plugin daemon.
6. `DIFY_AGENT_MODEL_CREDENTIALS_JSON` matches that provider's credential schema.
+140
View File
@@ -0,0 +1,140 @@
# Operating the Dify Agent Run Server
This guide describes how to run the MVP Dify Agent API server. The server is
implemented in `dify-agent/src/dify_agent/server/app.py` and uses Redis for run
records and per-run event streams only.
## Default local startup
Start Redis, then run one FastAPI/uvicorn process:
```bash
uv run --project dify-agent uvicorn dify_agent.server.app:app --reload
```
By default, the FastAPI lifespan creates:
- one Redis-backed run store used by HTTP routes
- one shared plugin-daemon `httpx.AsyncClient` used by local run tasks
- one process-local scheduler that starts background `asyncio` run tasks
This means local development needs one uvicorn process plus Redis, and
plugin-backed runs also need a reachable Dify plugin daemon. Run execution still
happens outside request handlers, so client disconnects do not cancel the agent
run.
## Configuration
`ServerSettings` loads environment variables with the `DIFY_AGENT_` prefix. It
also reads `.env` and `dify-agent/.env` when present.
| Environment variable | Default | Description |
| --- | --- | --- |
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |
| `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. |
| `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. |
| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. |
| `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. |
| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. |
| `DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT` | `10` | Plugin-daemon HTTP connect timeout in seconds. |
| `DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT` | `600` | Plugin-daemon HTTP read timeout in seconds. |
| `DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT` | `30` | Plugin-daemon HTTP write timeout in seconds. |
| `DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT` | `10` | Plugin-daemon HTTP connection-pool wait timeout in seconds. |
| `DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS` | `100` | Maximum total plugin-daemon HTTP connections. |
| `DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS` | `20` | Maximum idle keep-alive plugin-daemon HTTP connections. |
| `DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY` | `30` | Keep-alive expiry in seconds for idle plugin-daemon HTTP connections. |
Example `.env`:
```env
DIFY_AGENT_REDIS_URL=redis://localhost:6379/0
DIFY_AGENT_REDIS_PREFIX=dify-agent-dev
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key
```
Run records and event streams use the same retention. Status writes refresh the
record TTL, and event writes refresh both the stream TTL and the corresponding
record TTL so active runs that keep producing events remain observable.
## Scheduling and shutdown semantics
`POST /runs` validates the composition, persists a `running` run record, and starts
an `asyncio` task in the same process. There is no Redis job stream, consumer
group, pending reclaim, or automatic retry layer.
During FastAPI shutdown the scheduler rejects new runs, waits up to
`DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks
and best-effort appends a `run_failed` event plus failed status. A hard process
crash can still leave active runs stuck as `running`; there is no in-service
recovery or worker handoff.
Horizontal scaling is possible by running multiple API processes against the same
Redis prefix, but each process executes only the runs it accepted. Redis provides
shared status/event visibility, not load balancing or queued-job recovery.
## Run inputs and session snapshots
The API does not accept a top-level `user_prompt`. Submit a `RunComposition`
whose Agenton layers provide user input. With the MVP provider set, use
`plain.prompt` and its `config.user` field:
```json
{
"composition": {
"schema_version": 1,
"layers": [
{
"name": "prompt",
"type": "plain.prompt",
"config": {
"prefix": "You are concise.",
"user": "Summarize the current state."
}
}
]
}
}
```
`config.user` can be a string or a list of strings. Empty or whitespace-only
effective prompts are rejected during create-run validation before the run is
persisted or scheduled.
There is no Pydantic AI history layer. To resume Agenton layer state, pass the
`session_snapshot` from a previous `run_succeeded.data` payload together with a
composition that has the same layer names and order.
## Observing runs
Use the HTTP status endpoint for coarse state and the event endpoints for detailed
progress:
- `POST /runs` creates a running run and schedules it locally.
- `GET /runs/{run_id}` returns `running`, `succeeded`, or `failed`.
- `GET /runs/{run_id}/events` polls the Redis Stream event log with `after` and
`next_cursor` cursors.
- `GET /runs/{run_id}/events/sse` replays and streams events over SSE. The SSE
`id` is the event Redis Stream ID. `after` query cursors take precedence over
`Last-Event-ID` headers.
Successful runs emit `run_started`, zero or more `pydantic_ai_event`, and
`run_succeeded`. Failed runs end with `run_failed`. Event envelopes retain `id`,
`run_id`, `type`, `data`, and `created_at`; `data` is typed per event type,
including Pydantic AI's `AgentStreamEvent` payload for `pydantic_ai_event` and a
terminal `run_succeeded.data` object containing JSON-safe `output` plus a
`CompositorSessionSnapshot` for resumption.
## Examples
The repository includes simple consumers that print observed output/events:
- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py`
creates a run and polls events.
- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py`
consumes raw SSE frames for an existing run id.
The create-run examples submit Dify plugin model layers, so they require Redis,
the API server, plugin-daemon settings, and provider credentials.
+8
View File
@@ -0,0 +1,8 @@
# Dify Agent runtime
Dify Agent hosts Agenton-composed Pydantic AI runs behind a FastAPI API. Its
source code stays under `src/dify_agent`, while framework-neutral Agenton code
stays under `src/agenton` and `src/agenton_collections`.
See the [operations guide](guide/index.md) for local server behavior and the
[run API](api/index.md) for request and event schemas.
+11
View File
@@ -0,0 +1,11 @@
# Dify Agent
This documentation is split by ownership boundary:
- [Agenton](agenton/index.md) covers the framework-neutral layer compositor and reusable
collection layers.
- [Dify Agent](dify-agent/index.md) covers the Dify runtime, HTTP API, Redis-backed run
storage, and server examples.
The split mirrors the source tree so Agenton documentation and examples can be
moved together if Agenton is published separately later.
@@ -0,0 +1 @@
"""Runnable Agenton examples kept separate from Dify Agent runtime examples."""

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