refactor(api): consolidate deployment edition handling (#40142)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Byron.wangandautofix-ci[bot] authored and GitHub committed 2026-08-11 07:39:01 +00:00
1 parent 7fffd51535
commit c1e94f8ee7
250 files changed
+1217 -1378

No files matched your search

-1
View File
@@ -33,7 +33,6 @@ RUN uv sync --frozen --no-dev --no-editable
FROM base AS production
ENV FLASK_APP=app.py
ENV EDITION=SELF_HOSTED
ENV DEPLOY_ENV=PRODUCTION
ENV CONSOLE_API_URL=http://127.0.0.1:5001
ENV CONSOLE_WEB_URL=http://127.0.0.1:3000
+2 -1
View File
@@ -14,6 +14,7 @@ from contexts.wrapper import RecyclableContextVar
from controllers.console.error import UnauthorizedAndForceLogout
from core.logging.context import init_request_context
from dify_app import DifyApp
from enums import DeploymentEdition
from extensions.ext_socketio import sio
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.feature_entities import LicenseStatus
@@ -112,7 +113,7 @@ def create_flask_app_with_configs() -> DifyApp:
init_request_context()
RecyclableContextVar.increment_thread_recycles()
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
surface = _match_license_gated_surface(request.path)
if surface is not None:
try:
+6 -6
View File
@@ -10,6 +10,8 @@ import click
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs
@@ -126,14 +128,12 @@ def _get_archive_candidate_tenant_ids_by_prefix(
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
from configs import dify_config
from enums.cloud_plan import CloudPlan
from services.billing_service import BillingService
tenant_ids = sorted(set(tenant_ids))
if not tenant_ids:
return [], []
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return tenant_ids, []
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
@@ -1462,7 +1462,7 @@ def cleanup_orphaned_draft_variables(
"--graceful-period",
default=21,
show_default=True,
help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.",
help="Graceful period in days after subscription expiration; ignored outside the Cloud edition.",
)
@click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting")
def clean_expired_messages(
@@ -1514,8 +1514,8 @@ def clean_expired_messages(
if from_days_ago <= before_days:
raise click.UsageError("--from-days-ago must be greater than --before-days.")
# Create policy based on billing configuration
# NOTE: graceful_period will be ignored when billing is disabled.
# Create the policy for the configured deployment edition.
# NOTE: graceful_period is ignored outside the Cloud edition.
policy = create_message_clean_policy(graceful_period_days=graceful_period)
if from_days_ago is not None and before_days is not None:
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from events.app_event import app_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client
-9
View File
@@ -5,7 +5,6 @@ from typing import Any, override
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
from enums.deployment_edition import DeploymentEdition
from libs.file_utils import search_file_upwards
from .deploy import DeploymentConfig
@@ -117,11 +116,3 @@ class DifyConfig(
),
),
)
@property
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
if self.EDITION == "CLOUD":
return DeploymentEdition.CLOUD
if self.ENTERPRISE_ENABLED:
return DeploymentEdition.ENTERPRISE
return DeploymentEdition.COMMUNITY
+5 -5
View File
@@ -1,8 +1,8 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
from enums import DeploymentEdition
class DeploymentConfig(BaseSettings):
"""
@@ -25,9 +25,9 @@ class DeploymentConfig(BaseSettings):
default=False,
)
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
default="SELF_HOSTED",
DEPLOYMENT_EDITION: DeploymentEdition = Field(
description="Product edition of the application.",
default=DeploymentEdition.COMMUNITY,
)
DEPLOY_ENV: str = Field(
+1 -7
View File
@@ -8,12 +8,6 @@ class EnterpriseFeatureConfig(BaseSettings):
**Before using, please contact business@dify.ai by email to inquire about licensing matters.**
"""
ENTERPRISE_ENABLED: bool = Field(
description="Enable or disable enterprise-level features."
"Before using, please contact business@dify.ai by email to inquire about licensing matters.",
default=False,
)
WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field(
description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, "
"no auth). Disable in security-sensitive on-prem deployments.",
@@ -59,7 +53,7 @@ class EnterpriseTelemetryConfig(BaseSettings):
"""
ENTERPRISE_TELEMETRY_ENABLED: bool = Field(
description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).",
description="Enable enterprise telemetry collection for enterprise deployments.",
default=False,
)
-12
View File
@@ -810,17 +810,6 @@ class ModelLoadBalanceConfig(BaseSettings):
)
class BillingConfig(BaseSettings):
"""
Configuration for platform billing features
"""
BILLING_ENABLED: bool = Field(
description="Enable or disable billing functionality",
default=False,
)
class UpdateConfig(BaseSettings):
"""
Configuration for application update checks
@@ -1621,7 +1610,6 @@ class FeatureConfig(
# place the configs in alphabet order
AppExecutionConfig,
AuthConfig, # Changed from OAuthConfig to AuthConfig
BillingConfig,
CodeExecutionSandboxConfig,
CreatorsPlatformConfig,
TriggerConfig,
-6
View File
@@ -126,12 +126,6 @@ class UsageCountResponse(ResponseModel):
count: int
class IndexInfoResponse(ResponseModel):
welcome: str
api_version: str
server_version: str
class AvatarUrlResponse(ResponseModel):
avatar_url: str
+4 -1
View File
@@ -10,6 +10,7 @@ from controllers.console import console_ns
from controllers.console.auth.error import InvitationAccountMismatchError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.wraps import model_validate
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
@@ -161,7 +162,9 @@ class ActivateApi(Resource):
if current_account.id != account.id:
raise InvitationAccountMismatchError()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
account.email
):
raise AccountInFreezeError()
tenant = invitation["tenant"]
@@ -15,6 +15,7 @@ from controllers.console.auth.error import (
InvalidTokenError,
PasswordMismatchError,
)
from enums import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import EmailStr, extract_remote_ip
@@ -98,7 +99,9 @@ class EmailRegisterSendEmailApi(Resource):
if req_data.language is not None and req_data.language in languages:
language = req_data.language
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
+4 -2
View File
@@ -43,6 +43,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
@@ -121,7 +122,9 @@ class LoginApi(Resource):
request_email = req_data.email
normalized_email = request_email.lower()
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
@@ -160,7 +163,6 @@ class LoginApi(Resource):
AccountService.add_login_error_rate_limit(normalized_email)
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
raise AuthenticationFailedError() from exc
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
+4 -1
View File
@@ -12,6 +12,7 @@ from configs import dify_config
from constants.languages import languages
from controllers.common.fields import RedirectResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_remote_ip
@@ -301,7 +302,9 @@ def _generate_account(
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(
normalized_email
):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
+1 -1
View File
@@ -15,7 +15,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
+1 -1
View File
@@ -9,7 +9,7 @@ from flask import Response, abort, request
from configs import dify_config
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
from core.logging.context import get_request_id, get_trace_id
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions.ext_database import db
from models.model import DifySetup
from services.account_service import TenantService
+1 -1
View File
@@ -73,7 +73,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
"""Initialize system setup with admin account.
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards,
and init-password validation rather than user session authentication.
"""
try:
@@ -11,7 +11,6 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_enabled,
cloud_edition_billing_paid_plan_required,
model_validate,
only_edition_cloud,
@@ -122,7 +121,6 @@ class WorkflowRunArchivesApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self):
tenant_id, _ = _current_owner_or_admin_ids()
@@ -143,7 +141,6 @@ class WorkflowRunArchiveDownloadsApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
@model_validate(WorkflowRunArchiveDownloadPayload)
def post(self, req_data: WorkflowRunArchiveDownloadPayload):
@@ -170,7 +167,6 @@ class WorkflowRunArchiveDownloadApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self, download_id: str):
tenant_id, _ = _current_owner_or_admin_ids()
@@ -195,7 +191,6 @@ class WorkflowRunArchiveDownloadFileApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@cloud_edition_billing_paid_plan_required
def get(self, download_id: str):
tenant_id, _ = _current_owner_or_admin_ids()
+1 -6
View File
@@ -43,7 +43,6 @@ from controllers.console.workspace.error import (
)
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_enabled,
enable_change_email,
enterprise_license_required,
model_validate,
@@ -51,7 +50,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@@ -541,7 +540,6 @@ class EducationVerifyApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__])
@with_current_user
def get(self, account: Account):
@@ -559,7 +557,6 @@ class EducationApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@with_current_user
def post(self, account: Account):
raise EducationDiscountTemporarilyPausedError()
@@ -568,7 +565,6 @@ class EducationApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__])
@with_current_user
def get(self, account: Account):
@@ -586,7 +582,6 @@ class EducationAutoCompleteApi(Resource):
@login_required
@account_initialization_required
@only_edition_cloud
@cloud_edition_billing_enabled
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
def get(self):
payload = request.args.to_dict(flat=True)
+7 -3
View File
@@ -32,6 +32,7 @@ from controllers.console.wraps import (
setup_required,
with_current_user,
)
from enums import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -179,7 +180,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
@@ -189,7 +190,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
raise SeatsLimitExceeded()
return
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
@@ -265,7 +266,10 @@ class MemberInviteEmailApi(Resource):
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True:
if dify_config.DEPLOYMENT_EDITION in {
DeploymentEdition.CLOUD,
DeploymentEdition.ENTERPRISE,
}:
new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails)
_check_member_invite_limits(tenant_id, new_member_count, new_account_count)
+11 -3
View File
@@ -14,6 +14,7 @@ from controllers.console import console_ns
from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate, rbac_permission_required
from core.db.session_factory import session_factory
from core.rbac import RBACResourceWhitelistScope
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import Account
@@ -316,14 +317,16 @@ class RBACRolesApi(Resource):
options = req_data.to_inner_options()
if not dify_config.RBAC_ENABLED:
result = _legacy_workspace_roles(
options, include_owner=req_data.include_owner, billing_enabled=dify_config.BILLING_ENABLED
options,
include_owner=req_data.include_owner,
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
)
else:
result = svc.RBACService.Roles.list(
tenant_id,
account_id,
include_owner=req_data.include_owner,
biiling_enabled=dify_config.BILLING_ENABLED,
biiling_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
options=options,
)
@@ -351,7 +354,12 @@ class RBACRoleItemApi(Resource):
def get(self, role_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.Roles.get(tenant_id, account_id, role_id, billing_enabled=dify_config.BILLING_ENABLED)
svc.RBACService.Roles.get(
tenant_id,
account_id,
role_id,
billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD,
)
)
@login_required
@@ -61,6 +61,7 @@ from core.tools.entities.tool_entities import (
ToolProviderType,
WorkflowToolParameterConfiguration,
)
from enums import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import alphanumeric, dump_response, uuid_value
@@ -282,10 +283,10 @@ def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityM
can never imply forwarding that the runtime won't perform. This gates the
API surface to match the backend gate in
``MCPTool._forwarding_requested`` — both the API and the backend
invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``.
invocation must be gated on the Enterprise deployment edition.
"""
mode = current if requested is None else requested
if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED:
if mode != IdentityMode.OFF and dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return IdentityMode.OFF
return mode
@@ -1417,7 +1418,7 @@ class ToolProviderMCPApi(Resource):
with sessionmaker(db.engine).begin() as session:
service = MCPToolManageService(session=session)
# Resolve "leave unchanged" (None) against the stored value, and gate
# the result on ENTERPRISE_ENABLED — both are API-layer concerns, so
# the result on the Enterprise edition — both are API-layer concerns, so
# the service receives a concrete IdentityMode.
existing = service.get_provider(provider_id=req_data.provider_id, tenant_id=current_tenant_id)
identity_mode = _resolve_identity_mode(req_data.identity_mode, current=IdentityMode(existing.identity_mode))
+4 -15
View File
@@ -19,8 +19,7 @@ from controllers.common.wraps import (
)
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
@@ -142,7 +141,7 @@ def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
abort(404)
return view(*args, **kwargs)
@@ -161,16 +160,6 @@ def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return decorated
def cloud_edition_billing_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not dify_config.BILLING_ENABLED:
abort(403, "Billing feature is not enabled.")
return view(*args, **kwargs)
return decorated
def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
@@ -192,7 +181,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal
def decorated(*args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
if resource == "vector_space":
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(current_tenant_id)
@@ -301,7 +290,7 @@ def cloud_utm_record[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def decorated(*args: P.args, **kwargs: P.kwargs):
with contextlib.suppress(Exception):
utm_info = request.cookies.get("utm_info")
if dify_config.BILLING_ENABLED and utm_info:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and utm_info:
_, current_tenant_id = current_account_with_tenant()
utm_info_dict: UtmInfo = json.loads(utm_info)
OperationService.record_utm(current_tenant_id, utm_info_dict)
-2
View File
@@ -139,7 +139,6 @@ register_response_schema_models(
register_enum_models(openapi_ns, OpenApiErrorCode)
from . import (
_meta,
account,
app_dsl,
app_run,
@@ -157,7 +156,6 @@ from . import (
# Request models are imported from _models.py and registered above.
__all__ = [
"_meta",
"account",
"app_dsl",
"app_run",
-24
View File
@@ -1,24 +0,0 @@
"""Meta endpoint: `GET /openapi/v1/_version` — no auth.
Returns the server's project version and edition so the difyctl CLI can probe
compatibility without needing to be logged in. Mirrors the `_health` endpoint
in `index.py`.
"""
from flask_restx import Resource
from configs import dify_config
from controllers.openapi import openapi_ns
from controllers.openapi._contract import returns
from controllers.openapi._models import ServerVersionResponse
@openapi_ns.route("/_version")
class VersionApi(Resource):
@returns(200, ServerVersionResponse, description="Server version")
def get(self):
edition = dify_config.EDITION if dify_config.EDITION in ("SELF_HOSTED", "CLOUD") else "SELF_HOSTED"
return ServerVersionResponse(
version=dify_config.project.version,
edition=edition,
)
+2 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from enums import DeploymentEdition
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode
@@ -258,7 +259,7 @@ class ServerVersionResponse(BaseModel):
"""Meta endpoint payload for `GET /openapi/v1/_version` — no auth required."""
version: str
edition: Literal["SELF_HOSTED", "CLOUD"]
edition: DeploymentEdition
class HealthResponse(BaseModel):
@@ -23,7 +23,8 @@ from controllers.openapi._models import (
)
from controllers.openapi.apps import build_app_describe_response
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, Edition
from controllers.openapi.auth.data import AuthData
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
@@ -37,7 +38,7 @@ class PermittedExternalAppsListApi(Resource):
@auth_router.guard(
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
edition=frozenset({Edition.EE}),
edition=frozenset({DeploymentEdition.ENTERPRISE}),
)
@returns(200, PermittedExternalAppsListResponse, description="Permitted external apps list")
@accepts(query=PermittedExternalAppsListQuery)
@@ -94,7 +95,7 @@ class PermittedExternalAppDescribeApi(Resource):
@auth_router.guard(
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
edition=frozenset({Edition.EE}),
edition=frozenset({DeploymentEdition.ENTERPRISE}),
)
@returns(200, AppDescribeResponse, description="Permitted external app description")
@accepts(query=AppDescribeQuery)
+9 -6
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from controllers.openapi.auth.conditions import (
EDITION_EE,
EDITION_ENTERPRISE,
HAS_ALLOWED_ROLES,
HAS_RBAC,
LOADED_APP_IS_PRIVATE,
@@ -11,7 +11,6 @@ from controllers.openapi.auth.conditions import (
WORKSPACE_MEMBERSHIP_REQUIRED,
WORKSPACE_SCOPED,
)
from controllers.openapi.auth.data import Edition
from controllers.openapi.auth.flow import When
from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter
from controllers.openapi.auth.prepare import (
@@ -33,6 +32,7 @@ from controllers.openapi.auth.verify import (
check_workspace_mismatch,
check_workspace_role,
)
from enums import DeploymentEdition
from libs.oauth_bearer import TokenType
account_pipeline = AuthPipeline(
@@ -42,7 +42,7 @@ account_pipeline = AuthPipeline(
When(WORKSPACE_MEMBERSHIP_REQUIRED, then=load_tenant_from_request),
load_account,
When(WORKSPACE_SCOPED, then=load_workspace_role),
When(PATH_HAS_APP_ID & EDITION_EE, then=load_app_access_mode),
When(PATH_HAS_APP_ID & EDITION_ENTERPRISE, then=load_app_access_mode),
],
auth=[
When(PATH_HAS_APP_ID, then=check_app_api_enabled),
@@ -51,8 +51,8 @@ account_pipeline = AuthPipeline(
When(PATH_HAS_APP_ID, then=check_workspace_mismatch),
When(HAS_ALLOWED_ROLES, then=check_workspace_role),
When(HAS_RBAC, then=check_rbac_permission),
When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl),
When(EDITION_EE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission),
When(PATH_HAS_APP_ID & EDITION_ENTERPRISE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl),
When(EDITION_ENTERPRISE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission),
],
)
@@ -74,6 +74,9 @@ external_sso_pipeline = AuthPipeline(
auth_router = PipelineRouter(
{
TokenType.OAUTH_ACCOUNT: PipelineRoute(account_pipeline),
TokenType.OAUTH_EXTERNAL_SSO: PipelineRoute(external_sso_pipeline, required_edition=frozenset({Edition.EE})),
TokenType.OAUTH_EXTERNAL_SSO: PipelineRoute(
external_sso_pipeline,
required_edition=frozenset({DeploymentEdition.ENTERPRISE}),
),
}
)
+6 -4
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
from collections.abc import Callable
from controllers.openapi.auth.data import AuthData, Edition, RequestContext, current_edition
from configs import dify_config
from controllers.openapi.auth.data import AuthData, RequestContext
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from services.enterprise.enterprise_service import WebAppAccessMode
from services.feature_service import FeatureService
@@ -44,9 +46,9 @@ TOKEN_IS_OAUTH_EXTERNAL_SSO = request_cond(lambda ctx: ctx.token_type == TokenTy
PATH_HAS_APP_ID = request_cond(lambda ctx: "app_id" in ctx.path_params)
EDITION_CE = config_cond(lambda: current_edition() == Edition.CE)
EDITION_EE = config_cond(lambda: current_edition() == Edition.EE)
EDITION_SAAS = config_cond(lambda: current_edition() == Edition.SAAS)
EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY)
EDITION_ENTERPRISE = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE)
EDITION_CLOUD = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD)
WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled)
-16
View File
@@ -6,34 +6,18 @@ from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from enums.deployment_edition import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models.account import Account, Tenant, TenantAccountRole
from models.model import App, EndUser
from services.enterprise.enterprise_service import WebAppAccessMode
class Edition(StrEnum):
CE = "ce"
EE = "ee"
SAAS = "saas"
class CallerKind(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
def current_edition() -> Edition:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
return Edition.SAAS
if dify_config.ENTERPRISE_ENABLED:
return Edition.EE
return Edition.CE
class ExternalIdentity(BaseModel):
model_config = ConfigDict(frozen=True)
+11 -11
View File
@@ -16,16 +16,16 @@ from flask import current_app, request
from flask_login import user_logged_in
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from configs import dify_config
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi.auth.data import (
AuthData,
Edition,
ExternalIdentity,
RBACRequirement,
RequestContext,
current_edition,
)
from controllers.openapi.auth.flow import When
from enums import DeploymentEdition
from libs.oauth_bearer import (
AuthContext,
Scope,
@@ -112,7 +112,7 @@ class AuthPipeline:
@dataclass(frozen=True)
class PipelineRoute:
pipeline: AuthPipeline
required_edition: frozenset[Edition] | None = None
required_edition: frozenset[DeploymentEdition] | None = None
class PipelineRouter:
@@ -131,7 +131,7 @@ class PipelineRouter:
*,
scope: Scope | None = None,
allowed_token_types: frozenset[TokenType] | None = None,
edition: frozenset[Edition] | None = None,
edition: frozenset[DeploymentEdition] | None = None,
workspace_membership: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
@@ -150,7 +150,7 @@ class PipelineRouter:
*,
scope: Scope | None = None,
allowed_token_types: frozenset[TokenType] | None = None,
edition: frozenset[Edition] | None = None,
edition: frozenset[DeploymentEdition] | None = None,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
) -> Callable:
@@ -168,7 +168,7 @@ class PipelineRouter:
*,
scope: Scope | None,
allowed_token_types: frozenset[TokenType] | None,
edition: frozenset[Edition] | None,
edition: frozenset[DeploymentEdition] | None,
workspace_membership: bool,
allowed_roles: frozenset[TenantAccountRole] | None,
rbac: RBACRequirement | None,
@@ -200,17 +200,17 @@ class PipelineRouter:
*,
scope: Scope | None,
allowed_token_types: frozenset[TokenType] | None,
edition: frozenset[Edition] | None,
edition: frozenset[DeploymentEdition] | None,
workspace_membership: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
) -> Any:
# 404 not 403 — this edition doesn't expose the feature at all
if edition is not None and current_edition() not in edition:
if edition is not None and dify_config.DEPLOYMENT_EDITION not in edition:
raise NotFound()
license_checked = False
if edition is not None and Edition.EE in edition:
if edition is not None and DeploymentEdition.ENTERPRISE in edition:
_check_license()
license_checked = True
@@ -234,9 +234,9 @@ class PipelineRouter:
raise Forbidden("unsupported_token_type")
if route.required_edition is not None:
if current_edition() not in route.required_edition:
if dify_config.DEPLOYMENT_EDITION not in route.required_edition:
raise Forbidden("external_sso_requires_ee")
if not license_checked and Edition.EE in route.required_edition:
if not license_checked and DeploymentEdition.ENTERPRISE in route.required_edition:
_check_license()
return route.pipeline._run(
+14 -1
View File
@@ -1,8 +1,11 @@
"""Unauthenticated health and version probes for the OpenAPI surface."""
from flask_restx import Resource
from configs import dify_config
from controllers.openapi import openapi_ns
from controllers.openapi._contract import returns
from controllers.openapi._models import HealthResponse
from controllers.openapi._models import HealthResponse, ServerVersionResponse
@openapi_ns.route("/_health")
@@ -10,3 +13,13 @@ class HealthApi(Resource):
@returns(200, HealthResponse, description="Health check")
def get(self):
return HealthResponse(ok=True)
@openapi_ns.route("/_version")
class VersionApi(Resource):
@returns(200, ServerVersionResponse, description="Server version")
def get(self):
return ServerVersionResponse(
version=dify_config.project.version,
edition=dify_config.DEPLOYMENT_EDITION,
)
@@ -42,7 +42,7 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import UUIDStrOrEmpty
@@ -376,7 +376,11 @@ class ChatApi(Resource):
payload = ChatRequestPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
if app_mode == AppMode.ADVANCED_CHAT and payload.workflow_id and dify_config.BILLING_ENABLED:
if (
app_mode == AppMode.ADVANCED_CHAT
and payload.workflow_id
and dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD
):
billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True)
if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX:
raise WorkflowVersionExecutionNotAllowedError()
+2 -2
View File
@@ -45,7 +45,7 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
@@ -451,7 +451,7 @@ class WorkflowRunByIdApi(Resource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True)
if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX:
raise WorkflowVersionExecutionNotAllowedError()
+13 -6
View File
@@ -1,9 +1,16 @@
from flask_restx import Resource
from configs import dify_config
from controllers.common.fields import IndexInfoResponse
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from fields.base import ResponseModel
class IndexInfoResponse(ResponseModel):
welcome: str
api_version: str
server_version: str
register_response_schema_models(service_api_ns, IndexInfoResponse)
@@ -12,8 +19,8 @@ register_response_schema_models(service_api_ns, IndexInfoResponse)
class IndexApi(Resource):
@service_api_ns.response(200, "Success", service_api_ns.models[IndexInfoResponse.__name__])
def get(self) -> dict[str, str]:
return {
"welcome": "Dify OpenAPI",
"api_version": "v1",
"server_version": dify_config.project.version,
}
return IndexInfoResponse(
welcome="Dify OpenAPI",
api_version="v1",
server_version=dify_config.project.version,
).model_dump(mode="json")
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.service_api.schema import (
USER_QUERY_PARAM,
USER_REQUIRED_ATTR,
)
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import current_user
@@ -186,7 +186,7 @@ def cloud_edition_billing_resource_check[**P, R](
def decorated(*args: P.args, **kwargs: P.kwargs):
api_token = validate_and_get_api_token(api_token_type)
if resource == "vector_space":
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(api_token.tenant_id)
+1 -1
View File
@@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound, RequestEntityTooLarge
from controllers.trigger import bp
from core.trigger.debug.event_bus import TriggerDebugEventBus
from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key
from enums.quota_type import QuotaType
from enums import QuotaType
from services.errors.app import QuotaExceededError
from services.trigger.webhook_service import RawWebhookDataDict, WebhookService
+5 -2
View File
@@ -2,9 +2,9 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from extensions.ext_application_services import application_services
from libs.helper import dump_response
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
register_response_schema_models(web_ns, SystemFeatureModel)
@@ -30,4 +30,7 @@ class SystemFeatureApi(Resource):
Authentication configuration must be available before the authentication flow can be selected.
"""
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
return dump_response(
SystemFeatureModel,
application_services().feature_queries.get_system_features(),
)
+4 -2
View File
@@ -30,6 +30,7 @@ from controllers.console.wraps import (
)
from controllers.web import web_ns
from controllers.web.wraps import decode_jwt_token
from enums import DeploymentEdition
from extensions.ext_database import db
from libs.helper import EmailStr, extract_remote_ip
from libs.passport import PassportService
@@ -146,8 +147,9 @@ class LoginStatusApi(Resource):
if not app_code:
return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json")
app_id = AppService.get_app_id_by_code(app_code, session=db.session())
is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check(
app_id=app_id, session=db.session()
is_public = (
dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE
or not WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session())
)
user_logged_in = False
+1 -1
View File
@@ -8,7 +8,7 @@ from configs import dify_config
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.storage.storage_type import StorageType
from fields.base import ResponseModel
+1 -1
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel
from configs import dify_config
from core.entities import DEFAULT_PLUGIN_ID
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, RestrictModel
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from graphon.model_runtime.entities.model_entities import ModelType
+2 -1
View File
@@ -34,6 +34,7 @@ from core.rag.splitter.fixed_text_splitter import (
)
from core.rag.splitter.text_splitter import TextSplitter
from core.tools.utils.web_reader_tool import get_image_upload_file_ids
from enums import DeploymentEdition
from extensions.ext_redis import redis_client
from extensions.ext_storage import storage
from graphon.model_runtime.entities.model_entities import ModelType
@@ -330,7 +331,7 @@ class IndexingRunner:
Estimate the indexing for the document.
"""
# check document limit
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
count = len(extract_settings)
batch_upload_limit = dify_config.BATCH_UPLOAD_LIMIT
if count > batch_upload_limit:
+2 -1
View File
@@ -58,6 +58,7 @@ from core.plugin.impl.debugging import PluginDebuggingClient
from core.plugin.impl.endpoint import PluginEndpointClient
from core.plugin.impl.model import PluginModelClient
from core.plugin.impl.plugin import PluginInstaller
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
@@ -1256,7 +1257,7 @@ class PluginService:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
return result
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
PluginManagerService.try_pre_uninstall_plugin(
PreUninstallPluginRequest(
tenant_id=tenant_id,
+1 -1
View File
@@ -36,7 +36,7 @@ from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderC
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions import ext_hosting_provider
from extensions.ext_database import db
from extensions.ext_redis import redis_client
+2 -1
View File
@@ -25,6 +25,7 @@ from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolProviderType
from core.tools.errors import ToolInvokeError
from enums import DeploymentEdition
from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata
logger = logging.getLogger(__name__)
@@ -272,7 +273,7 @@ class MCPTool(Tool):
the deployment actually has the enterprise side that can mint tokens.
Non-enterprise installs treat the DB value as a no-op — a stale row
won't trigger a 5xx against a missing inner-API endpoint."""
return self.identity_mode != IdentityMode.OFF and dify_config.ENTERPRISE_ENABLED
return self.identity_mode != IdentityMode.OFF and dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
def invoke_remote_mcp_tool(
self,
+3 -3
View File
@@ -31,13 +31,13 @@ if [[ "${MODE}" == "worker" ]]; then
CONCURRENCY_OPTION="-c ${CELERY_WORKER_AMOUNT:-1}"
fi
# Configure queues based on edition if not explicitly set
# Configure queues based on product edition if not explicitly set
if [[ -z "${CELERY_QUEUES}" ]]; then
if [[ "${EDITION}" == "CLOUD" ]]; then
if [[ "${DEPLOYMENT_EDITION:-COMMUNITY}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
else
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
# Self-hosted editions: dataset, pipeline and workflow have separate queues
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
fi
else
+1 -1
View File
@@ -32,7 +32,7 @@ The Enterprise OTEL exporter is configured via environment variables.
| Variable | Description | Default |
|----------|-------------|---------|
| `ENTERPRISE_ENABLED` | Master switch for all enterprise features. | `false` |
| `DEPLOYMENT_EDITION` | Product edition; enterprise telemetry is only available in `ENTERPRISE`. | `COMMUNITY` |
| `ENTERPRISE_TELEMETRY_ENABLED` | Master switch for enterprise telemetry. | `false` |
| `ENTERPRISE_OTLP_ENDPOINT` | OTLP collector endpoint (e.g., `http://otel-collector:4318`). | - |
| `ENTERPRISE_OTLP_HEADERS` | Custom headers for OTLP requests (e.g., `x-scope-orgid=tenant1`). | - |
+4 -1
View File
@@ -42,12 +42,15 @@ from enterprise.telemetry.id_generator import (
set_correlation_id,
set_span_id_source,
)
from enums import DeploymentEdition
logger = logging.getLogger(__name__)
def is_enterprise_telemetry_enabled() -> bool:
return bool(dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED)
return bool(
dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED
)
def _parse_otlp_headers(raw: str) -> dict[str, str]:
+59
View File
@@ -0,0 +1,59 @@
from enum import StrEnum, auto
class CloudPlan(StrEnum):
"""
Enum representing user plan types in the cloud platform.
SANDBOX: Free/default plan with limited features
PROFESSIONAL: Professional paid plan
TEAM: Team collaboration paid plan
"""
SANDBOX = auto()
PROFESSIONAL = auto()
TEAM = auto()
class DeploymentEdition(StrEnum):
"""Enum representing the deployment edition of the platform."""
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
class HostedTrialProvider(StrEnum):
"""Enum representing hosted model provider names for trial access."""
OPENAI = "langgenius/openai/openai"
ANTHROPIC = "langgenius/anthropic/anthropic"
GEMINI = "langgenius/gemini/google"
X = "langgenius/x/x"
DEEPSEEK = "langgenius/deepseek/deepseek"
TONGYI = "langgenius/tongyi/tongyi"
@property
def config_key(self) -> str:
"""Return the config key used in dify_config (e.g., HOSTED_{config_key}_PAID_ENABLED)."""
if self == HostedTrialProvider.X:
return "XAI"
return self.name
class QuotaType(StrEnum):
"""Supported quota types for tenant feature usage."""
TRIGGER = auto()
WORKFLOW = auto()
UNLIMITED = auto()
@property
def billing_key(self) -> str:
match self:
case QuotaType.TRIGGER:
return "trigger_event"
case QuotaType.WORKFLOW:
return "api_rate_limit"
case _:
raise ValueError(f"Invalid quota type: {self}")
-15
View File
@@ -1,15 +0,0 @@
from enum import StrEnum, auto
class CloudPlan(StrEnum):
"""
Enum representing user plan types in the cloud platform.
SANDBOX: Free/default plan with limited features
PROFESSIONAL: Professional paid plan
TEAM: Team collaboration paid plan
"""
SANDBOX = auto()
PROFESSIONAL = auto()
TEAM = auto()
-11
View File
@@ -1,11 +0,0 @@
from enum import StrEnum
class DeploymentEdition(StrEnum):
"""
Enum representing the deployment edition of the platform.
"""
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
-21
View File
@@ -1,21 +0,0 @@
from enum import StrEnum
class HostedTrialProvider(StrEnum):
"""
Enum representing hosted model provider names for trial access.
"""
OPENAI = "langgenius/openai/openai"
ANTHROPIC = "langgenius/anthropic/anthropic"
GEMINI = "langgenius/gemini/google"
X = "langgenius/x/x"
DEEPSEEK = "langgenius/deepseek/deepseek"
TONGYI = "langgenius/tongyi/tongyi"
@property
def config_key(self) -> str:
"""Return the config key used in dify_config (e.g., HOSTED_{config_key}_PAID_ENABLED)."""
if self == HostedTrialProvider.X:
return "XAI"
return self.name
-21
View File
@@ -1,21 +0,0 @@
from enum import StrEnum, auto
class QuotaType(StrEnum):
"""
Supported quota types for tenant feature usage.
"""
TRIGGER = auto()
WORKFLOW = auto()
UNLIMITED = auto()
@property
def billing_key(self) -> str:
match self:
case QuotaType.TRIGGER:
return "trigger_event"
case QuotaType.WORKFLOW:
return "api_rate_limit"
case _:
raise ValueError(f"Invalid quota type: {self}")
@@ -1,4 +1,5 @@
from configs import dify_config
from enums import DeploymentEdition
from events.tenant_event import tenant_was_created
from services.enterprise.workspace_sync import WorkspaceSyncService
@@ -7,7 +8,7 @@ from services.enterprise.workspace_sync import WorkspaceSyncService
def handle(sender, **kwargs):
"""Queue credential sync when a tenant/workspace is created."""
# Only queue sync tasks if plugin manager (enterprise feature) is enabled
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return
tenant = sender
+1 -1
View File
@@ -10,7 +10,7 @@ from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.db.session_factory import get_session_maker
from core.schemas.schema_manager import SchemaManager
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from extensions.ext_redis import RedisClientWrapper, redis_client
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
from repositories.installation_state_repository import InstallationStateRepository
+3 -3
View File
@@ -10,6 +10,7 @@ from typing_extensions import TypedDict
from configs import dify_config
from dify_app import DifyApp
from enums import DeploymentEdition
from extensions.redis_names import normalize_redis_key_prefix
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
@@ -276,8 +277,7 @@ def init_app(app: DifyApp) -> Celery:
}
if (
dify_config.EDITION == "SELF_HOSTED"
and not dify_config.ENTERPRISE_ENABLED
dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY
and not dify_config.DISABLE_TELEMETRY
and not dify_config.DO_NOT_TRACK
and not dify_config.CI
@@ -288,7 +288,7 @@ def init_app(app: DifyApp) -> Celery:
"schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES),
}
if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
imports.append("tasks.enterprise_telemetry_task")
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
+5 -2
View File
@@ -4,7 +4,7 @@ Initializes the EnterpriseExporter singleton during ``create_app()``
(single-threaded), registers blinker event handlers, and hooks atexit
for graceful shutdown.
Skipped entirely when either ``ENTERPRISE_ENABLED`` or ``ENTERPRISE_TELEMETRY_ENABLED``
Skipped entirely outside the Enterprise edition or when ``ENTERPRISE_TELEMETRY_ENABLED``
is false (``is_enabled()`` gate).
"""
@@ -15,6 +15,7 @@ import logging
from typing import TYPE_CHECKING
from configs import dify_config
from enums import DeploymentEdition
if TYPE_CHECKING:
from dify_app import DifyApp
@@ -26,7 +27,9 @@ _exporter: EnterpriseExporter | None = None
def is_enabled() -> bool:
return bool(dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED)
return bool(
dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED
)
def init_app(app: DifyApp) -> None:
+1 -1
View File
@@ -59,7 +59,7 @@ def init_app(app: DifyApp):
SERVICE_NAME: dify_config.APPLICATION_NAME,
SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
PROCESS_PID: os.getpid(),
DEPLOYMENT_ENVIRONMENT_NAME: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}",
DEPLOYMENT_ENVIRONMENT_NAME: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}",
HOST_NAME: socket.gethostname(),
HOST_ARCH: platform.machine(),
"custom.deployment.git_commit": dify_config.COMMIT_SHA,
+2 -1
View File
@@ -1,5 +1,6 @@
from configs import dify_config
from dify_app import DifyApp
from enums import DeploymentEdition
def init_app(app: DifyApp):
@@ -47,7 +48,7 @@ def init_app(app: DifyApp):
before_send=before_send,
)
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
# Cloud only. `opentelemetry.context.detach()` catches its own failures and reports
# them through `logger.exception`, so a double-detach surfaces as an error event
# rather than as a raised exception. Under gevent this can fire about once per
+4 -3
View File
@@ -3,7 +3,7 @@ Base parser interface and utilities for OpenTelemetry node parsers.
Content gating: ``should_include_content()`` controls whether content-bearing
span attributes (inputs, outputs, prompts, completions, documents) are written.
Gate is only active in EE (``ENTERPRISE_ENABLED=True``) when
Gate is only active in the Enterprise edition when
``ENTERPRISE_INCLUDE_CONTENT=False``; CE behaviour is unchanged.
"""
@@ -15,6 +15,7 @@ from opentelemetry.trace.status import Status, StatusCode
from pydantic import BaseModel
from configs import dify_config
from enums import DeploymentEdition
from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes
from graphon.enums import BuiltinNodeTypes
from graphon.file import File
@@ -26,9 +27,9 @@ from graphon.variables import Segment
def should_include_content() -> bool:
"""Return True if content should be written to spans.
CE (ENTERPRISE_ENABLED=False): always True — no behaviour change.
Community and Cloud editions: always True — no behaviour change.
"""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return True
return dify_config.ENTERPRISE_INCLUDE_CONTENT
+2 -1
View File
@@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, ServiceUnavailable, Unauthorized
from configs import dify_config
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.rate_limit import enforce_bearer_rate_limit
@@ -531,7 +532,7 @@ def require_workspace_member(ctx: AuthContext, tenant_id: str) -> None:
No-op on EE (gateway RBAC owns tenant isolation) and for SSO subjects
(no `tenant_account_joins` row by definition).
"""
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
return
if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None:
return
+5 -4
View File
@@ -12,6 +12,7 @@ import logging
from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@@ -32,8 +33,8 @@ def check_workspace_member_invite_permission(workspace_id: str) -> None:
Raises:
Forbidden: If either billing plan or workspace policy prohibits member invitations
"""
# Check enterprise workspace policy level (only if enterprise enabled)
if dify_config.ENTERPRISE_ENABLED:
# Check the enterprise workspace policy only in the Enterprise edition.
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
try:
permission = EnterpriseService.WorkspacePermissionService.get_permission(workspace_id)
if not permission.allow_member_invite:
@@ -62,8 +63,8 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
if not features.is_allow_transfer_workspace:
raise Forbidden("Your current plan does not allow workspace ownership transfer")
# Check enterprise workspace policy level (only if enterprise enabled)
if dify_config.ENTERPRISE_ENABLED:
# Check the enterprise workspace policy only in the Enterprise edition.
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
try:
permission = EnterpriseService.WorkspacePermissionService.get_permission(workspace_id)
if not permission.allow_owner_transfer:
+2 -2
View File
@@ -15942,7 +15942,7 @@ Retrieval settings for Amazon Bedrock knowledge base queries.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
| enabled | boolean | Deprecated. Use system features deployment_edition to determine the product edition. | Yes |
| subscription | [SubscriptionModel](#subscriptionmodel) | | Yes |
#### BillingResponse
@@ -24709,7 +24709,7 @@ FastOpenAPI proof of concept for Dify API
**Initialize system setup with admin account.
NOTE: This endpoint is unauthenticated by design for first-time bootstrap.
Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,
Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards,
and init-password validation rather than user session authentication.
**
+9 -1
View File
@@ -648,6 +648,14 @@ mode is a closed enum of listable app types.
| ---- | ---- | ----------- | -------- |
| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No |
#### DeploymentEdition
Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | Enum representing the deployment edition of the platform. | |
#### DeviceCodeRequest
| Name | Type | Description | Required |
@@ -974,7 +982,7 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| edition | string, <br>**Available values:** "CLOUD", "SELF_HOSTED" | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes |
| edition | [DeploymentEdition](#deploymentedition) | | Yes |
| version | string | | Yes |
#### SessionListQuery
@@ -53,7 +53,7 @@ class TraceClient:
attributes={
service_attributes.SERVICE_NAME: service_name,
service_attributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}",
DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}",
HOST_NAME: socket.gethostname(),
ACS_ARMS_SERVICE_FEATURE: "genai_app",
}
@@ -81,7 +81,7 @@ class TencentTraceClient:
attributes={
service_attributes.SERVICE_NAME: service_name,
service_attributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}",
DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}",
HOST_NAME: socket.gethostname(),
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "opentelemetry",
@@ -16,6 +16,8 @@ from dify_trace_tencent.entities.tencent_trace_entity import SpanData
from opentelemetry.sdk.trace import Event
from opentelemetry.trace import SpanContext, Status, StatusCode, TraceFlags
from enums import DeploymentEdition
metric_reader_instances: list[DummyMetricReader] = []
meter_provider_instances: list[DummyMeterProvider] = []
@@ -158,7 +160,7 @@ def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> PatchedCoreCompone
project=SimpleNamespace(version="test"),
COMMIT_SHA="sha",
DEPLOY_ENV="dev",
EDITION="cloud",
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
)
monkeypatch.setattr(client_module, "dify_config", fake_config)
+3 -4
View File
@@ -19,15 +19,14 @@ def clean_messages():
Clean expired messages based on clean policy.
This task uses MessagesCleanService to efficiently clean messages in batches.
The behavior depends on BILLING_ENABLED configuration:
- BILLING_ENABLED=True: only delete messages from sandbox tenants (with whitelist/grace period)
- BILLING_ENABLED=False: delete all messages within the time range
Cloud only deletes messages from sandbox tenants (with whitelist/grace period).
Self-hosted editions delete all messages within the configured time range.
"""
click.echo(click.style("clean_messages: start clean messages.", fg="green"))
start_at = time.perf_counter()
try:
# Create policy based on billing configuration
# Create policy based on deployment edition.
policy = create_message_clean_policy(
graceful_period_days=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD,
)
+1 -1
View File
@@ -10,7 +10,7 @@ import app
from configs import dify_config
from core.db.session_factory import session_factory
from core.rag.index_processor.index_processor_factory import IndexProcessorFactory
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_redis import redis_client
from libs.pagination import paginate_query
from models.dataset import Dataset, DatasetAutoDisableLog, DatasetQuery, Document
@@ -8,7 +8,7 @@ from sqlalchemy import select
import app
from configs import dify_config
from core.db.session_factory import session_factory
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_mail import mail
from libs.email_i18n import EmailType, get_email_i18n_service
from models import Account, Tenant, TenantAccountJoin
+8 -7
View File
@@ -22,6 +22,7 @@ from werkzeug.exceptions import Unauthorized
from configs import dify_config
from constants.languages import get_valid_language, language_timezone_mapping
from enums import DeploymentEdition
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client, redis_fallback
@@ -119,7 +120,7 @@ class InvitationDetailDict(TypedDict):
def _try_join_enterprise_default_workspace(account_id: str) -> None:
"""Best-effort join to enterprise default workspace."""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return
from services.enterprise.enterprise_service import try_join_default_workspace
@@ -378,7 +379,7 @@ class AccountService:
payload = {
"user_id": account.id,
"exp": exp,
"iss": dify_config.EDITION,
"iss": dify_config.DEPLOYMENT_EDITION.value,
"sub": "Console API Passport",
}
@@ -463,7 +464,7 @@ class AccountService:
if not FeatureService.get_license().seats.is_available():
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@@ -1053,7 +1054,7 @@ class AccountService:
@classmethod
def get_user_through_email(cls, email: str, *, session: Session):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
raise AccountRegisterError(
description=(
"This email account has been deleted within the past "
@@ -1072,7 +1073,7 @@ class AccountService:
@classmethod
def is_account_in_freeze(cls, email: str) -> bool:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
return True
return False
@@ -1395,7 +1396,7 @@ class TenantService:
session.add(ta)
session.commit()
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(tenant.id)
return ta
@@ -1828,7 +1829,7 @@ class TenantService:
account_email,
)
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(tenant.id)
# Queue account deletion sync task for enterprise backend to reassign resources (enterprise only)
+2 -2
View File
@@ -21,7 +21,7 @@ from core.app.features.rate_limiting import RateLimit
from core.app.features.rate_limiting.rate_limit import rate_limit_context
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.db import session_factory
from enums.quota_type import QuotaType
from enums import DeploymentEdition, QuotaType
from extensions.otel import AppGenerateHandler, trace_span
from models.model import Account, App, AppMode, EndUser
from models.workflow import Workflow, WorkflowRun
@@ -134,7 +134,7 @@ class AppGenerateService:
action: Callable[[RateLimit, str], Any],
):
quota_charge = unlimited()
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
try:
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id)
except QuotaExceededError:
+3 -2
View File
@@ -19,6 +19,7 @@ from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.model_manager import ModelManager
from core.tools.tool_manager import ToolManager
from core.tools.utils.configuration import ToolParameterConfigurationManager
from enums import DeploymentEdition
from events.app_event import app_was_created, app_was_deleted, app_was_updated
from extensions.ext_database import db # noqa: F401
from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
@@ -652,7 +653,7 @@ class AppService:
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(app.tenant_id)
return app
@@ -1056,7 +1057,7 @@ class AppService:
if FeatureService.get_system_features().webapp_auth.enabled:
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
if dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
BillingService.clean_billing_info_cache(app.tenant_id)
# Trigger asynchronous deletion of app and related data
+1 -1
View File
@@ -14,7 +14,7 @@ from celery.result import AsyncResult
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from enums.quota_type import QuotaType
from enums import QuotaType
from extensions.ext_database import db
from models.account import Account
from models.enums import CreatorUserRole, WorkflowTriggerStatus
+1 -1
View File
@@ -12,7 +12,7 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fix
from werkzeug.exceptions import InternalServerError
from core.helper.http_client_pooling import get_pooled_http_client
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_redis import redis_client
from libs.helper import RateLimiter
from models import Account, TenantAccountJoin, TenantAccountRole
@@ -10,7 +10,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from extensions.ext_storage import storage
from graphon.model_runtime.utils.encoders import jsonable_encoder
@@ -371,7 +371,7 @@ class ClearFreePlanTenantExpiredLogs:
def process_tenant(flask_app: Flask, tenant_id: str):
try:
if (
not dify_config.BILLING_ENABLED
dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD
or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX
):
# only process sandbox tenant
+2 -1
View File
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from core.errors.error import QuotaExceededError
from enums import DeploymentEdition
from extensions.ext_redis import redis_client
from models import TenantCreditPool
from models.enums import ProviderQuotaType
@@ -51,7 +52,7 @@ class CreditPoolService:
@staticmethod
def _use_billing_quota() -> bool:
return bool(dify_config.BILLING_ENABLED)
return bool(dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD)
@staticmethod
def _require_session(session: Session | None) -> Session:
+1 -1
View File
@@ -23,7 +23,7 @@ from core.model_manager import ModelManager
from core.rag.index_processor.constant.built_in_field import BuiltInField
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from events.dataset_event import dataset_was_deleted
from events.document_event import document_was_deleted
from extensions.ext_redis import redis_client
+1 -1
View File
@@ -4,7 +4,7 @@ from collections.abc import Callable
from functools import cached_property
from typing import Any, ClassVar
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
@@ -8,6 +8,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums import DeploymentEdition
from extensions.ext_redis import redis_client
from models.account import TenantAccountJoin
@@ -79,9 +80,9 @@ def sync_workspace_member_removal(workspace_id: str, member_id: str, *, source:
source: Source of the sync request (e.g., "workspace_member_removed")
Returns:
bool: True if task was queued (or skipped in community), False if queueing failed
bool: True if task was queued (or skipped outside the Enterprise edition), False if queueing failed
"""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return True
return _queue_task(workspace_id=workspace_id, member_id=member_id, source=source)
@@ -100,9 +101,9 @@ def sync_account_deletion(account_id: str, *, source: str, session: Session) ->
session: SQLAlchemy session used to fetch workspace memberships
Returns:
bool: True if all tasks were queued (or skipped in community), False if any queueing failed
bool: True if all tasks were queued (or skipped outside the Enterprise edition), False if any queueing failed
"""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return True
# Fetch all workspaces the account belongs to
@@ -9,6 +9,7 @@ from cachetools.func import ttl_cache
from pydantic import BaseModel, ConfigDict, Field, model_validator
from configs import dify_config
from enums import DeploymentEdition
from extensions.ext_redis import redis_client
from services.enterprise.base import (
EnterpriseRequest,
@@ -95,7 +96,7 @@ def try_join_default_workspace(account_id: str) -> None:
This is a best-effort integration. Failures must not block user registration.
"""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return
try:
@@ -373,9 +374,9 @@ class EnterpriseService:
caching, every request on an expired license would hit the enterprise API.
Returns:
LicenseStatus enum value, or None if enterprise is disabled / unreachable.
LicenseStatus enum value, or None outside the Enterprise edition or when unreachable.
"""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return None
cached = cls._read_cached_license_status()
+8 -3
View File
@@ -4,8 +4,7 @@ from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums import CloudPlan, DeploymentEdition
class FeatureResponseModel(BaseModel):
@@ -18,7 +17,13 @@ class SubscriptionModel(FeatureResponseModel):
class BillingModel(FeatureResponseModel):
enabled: bool = False
# Deprecated compatibility field. Deployment edition is the only source of truth for product edition.
# TODO: Remove after clients migrate to `SystemFeatureModel.deployment_edition`.
enabled: bool = Field(
default=False,
deprecated=True,
description="Deprecated. Use system features deployment_edition to determine the product edition.",
)
subscription: SubscriptionModel = SubscriptionModel()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Application service for Console feature queries."""
"""Application service for feature queries exposed by API adapters."""
from collections.abc import Sequence
from typing import Protocol
+15 -18
View File
@@ -4,9 +4,7 @@ from collections.abc import Mapping
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums.hosted_provider import HostedTrialProvider
from enums import CloudPlan, DeploymentEdition, HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
@@ -30,14 +28,14 @@ class FeatureService:
cls._fulfill_params_from_env(features)
if dify_config.BILLING_ENABLED and tenant_id:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id:
cls._fulfill_params_from_billing_api(
features,
tenant_id,
exclude_vector_space=exclude_vector_space,
)
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
features.webapp_copyright_enabled = True
features.knowledge_pipeline.publish_enabled = True
cls._fulfill_params_from_workspace_info(features, tenant_id)
@@ -52,7 +50,7 @@ class FeatureService:
@classmethod
def get_vector_space(cls, tenant_id: str) -> feature_entities.VectorSpaceLimitationModel:
vector_space = feature_entities.VectorSpaceLimitationModel(size=0, limit=5)
if dify_config.BILLING_ENABLED and tenant_id:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id:
billing_vector_space = BillingService.get_vector_space(tenant_id)
# NOTE: billing API returns vector_space.size as float (e.g. 0.0),
# but feature API keeps LimitationModel.size as int for compatibility.
@@ -65,7 +63,7 @@ class FeatureService:
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str):
knowledge_rate_limit = feature_entities.KnowledgeRateLimitModel()
if dify_config.BILLING_ENABLED and tenant_id:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id:
knowledge_rate_limit.enabled = True
limit_info = BillingService.get_knowledge_rate_limit(tenant_id)
knowledge_rate_limit.limit = limit_info.get("limit", 10)
@@ -75,7 +73,7 @@ class FeatureService:
@classmethod
def get_knowledge_file_size_limit(cls, tenant_id: str | None) -> int:
default_limit = dify_config.UPLOAD_FILE_SIZE_LIMIT
if not dify_config.BILLING_ENABLED or not tenant_id:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD or not tenant_id:
return default_limit
billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True)
@@ -91,7 +89,7 @@ class FeatureService:
def _resolve_human_input_email_delivery_enabled(
cls, *, features: feature_entities.FeatureModel, tenant_id: str | None
) -> bool:
if dify_config.ENTERPRISE_ENABLED or not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return True
if not tenant_id:
return False
@@ -107,7 +105,7 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
@@ -125,7 +123,7 @@ class FeatureService:
def is_workspace_creation_allowed(cls) -> bool:
"""Resolve the backend workspace-creation policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_CREATE_WORKSPACE
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
@@ -134,12 +132,12 @@ class FeatureService:
@classmethod
def is_plugin_manager_enabled(cls) -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.ENTERPRISE_ENABLED
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@classmethod
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if not dify_config.ENTERPRISE_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@@ -151,11 +149,10 @@ class FeatureService:
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if dify_config.ENTERPRISE_ENABLED:
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
else:
license_model = feature_entities.LicenseModel()
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseModel()
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
return license_model
@staticmethod
+1 -1
View File
@@ -1,4 +1,4 @@
"""Feature-query gateway backed by FeatureService."""
"""Feature-query gateway backed by the existing FeatureService."""
from typing import override
+2 -1
View File
@@ -18,6 +18,7 @@ from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
from core.plugin.plugin_service import PluginService
from core.provider_manager import ProviderManager
from enums import DeploymentEdition
from extensions import ext_hosting_provider
from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule
from models.provider import (
@@ -296,7 +297,7 @@ class ModelProviderService:
) -> ProviderType:
if state.preferred_provider_type is not None:
return state.preferred_provider_type
if dify_config.EDITION == "CLOUD" and system_enabled:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_enabled:
return ProviderType.SYSTEM
if custom_present:
return ProviderType.CUSTOM
+4 -3
View File
@@ -1,6 +1,6 @@
"""License gate for the /openapi/v1/permitted-external-apps* surface.
EE-only. CE deploys (``ENTERPRISE_ENABLED=false``) skip the gate entirely —
Enterprise-edition only. Community and Cloud deployments skip the gate entirely —
the EE blueprint chain is what gives CE deploys no callers on this surface
in practice, but the explicit short-circuit avoids any test/fixture that
flips the surface on without flipping the license.
@@ -22,6 +22,7 @@ from functools import wraps
from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
@@ -32,12 +33,12 @@ _VALID_LICENSE_STATUSES: frozenset[LicenseStatus] = frozenset({LicenseStatus.ACT
def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Decorator form. Raises ``Forbidden('license_required')`` when the EE
deployment has no valid license. No-op on CE (``ENTERPRISE_ENABLED=false``).
deployment has no valid license. No-op outside the Enterprise edition.
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if dify_config.ENTERPRISE_ENABLED and not _is_license_valid():
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and not _is_license_valid():
raise Forbidden(description="license_required")
return view(*args, **kwargs)
+5 -10
View File
@@ -3,12 +3,9 @@ from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from configs import dify_config
if TYPE_CHECKING:
from enums.quota_type import QuotaType
from enums import DeploymentEdition, QuotaType
logger = logging.getLogger(__name__)
@@ -88,8 +85,6 @@ class QuotaCharge:
def unlimited() -> QuotaCharge:
from enums.quota_type import QuotaType
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
@@ -123,8 +118,8 @@ class QuotaService:
from services.billing_service import BillingService
from services.errors.app import QuotaExceededError
if not dify_config.BILLING_ENABLED:
logger.debug("Billing disabled, allowing request for %s", tenant_id)
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
logger.debug("Quota billing is unavailable outside the Cloud edition; allowing request for %s", tenant_id)
return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type)
logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id)
@@ -179,7 +174,7 @@ class QuotaService:
@staticmethod
def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool:
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return True
if amount <= 0:
@@ -198,7 +193,7 @@ class QuotaService:
try:
from services.billing_service import BillingService
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
if not reservation_id:
@@ -5,7 +5,7 @@ from functools import cached_property
from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity
from core.rag.pipeline.queue import TenantIsolatedTaskQueue
from enums.cloud_plan import CloudPlan
from enums import CloudPlan
from extensions.ext_database import db
from services.feature_service import FeatureService
from services.file_service import FileService
+1 -1
View File
@@ -4,7 +4,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from enums import DeploymentEdition
from models.model import AccountTrialAppRecord, App, TrialApp
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Protocol, override
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from services.billing_service import BillingService, SubscriptionPlan
logger = logging.getLogger(__name__)
@@ -45,7 +45,7 @@ class MessagesCleanPolicy(Protocol):
class BillingDisabledPolicy(MessagesCleanPolicy):
"""
Policy for community or enterpriseedition (billing disabled).
Policy for self-hosted editions, which do not use Cloud billing plans.
No special filter logic, just return all message ids.
"""
@@ -61,7 +61,7 @@ class BillingDisabledPolicy(MessagesCleanPolicy):
class BillingSandboxPolicy(MessagesCleanPolicy):
"""
Policy for sandbox plan tenants in cloud edition (billing enabled).
Policy for sandbox-plan tenants in the Cloud edition.
Filters messages based on sandbox plan expiration rules:
- Skip tenants in the whitelist
@@ -186,24 +186,22 @@ def create_message_clean_policy(
"""
Factory function to create the appropriate message clean policy.
Determines which policy to use based on BILLING_ENABLED configuration:
- If BILLING_ENABLED is True: returns BillingSandboxPolicy
- If BILLING_ENABLED is False: returns BillingDisabledPolicy
Cloud uses BillingSandboxPolicy; self-hosted editions use BillingDisabledPolicy.
Args:
graceful_period_days: Grace period in days after subscription expiration (default: 21)
current_timestamp: Current Unix timestamp for testing (default: None, uses current time)
"""
if not dify_config.BILLING_ENABLED:
logger.info("create_message_clean_policy: billing disabled, using BillingDisabledPolicy")
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
logger.info("create_message_clean_policy: self-hosted edition, using BillingDisabledPolicy")
return BillingDisabledPolicy()
# Billing enabled - fetch whitelist from BillingService
# Cloud deployment - fetch whitelist from BillingService.
tenant_whitelist = BillingService.get_expired_subscription_cleanup_whitelist()
plan_provider = BillingService.get_plan_bulk_with_cache
logger.info(
"create_message_clean_policy: billing enabled, using BillingSandboxPolicy "
"create_message_clean_policy: Cloud edition, using BillingSandboxPolicy "
"(graceful_period_days=%s, whitelist=%s)",
graceful_period_days,
tenant_whitelist,
@@ -169,8 +169,8 @@ class MessagesCleanService:
"""
Service for cleaning expired messages based on retention policies.
Compatible with non cloud edition (billing disabled): all messages in the time range will be deleted.
If billing is enabled: only sandbox plan tenant messages are deleted (with whitelist and grace period support).
In self-hosted editions, all messages in the time range are deleted.
In the Cloud edition, only sandbox-plan tenant messages are deleted, with whitelist and grace-period support.
"""
def __init__(
@@ -46,7 +46,7 @@ from sqlalchemy import inspect, select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from graphon.enums import WorkflowType
from libs.archive_storage import (
@@ -535,8 +535,8 @@ class WorkflowRunArchiver:
if self.paid_tenant_ids is not None:
return tenant_ids & self.paid_tenant_ids
if not dify_config.BILLING_ENABLED:
# If billing is not enabled, treat all tenants as paid
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
# Self-hosted editions have no Cloud billing plans, so treat all tenants as paid.
return tenant_ids
if not tenant_ids:
@@ -16,7 +16,7 @@ import click
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums import CloudPlan, DeploymentEdition
from extensions.ext_database import db
from repositories.api_workflow_run_repository import (
APIWorkflowRunRepository,
@@ -478,7 +478,7 @@ class WorkflowRunCleanup:
def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]:
tenant_id_list = sorted(set(tenant_ids))
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return set(tenant_id_list)
if not tenant_id_list:
@@ -538,7 +538,7 @@ class WorkflowRunCleanup:
if self._cleanup_whitelist is not None:
return self._cleanup_whitelist
if not dify_config.BILLING_ENABLED:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
self._cleanup_whitelist = set()
return self._cleanup_whitelist
+3 -2
View File
@@ -8,7 +8,9 @@ from typing import Any
from sqlalchemy import delete, event, func, select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
from enums import DeploymentEdition
from graphon.enums import BuiltinNodeTypes, NodeType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account, TagBinding
@@ -150,10 +152,9 @@ class SnippetService:
@staticmethod
def _delete_archived_workflow_run_files(*, snippet: CustomizedSnippet) -> None:
from configs import dify_config
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
if not (dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED):
if not (dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ARCHIVE_STORAGE_ENABLED):
return
prefix = f"{snippet.tenant_id}/app_id={snippet.id}/"
+3 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums import DeploymentEdition
from libs.datetime_utils import naive_utc_now
from models.model import DifySetup
@@ -74,8 +75,7 @@ class CommunityTelemetryService:
@classmethod
def _is_enabled(cls) -> bool:
return (
dify_config.EDITION == "SELF_HOSTED"
and not dify_config.ENTERPRISE_ENABLED
dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY
and not dify_config.DISABLE_TELEMETRY
and not dify_config.DO_NOT_TRACK
and not dify_config.CI
@@ -88,7 +88,7 @@ class CommunityTelemetryService:
"event": event,
"instance_id": setup.instance_id or "",
"version": setup.version if event == "install" else dify_config.project.version,
"edition": dify_config.EDITION,
"edition": dify_config.DEPLOYMENT_EDITION.value,
"deployment_type": "unknown",
"schema_version": SCHEMA_VERSION,
"os": cls._normalize_os(platform.system()),
@@ -31,6 +31,7 @@ from core.tools.utils.system_encryption import decrypt_system_params
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.account import Account
from models.enums import PermissionEnum
from models.provider_ids import ToolProviderID
from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient
from services.tools.tools_transform_service import ToolTransformService
@@ -282,8 +283,6 @@ class BuiltinToolManageService:
cache=NoOpProviderCredentialCache(),
)
from models.enums import PermissionEnum
visibility_enum = PermissionEnum(visibility) if visibility else PermissionEnum.ALL_TEAM
# Plugin credentials only expose only_me / all_team_members at creation;
# partial-member access is handled by workspace RBAC, not per-credential.
@@ -259,7 +259,7 @@ class MCPToolManageService:
mcp_provider.encrypted_credentials = self._process_credentials(authentication, mcp_provider, tenant_id)
# Update user-identity forwarding mode. The controller has already
# resolved "leave unchanged" and applied the ENTERPRISE_ENABLED gate,
# resolved "leave unchanged" and applied the Enterprise-edition gate,
# so this is always a concrete, vetted value.
mcp_provider.identity_mode = identity_mode
+1 -1
View File
@@ -23,7 +23,7 @@ from core.workflow.nodes.trigger_webhook.entities import (
WebhookData,
WebhookParameter,
)
from enums.quota_type import QuotaType
from enums import QuotaType
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from factories import file_factory
@@ -15,8 +15,7 @@ from core.rag.datasource.vdb.vector_type import VectorType
from core.rag.embedding.cached_embedding import CacheEmbedding
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from core.rag.models.document import Document
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums import CloudPlan, DeploymentEdition
from extensions.ext_redis import redis_client
from graphon.model_runtime.entities.model_entities import ModelType
from models.dataset import Dataset
@@ -248,7 +247,6 @@ class VectorSpaceAdmissionService:
) -> None:
if (
dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD
or not dify_config.BILLING_ENABLED
or dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY
or workload.total_points == 0
or workload.probe_text is None
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more