From 8acdb1538ba45ac1dca265bc4b80e560edbda7de Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 8 Sep 2026 05:42:22 +0000
Subject: [PATCH] refactor(api): trim workspace features response contract
(#41948)
---
api/controllers/console/feature.py | 8 +-
api/controllers/console/workspace/members.py | 8 +-
api/openapi/markdown/console-openapi.md | 15 ++-
api/services/billing_service.py | 11 --
api/services/entities/feature_entities.py | 17 +--
api/services/feature_service.py | 11 --
.../services/test_feature_service.py | 102 ------------------
.../controllers/console/test_feature.py | 18 +++-
.../console/workspace/test_members.py | 23 ++--
.../services/test_billing_service.py | 23 +---
.../test_feature_service_vector_space.py | 3 -
.../api/console/features/orpc.gen.ts | 8 +-
.../api/console/features/types.gen.ts | 5 -
.../generated/api/console/features/zod.gen.ts | 7 +-
14 files changed, 64 insertions(+), 195 deletions(-)
diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py
index c735420c476..38e904cd6af 100644
--- a/api/controllers/console/feature.py
+++ b/api/controllers/console/feature.py
@@ -41,7 +41,7 @@ register_response_schema_models(
@console_ns.route("/features")
class FeatureApi(Resource):
@console_ns.doc("get_tenant_features")
- @console_ns.doc(description="Get feature configuration for current tenant")
+ @console_ns.doc(description="Get feature availability and limits for the current workspace")
@console_ns.response(
200,
"Success",
@@ -50,10 +50,8 @@ class FeatureApi(Resource):
@console_account_admission()
@cloud_utm_record
def get(self, request_context: RequestContext):
- """Get feature configuration for current tenant"""
- payload = application_services().feature_queries.get_features(request_context).model_dump()
- payload.pop("vector_space", None)
- return payload
+ """Get current workspace features."""
+ return dump_response(FeatureModel, application_services().feature_queries.get_features(request_context))
@console_ns.route("/features/vector-space")
diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py
index f32bc47cc42..6587cce55fe 100644
--- a/api/controllers/console/workspace/members.py
+++ b/api/controllers/console/workspace/members.py
@@ -142,10 +142,10 @@ register_response_schema_models(
)
-def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
+def _is_role_enabled(role: TenantAccountRole | str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
- return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled
+ return dify_config.DATASET_OPERATOR_ENABLED
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> tuple[int, int]:
@@ -254,7 +254,7 @@ 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):
+ if not _is_role_enabled(invitee_role):
raise InvalidMemberRoleError()
# Check workspace permission for member invitations
@@ -374,7 +374,7 @@ class MemberUpdateRoleApi(Resource):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
if not current_user.current_tenant:
raise ValueError("No current tenant")
- if not _is_role_enabled(new_role, current_user.current_tenant.id):
+ if not _is_role_enabled(new_role):
return {"code": "invalid-role", "message": "Invalid role"}, HTTPStatus.BAD_REQUEST
member = db.session.get(Account, str(member_id))
if not member:
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 151dcef37f2..c60f5a5d8a6 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -6552,7 +6552,9 @@ Check if dataset is in use
| 404 | Recommended app not found | |
### [GET] /features
-**Get feature configuration for current tenant**
+**Get current workspace features**
+
+Get feature availability and limits for the current workspace
#### Responses
@@ -18453,6 +18455,8 @@ Flask blueprint initialization.
#### FeatureModel
+Effective feature availability and limits for the current workspace.
+
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| annotation_quota_limit | [LimitationModel](#limitationmodel) | | Yes |
@@ -18460,20 +18464,15 @@ Flask blueprint initialization.
| apps | [LimitationModel](#limitationmodel) | | Yes |
| billing | [BillingModel](#billingmodel) | | Yes |
| can_replace_logo | boolean | | Yes |
-| dataset_operator_enabled | boolean | | Yes |
-| docs_processing | string,
**Default:** standard | | Yes |
| documents_upload_quota | [LimitationModel](#limitationmodel) | | Yes |
| education | [EducationModel](#educationmodel) | | Yes |
| enable_skill | boolean,
**Default:** true | | Yes |
| human_input_email_delivery_enabled | boolean | | Yes |
| is_allow_transfer_workspace | boolean,
**Default:** true | | Yes |
| knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes |
-| knowledge_rate_limit | integer,
**Default:** 10 | | Yes |
| members | [LimitationModel](#limitationmodel) | | Yes |
| model_load_balancing_enabled | boolean | | Yes |
-| next_credit_reset_date | integer | | Yes |
| trigger_event | [Quota](#quota) | | Yes |
-| vector_space | [LimitationModel](#limitationmodel) | | Yes |
| webapp_copyright_enabled | boolean | | Yes |
| workspace_members | [LicenseLimitationModel](#licenselimitationmodel) | | Yes |
@@ -21343,8 +21342,8 @@ Payload for publishing snippet workflow.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
-| limit | integer | | Yes |
-| reset_date | integer,
**Default:** -1 | | Yes |
+| limit | integer | Quota limit; -1 means unlimited and 0 means no quota | Yes |
+| reset_date | integer,
**Default:** -1 | Next quota reset as a Unix timestamp in seconds; -1 means no reset | Yes |
| usage | integer | | Yes |
#### QuotaConfiguration
diff --git a/api/services/billing_service.py b/api/services/billing_service.py
index 25965bbccfe..c16f5b5142c 100644
--- a/api/services/billing_service.py
+++ b/api/services/billing_service.py
@@ -147,15 +147,6 @@ class _VectorSpaceQuota(TypedDict):
usage_unknown: NotRequired[bool]
-class _KnowledgeRateLimit(TypedDict):
- # NOTE (hj24):
- # 1. Return for sandbox users but is null for other plans, it's defined but never used.
- # 2. Keep it for compatibility for now, can be deprecated in future versions.
- size: NotRequired[int]
- # NOTE END
- limit: int
-
-
class _BillingSubscription(TypedDict):
plan: str
interval: str
@@ -177,10 +168,8 @@ class BillingInfo(TypedDict):
members: _BillingQuota
apps: _BillingQuota
vector_space: NotRequired[_VectorSpaceQuota]
- knowledge_rate_limit: _KnowledgeRateLimit
documents_upload_quota: _BillingQuota
annotation_quota_limit: _BillingQuota
- docs_processing: str
can_replace_logo: bool
model_load_balancing_enabled: bool
knowledge_pipeline_publish_enabled: bool
diff --git a/api/services/entities/feature_entities.py b/api/services/entities/feature_entities.py
index a963240e2ad..55347cfe9d4 100644
--- a/api/services/entities/feature_entities.py
+++ b/api/services/entities/feature_entities.py
@@ -66,8 +66,10 @@ class LicenseLimitationModel(FeatureResponseModel):
class Quota(FeatureResponseModel):
usage: int = 0
- limit: int = 0
- reset_date: int = -1
+ limit: int = Field(default=0, description="Quota limit; -1 means unlimited and 0 means no quota")
+ reset_date: int = Field(
+ default=-1, description="Next quota reset as a Unix timestamp in seconds; -1 means no reset"
+ )
class LicenseStatus(StrEnum):
@@ -142,19 +144,19 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
class FeatureModel(FeatureResponseModel):
+ """Effective feature availability and limits for the current workspace."""
+
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
enable_skill: bool = True
members: LimitationModel = LimitationModel(size=0, limit=1)
apps: LimitationModel = LimitationModel(size=0, limit=10)
- vector_space: LimitationModel | None = LimitationModel(size=0, limit=5)
- knowledge_rate_limit: int = 10
+ # Indexing tasks still consume this internally; Console queries vector usage separately.
+ vector_space: LimitationModel | None = Field(default=LimitationModel(size=0, limit=5), exclude=True)
annotation_quota_limit: LimitationModel = LimitationModel(size=0, limit=10)
documents_upload_quota: LimitationModel = LimitationModel(size=0, limit=50)
- docs_processing: str = "standard"
can_replace_logo: bool = False
model_load_balancing_enabled: bool = False
- dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
is_allow_transfer_workspace: bool = True
@@ -163,7 +165,8 @@ class FeatureModel(FeatureResponseModel):
# Controls whether email delivery is allowed for HumanInput nodes.
human_input_email_delivery_enabled: bool = False
knowledge_pipeline: KnowledgePipeline = KnowledgePipeline()
- next_credit_reset_date: int = 0
+ # Workspace and model-provider credit responses own the public reset-date contract.
+ next_credit_reset_date: int = Field(default=0, exclude=True)
class KnowledgeRateLimitModel(FeatureResponseModel):
diff --git a/api/services/feature_service.py b/api/services/feature_service.py
index dd9f84f8cd9..eda3577a64d 100644
--- a/api/services/feature_service.py
+++ b/api/services/feature_service.py
@@ -112,7 +112,6 @@ class FeatureService:
def _fulfill_params_from_env(cls, features: feature_entities.FeatureModel):
features.can_replace_logo = dify_config.CAN_REPLACE_LOGO
features.model_load_balancing_enabled = dify_config.MODEL_LB_ENABLED
- features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED
features.education.enabled = dify_config.EDUCATION_ENABLED
features.enable_skill = dify_config.ENABLE_SKILL
@@ -177,22 +176,12 @@ class FeatureService:
features.annotation_quota_limit.size = billing_info["annotation_quota_limit"]["size"]
features.annotation_quota_limit.limit = billing_info["annotation_quota_limit"]["limit"]
- if "docs_processing" in billing_info:
- features.docs_processing = billing_info["docs_processing"]
-
if "can_replace_logo" in billing_info:
features.can_replace_logo = billing_info["can_replace_logo"]
if "model_load_balancing_enabled" in billing_info:
features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"]
- if "knowledge_rate_limit" in billing_info:
- # NOTE (hj24):
- # 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used.
- # 2. So be careful if later we decide to use [size], we cannot assume it is always present.
- features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
- # NOTE END
-
if "knowledge_pipeline_publish_enabled" in billing_info:
features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"]
diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py
index edbca68e194..0ea4ac3a058 100644
--- a/api/tests/test_containers_integration_tests/services/test_feature_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py
@@ -36,10 +36,8 @@ class TestFeatureService:
"vector_space": {"size": 2, "limit": 10},
"documents_upload_quota": {"size": 15, "limit": 100},
"annotation_quota_limit": {"size": 8, "limit": 50},
- "docs_processing": "enhanced",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
- "knowledge_rate_limit": {"limit": 100},
}
mock_billing_service.get_knowledge_rate_limit.return_value = {
@@ -106,7 +104,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = True
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = True
# Act: Execute the method under test
@@ -142,10 +139,8 @@ class TestFeatureService:
assert result.annotation_quota_limit.limit == 50
# Verify other features
- assert result.docs_processing == "enhanced"
assert result.can_replace_logo is True
assert result.model_load_balancing_enabled is True
- assert result.knowledge_rate_limit == 100
# Enterprise workspace features are not loaded in Cloud.
assert result.workspace_members.enabled is False
@@ -177,7 +172,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = False
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = False
mock_config.EDUCATION_ENABLED = False
# Set mock return value inside the patch context
@@ -188,10 +182,8 @@ class TestFeatureService:
"vector_space": {"size": 1, "limit": 2},
"documents_upload_quota": {"size": 5, "limit": 20},
"annotation_quota_limit": {"size": 2, "limit": 10},
- "docs_processing": "standard",
"can_replace_logo": False,
"model_load_balancing_enabled": False,
- "knowledge_rate_limit": {"limit": 10},
}
# Act: Execute the method under test
@@ -218,8 +210,6 @@ class TestFeatureService:
assert result.is_allow_transfer_workspace is False
assert result.can_replace_logo is False
assert result.model_load_balancing_enabled is False
- assert result.docs_processing == "standard"
- assert result.knowledge_rate_limit == 10
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
@@ -495,7 +485,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = True
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = True
tenant_id = self._create_test_tenant_id()
@@ -510,7 +499,6 @@ class TestFeatureService:
# Verify environment-based features
assert result.can_replace_logo is True
assert result.model_load_balancing_enabled is True
- assert result.dataset_operator_enabled is True
assert result.education.enabled is True
# Verify default limitations
@@ -524,8 +512,6 @@ class TestFeatureService:
assert result.documents_upload_quota.limit == 50
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10
- assert result.knowledge_rate_limit == 10
- assert result.docs_processing == "standard"
# Verify no enterprise features
assert result.workspace_members.enabled is False
@@ -580,7 +566,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.CAN_REPLACE_LOGO = False
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = False
mock_config.EDUCATION_ENABLED = False
tenant_id = self._create_test_tenant_id()
@@ -603,7 +588,6 @@ class TestFeatureService:
# Verify environment-based features
assert result.can_replace_logo is False
assert result.model_load_balancing_enabled is False
- assert result.dataset_operator_enabled is False
assert result.education.enabled is False
# Verify default limitations
@@ -691,7 +675,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
# Act: Execute the method under test
@@ -704,7 +687,6 @@ class TestFeatureService:
# Verify environment-based features
assert result.can_replace_logo is True
assert result.model_load_balancing_enabled is False
- assert result.dataset_operator_enabled is True
assert result.education.enabled is False
# Verify default limitations
@@ -737,7 +719,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -767,8 +748,6 @@ class TestFeatureService:
assert result.documents_upload_quota.limit == 50
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10
- assert result.knowledge_rate_limit == 10
- assert result.docs_processing == "standard"
# Verify paid plan behavior.
assert result.webapp_copyright_enabled is True
@@ -796,7 +775,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -831,8 +809,6 @@ class TestFeatureService:
assert result.documents_upload_quota.limit == 50
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10
- assert result.knowledge_rate_limit == 10
- assert result.docs_processing == "standard"
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
@@ -912,7 +888,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -947,8 +922,6 @@ class TestFeatureService:
assert result.documents_upload_quota.limit == 50
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10
- assert result.knowledge_rate_limit == 10
- assert result.docs_processing == "standard"
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
@@ -1231,7 +1204,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -1468,62 +1440,6 @@ class TestFeatureService:
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
- def test_get_features_edge_case_docs_processing(
- self, db_session_with_containers: Session, mock_external_service_dependencies
- ):
- """
- Test feature retrieval with edge case document processing configuration.
-
- This test verifies:
- - Proper handling of different document processing modes
- - Correct integration with billing service
- - Proper fallback to default values
- - Return value correctness and structure
- """
- # Arrange: Setup edge case docs processing mock with proper config
- tenant_id = self._create_test_tenant_id()
-
- with patch("services.feature_service.dify_config") as mock_config:
- mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
- mock_config.CAN_REPLACE_LOGO = True
- mock_config.MODEL_LB_ENABLED = True
- mock_config.DATASET_OPERATOR_ENABLED = True
- mock_config.EDUCATION_ENABLED = False
-
- mock_external_service_dependencies["billing_service"].get_info.return_value = {
- "subscription": {"plan": CloudPlan.TEAM, "interval": "monthly"},
- "docs_processing": "advanced",
- "can_replace_logo": True,
- "model_load_balancing_enabled": True,
- }
-
- # Act: Execute the method under test
- result = FeatureService.get_features(tenant_id)
-
- # Assert: Verify the expected outcomes
- assert result is not None
- assert isinstance(result, FeatureModel)
-
- # Verify docs processing configuration
- assert result.docs_processing == "advanced"
- assert result.can_replace_logo is True
- assert result.model_load_balancing_enabled is True
-
- # Verify paid plan behavior.
- assert result.webapp_copyright_enabled is True
- assert result.is_allow_transfer_workspace is True
-
- # Verify default limitations (no specific billing info)
- assert result.members.size == 0
- assert result.members.limit == 1
- assert result.apps.size == 0
- assert result.apps.limit == 10
- assert result.vector_space.size == 0
- assert result.vector_space.limit == 5
-
- # Verify mock interactions
- mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
-
def test_get_system_features_edge_case_branding(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -1604,13 +1520,11 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
"subscription": {"plan": CloudPlan.TEAM, "interval": "yearly"},
"annotation_quota_limit": {"size": 999, "limit": 1000},
- "knowledge_rate_limit": {"limit": 500},
}
# Act: Execute the method under test
@@ -1624,9 +1538,6 @@ class TestFeatureService:
assert result.annotation_quota_limit.size == 999
assert result.annotation_quota_limit.limit == 1000
- # Verify knowledge rate limit
- assert result.knowledge_rate_limit == 500
-
# Verify paid plan behavior.
assert result.webapp_copyright_enabled is True
assert result.is_allow_transfer_workspace is True
@@ -1640,7 +1551,6 @@ class TestFeatureService:
assert result.vector_space.limit == 5
assert result.documents_upload_quota.size == 0
assert result.documents_upload_quota.limit == 50
- assert result.docs_processing == "standard"
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
@@ -1664,7 +1574,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -1673,7 +1582,6 @@ class TestFeatureService:
"size": 0, # Edge case: zero current size
"limit": 0, # Edge case: zero limit
},
- "knowledge_rate_limit": {"limit": 100},
}
# Act: Execute the method under test
@@ -1687,9 +1595,6 @@ class TestFeatureService:
assert result.documents_upload_quota.size == 0
assert result.documents_upload_quota.limit == 0
- # Verify knowledge rate limit
- assert result.knowledge_rate_limit == 100
-
# Verify paid plan behavior.
assert result.webapp_copyright_enabled is True
assert result.is_allow_transfer_workspace is True
@@ -1703,7 +1608,6 @@ class TestFeatureService:
assert result.vector_space.limit == 5
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10 # Default value when not provided
- assert result.docs_processing == "standard"
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
@@ -1778,7 +1682,6 @@ class TestFeatureService:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_config.CAN_REPLACE_LOGO = True
mock_config.MODEL_LB_ENABLED = False
- mock_config.DATASET_OPERATOR_ENABLED = True
mock_config.EDUCATION_ENABLED = False
mock_external_service_dependencies["billing_service"].get_info.return_value = {
@@ -1787,7 +1690,6 @@ class TestFeatureService:
"interval": "monthly",
"education": False, # Education explicitly disabled
},
- "knowledge_rate_limit": {"limit": 100},
}
# Act: Execute the method under test
@@ -1800,9 +1702,6 @@ class TestFeatureService:
# Verify education configuration
assert result.education.activated is False
- # Verify knowledge rate limit
- assert result.knowledge_rate_limit == 100
-
# Verify paid plan behavior.
assert result.webapp_copyright_enabled is True
assert result.is_allow_transfer_workspace is True
@@ -1818,7 +1717,6 @@ class TestFeatureService:
assert result.documents_upload_quota.limit == 50
assert result.annotation_quota_limit.size == 0
assert result.annotation_quota_limit.limit == 10 # Default value when not provided
- assert result.docs_processing == "standard"
# Verify mock interactions
mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id)
diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py
index 5afc9fc1de4..bf5ec1fe354 100644
--- a/api/tests/unit_tests/controllers/console/test_feature.py
+++ b/api/tests/unit_tests/controllers/console/test_feature.py
@@ -39,8 +39,9 @@ class TestFeatureApi:
from controllers.console.feature import FeatureApi
features = FeatureModel(
- knowledge_rate_limit=42,
+ apps=LimitationModel(size=3, limit=10),
vector_space=LimitationModel(size=1, limit=2),
+ next_credit_reset_date=1775001600,
)
feature_queries = _install_application_services(mocker)
get_features = feature_queries.get_features
@@ -52,9 +53,18 @@ class TestFeatureApi:
request_context = _request_context()
result = raw_get(api, request_context)
- expected = features.model_dump()
- expected.pop("vector_space")
- assert result == expected
+ assert result == features.model_dump(mode="json")
+ assert result["apps"] == {"size": 3, "limit": 10}
+ retired_fields = {
+ "vector_space",
+ "next_credit_reset_date",
+ "docs_processing",
+ "knowledge_rate_limit",
+ "dataset_operator_enabled",
+ }
+ schema = FeatureModel.model_json_schema(mode="serialization")
+ assert retired_fields.isdisjoint(result)
+ assert set(schema["properties"]) == set(schema["required"]) == set(result)
get_features.assert_called_once_with(request_context)
diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py
index aedf0c12fc8..20f9df2e652 100644
--- a/api/tests/unit_tests/controllers/console/workspace/test_members.py
+++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py
@@ -150,7 +150,9 @@ class TestMemberInviteEmailApi:
with patch("controllers.console.workspace.members.redis_client.lock", return_value=nullcontext()):
yield
- def test_invite_success(self, app: Flask):
+ @pytest.mark.parametrize("role", ["normal", "dataset_operator"])
+ def test_invite_success(self, app: Flask, config_overrides, role: str):
+ config_overrides(DATASET_OPERATOR_ENABLED=True)
api = MemberInviteEmailApi()
method = unwrap(api.post)
@@ -162,7 +164,7 @@ class TestMemberInviteEmailApi:
payload = {
"emails": ["A@TEST.com", "a@test.com"],
- "role": "normal",
+ "role": role,
"language": "en-US",
}
@@ -182,6 +184,7 @@ class TestMemberInviteEmailApi:
mock_count.assert_not_called()
mock_invite.assert_called_once()
assert mock_invite.call_args.kwargs["email"] == "a@test.com"
+ assert mock_invite.call_args.kwargs["role"] == role
def test_invite_limit_exceeded(self, app: Flask, config_overrides):
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
@@ -263,18 +266,20 @@ class TestMemberInviteEmailApi:
assert result["invitation_results"][0]["status"] == "already_member"
assert result["invitation_results"][0]["message"] == "Account already in workspace."
- def test_invite_invalid_role(self, app: Flask):
+ @pytest.mark.parametrize("role", ["owner", "dataset_operator"])
+ def test_invite_invalid_role(self, app: Flask, config_overrides, role: str):
+ config_overrides(DATASET_OPERATOR_ENABLED=False)
api = MemberInviteEmailApi()
method = unwrap(api.post)
payload = {
"emails": ["a@test.com"],
- "role": "owner",
+ "role": role,
}
with app.test_request_context("/", json=payload):
with pytest.raises(InvalidMemberRoleError) as exc_info:
- method(api, _account())
+ method(api, _account(tenant=_tenant()))
assert exc_info.value.error_code == "invalid_role"
@@ -540,14 +545,16 @@ class TestCountNewMemberInvites:
class TestMemberUpdateRoleApi:
- def test_update_invalid_role(self, app: Flask):
+ @pytest.mark.parametrize("role", ["invalid-role", "dataset_operator"])
+ def test_update_invalid_role(self, app: Flask, config_overrides, role: str):
+ config_overrides(DATASET_OPERATOR_ENABLED=False)
api = MemberUpdateRoleApi()
method = unwrap(api.put)
- payload = {"role": "invalid-role"}
+ payload = {"role": role}
with app.test_request_context("/", json=payload):
- result, status = method(api, _account(), "id")
+ result, status = method(api, _account(tenant=_tenant()), "id")
assert status == 400
diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py
index e8027fcda06..0e10220635b 100644
--- a/api/tests/unit_tests/services/test_billing_service.py
+++ b/api/tests/unit_tests/services/test_billing_service.py
@@ -434,16 +434,18 @@ class TestBillingServiceSubscriptionInfo:
"members": {"size": 1, "limit": 50},
"apps": {"size": 1, "limit": 200},
"vector_space": {"size": 0.0, "limit": 20480},
- "knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 0, "limit": 1000},
"annotation_quota_limit": {"size": 0, "limit": 5000},
- "docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
"next_credit_reset_date": 1775952000,
}
- mock_send_request.return_value = expected_response
+ mock_send_request.return_value = {
+ **expected_response,
+ "docs_processing": "top-priority",
+ "knowledge_rate_limit": {"limit": 1000},
+ }
# Act
result = BillingService.get_info(tenant_id)
@@ -460,10 +462,8 @@ class TestBillingServiceSubscriptionInfo:
"subscription": {"plan": "professional", "interval": "month", "education": False},
"members": {"size": 1, "limit": 50},
"apps": {"size": 1, "limit": 200},
- "knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 0, "limit": 1000},
"annotation_quota_limit": {"size": 0, "limit": 5000},
- "docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
@@ -490,10 +490,8 @@ class TestBillingServiceSubscriptionInfo:
"members": {"size": 1, "limit": 50},
"apps": {"size": 1, "limit": 200},
"vector_space": None,
- "knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 0, "limit": 1000},
"annotation_quota_limit": {"size": 0, "limit": 5000},
- "docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
@@ -545,10 +543,8 @@ class TestBillingServiceSubscriptionInfo:
"members": {"size": 1, "limit": 1},
"apps": {"size": 1, "limit": 10},
"vector_space": {"size": 0.0, "limit": 50, "usage_unknown": True},
- "knowledge_rate_limit": {"limit": 10},
"documents_upload_quota": {"size": 1, "limit": 50},
"annotation_quota_limit": {"size": 0, "limit": 10},
- "docs_processing": "standard",
"can_replace_logo": False,
"model_load_balancing_enabled": False,
"knowledge_pipeline_publish_enabled": False,
@@ -1750,10 +1746,8 @@ class TestBillingServiceIntegrationScenarios:
"members": {"size": 0, "limit": 1},
"apps": {"size": 0, "limit": 5},
"vector_space": {"size": 0.0, "limit": 50},
- "knowledge_rate_limit": {"limit": 10},
"documents_upload_quota": {"size": 0, "limit": 50},
"annotation_quota_limit": {"size": 0, "limit": 10},
- "docs_processing": "standard",
"can_replace_logo": False,
"model_load_balancing_enabled": False,
"knowledge_pipeline_publish_enabled": False,
@@ -1825,10 +1819,8 @@ class TestBillingServiceSubscriptionInfoDataType:
"members": {"size": 10, "limit": 50},
"apps": {"size": 80, "limit": 200},
"vector_space": {"size": 5120.75, "limit": 20480},
- "knowledge_rate_limit": {"limit": 1000},
"documents_upload_quota": {"size": 450, "limit": 1000},
"annotation_quota_limit": {"size": 1200, "limit": 5000},
- "docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
@@ -1846,10 +1838,8 @@ class TestBillingServiceSubscriptionInfoDataType:
"members": {"size": "10", "limit": "50"},
"apps": {"size": "80", "limit": "200"},
"vector_space": {"size": 5120.75, "limit": "20480"},
- "knowledge_rate_limit": {"limit": "1000"},
"documents_upload_quota": {"size": "450", "limit": "1000"},
"annotation_quota_limit": {"size": "1200", "limit": "5000"},
- "docs_processing": "top-priority",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
"knowledge_pipeline_publish_enabled": True,
@@ -1874,15 +1864,12 @@ class TestBillingServiceSubscriptionInfoDataType:
if "usage_unknown" in result["vector_space"]:
assert isinstance(result["vector_space"]["usage_unknown"], bool)
- assert isinstance(result["knowledge_rate_limit"]["limit"], int)
-
assert isinstance(result["documents_upload_quota"]["size"], int)
assert isinstance(result["documents_upload_quota"]["limit"], int)
assert isinstance(result["annotation_quota_limit"]["size"], int)
assert isinstance(result["annotation_quota_limit"]["limit"], int)
- assert isinstance(result["docs_processing"], str)
assert isinstance(result["can_replace_logo"], bool)
assert isinstance(result["model_load_balancing_enabled"], bool)
assert isinstance(result["knowledge_pipeline_publish_enabled"], bool)
diff --git a/api/tests/unit_tests/services/test_feature_service_vector_space.py b/api/tests/unit_tests/services/test_feature_service_vector_space.py
index b61091fd730..913a8526a95 100644
--- a/api/tests/unit_tests/services/test_feature_service_vector_space.py
+++ b/api/tests/unit_tests/services/test_feature_service_vector_space.py
@@ -15,10 +15,8 @@ def test_get_features_exclude_vector_space_sets_vector_space_to_none(config_over
"apps": {"size": 2, "limit": 20},
"documents_upload_quota": {"size": 3, "limit": 100},
"annotation_quota_limit": {"size": 4, "limit": 50},
- "docs_processing": "standard",
"can_replace_logo": True,
"model_load_balancing_enabled": True,
- "knowledge_rate_limit": {"limit": 100},
"knowledge_pipeline_publish_enabled": True,
}
@@ -26,7 +24,6 @@ def test_get_features_exclude_vector_space_sets_vector_space_to_none(config_over
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
CAN_REPLACE_LOGO=False,
MODEL_LB_ENABLED=False,
- DATASET_OPERATOR_ENABLED=False,
EDUCATION_ENABLED=False,
)
with (
diff --git a/packages/contracts/generated/api/console/features/orpc.gen.ts b/packages/contracts/generated/api/console/features/orpc.gen.ts
index f59b841fcd2..cdde66b21ad 100644
--- a/packages/contracts/generated/api/console/features/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/features/orpc.gen.ts
@@ -25,18 +25,18 @@ export const vectorSpace = {
}
/**
- * Get feature configuration for current tenant
+ * Get current workspace features
*
- * Get feature configuration for current tenant
+ * Get feature availability and limits for the current workspace
*/
export const get2 = oc
.route({
- description: 'Get feature configuration for current tenant',
+ description: 'Get feature availability and limits for the current workspace',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getFeatures',
path: '/features',
- summary: 'Get feature configuration for current tenant',
+ summary: 'Get current workspace features',
tags: ['console'],
})
.output(zGetFeaturesResponse)
diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts
index ecdf566866c..b6e49d912a5 100644
--- a/packages/contracts/generated/api/console/features/types.gen.ts
+++ b/packages/contracts/generated/api/console/features/types.gen.ts
@@ -10,20 +10,15 @@ export type FeatureModel = {
apps: LimitationModel
billing: BillingModel
can_replace_logo: boolean
- dataset_operator_enabled: boolean
- docs_processing: string
documents_upload_quota: LimitationModel
education: EducationModel
enable_skill: boolean
human_input_email_delivery_enabled: boolean
is_allow_transfer_workspace: boolean
knowledge_pipeline: KnowledgePipeline
- knowledge_rate_limit: number
members: LimitationModel
model_load_balancing_enabled: boolean
- next_credit_reset_date: number
trigger_event: Quota
- vector_space: LimitationModel | null
webapp_copyright_enabled: boolean
workspace_members: LicenseLimitationModel
}
diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts
index a4aa76550e4..b8f55cef66f 100644
--- a/packages/contracts/generated/api/console/features/zod.gen.ts
+++ b/packages/contracts/generated/api/console/features/zod.gen.ts
@@ -84,6 +84,8 @@ export const zBillingModel = z.object({
/**
* FeatureModel
+ *
+ * Effective feature availability and limits for the current workspace.
*/
export const zFeatureModel = z.object({
annotation_quota_limit: zLimitationModel.default({ limit: 10, size: 0 }),
@@ -95,24 +97,19 @@ export const zFeatureModel = z.object({
apps: zLimitationModel.default({ limit: 10, size: 0 }),
billing: zBillingModel.default({ subscription: { interval: '', plan: 'sandbox' } }),
can_replace_logo: z.boolean().default(false),
- dataset_operator_enabled: z.boolean().default(false),
- docs_processing: z.string().default('standard'),
documents_upload_quota: zLimitationModel.default({ limit: 50, size: 0 }),
education: zEducationModel.default({ activated: false, enabled: false }),
enable_skill: z.boolean().default(true),
human_input_email_delivery_enabled: z.boolean().default(false),
is_allow_transfer_workspace: z.boolean().default(true),
knowledge_pipeline: zKnowledgePipeline.default({ publish_enabled: false }),
- knowledge_rate_limit: z.int().default(10),
members: zLimitationModel.default({ limit: 1, size: 0 }),
model_load_balancing_enabled: z.boolean().default(false),
- next_credit_reset_date: z.int().default(0),
trigger_event: zQuota.default({
limit: 3000,
reset_date: 0,
usage: 0,
}),
- vector_space: zLimitationModel.nullable().default({ limit: 5, size: 0 }),
webapp_copyright_enabled: z.boolean().default(false),
workspace_members: zLicenseLimitationModel.default({
enabled: false,