Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7210f856c9 | ||
|
|
ebcc1200a3 | ||
|
|
e660d7af38 | ||
|
|
d9ccfcbc6e | ||
|
|
a9bcec013f | ||
|
|
aeb7687e2c | ||
|
|
9355d36718 | ||
|
|
a03ee828a3 | ||
|
|
7066372892 | ||
|
|
55f95dbc36 | ||
|
|
8b40de3c4e | ||
|
|
af4b9bfa8f | ||
|
|
b9e3130388 | ||
|
|
12d33652b6 | ||
|
|
fe8cf2aff4 | ||
|
|
d1d190374d | ||
|
|
e1be4e6aa8 | ||
|
|
301a470e7a | ||
|
|
91251ad5a5 | ||
|
|
3f6644a615 | ||
|
|
5edc682c4a | ||
|
|
13c00ecfc4 | ||
|
|
9d545144ce | ||
|
|
2afa39cdcb | ||
|
|
bb1c883be4 | ||
|
|
03861bcee3 | ||
|
|
c34fc429ae | ||
|
|
d110112863 | ||
|
|
934a20e745 | ||
|
|
7e56a244a8 | ||
|
|
6facd9360c | ||
|
|
a18d7f51eb | ||
|
|
680ef077ae | ||
|
|
c26be9d3f4 |
+1
-1
@@ -250,5 +250,5 @@ scripts/stress-test/reports/
|
||||
|
||||
# Code Agent Folder
|
||||
.qoder/*
|
||||
|
||||
.context/*
|
||||
.eslintcache
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+77
-5
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
class BaseServiceError(ValueError):
|
||||
def __init__(self, description: str | None = None):
|
||||
def __init__(self, description: str = ""):
|
||||
self.description = description
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
dify-aio
|
||||
@@ -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 you’ll 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 repo’s 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 there’s 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 it’s 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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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})"
|
||||
@@ -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"}
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user