refactor(api): trim workspace features response contract (#41948)

This commit is contained in:
yyh 2026-09-08 05:42:22 +00:00 committed by GitHub
parent a67f62e9aa
commit 8acdb1538b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 64 additions and 195 deletions

View File

@ -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")

View File

@ -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:

View File

@ -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, <br>**Default:** standard | | Yes |
| documents_upload_quota | [LimitationModel](#limitationmodel) | | Yes |
| education | [EducationModel](#educationmodel) | | Yes |
| enable_skill | boolean, <br>**Default:** true | | Yes |
| human_input_email_delivery_enabled | boolean | | Yes |
| is_allow_transfer_workspace | boolean, <br>**Default:** true | | Yes |
| knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes |
| knowledge_rate_limit | integer, <br>**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, <br>**Default:** -1 | | Yes |
| limit | integer | Quota limit; -1 means unlimited and 0 means no quota | Yes |
| reset_date | integer, <br>**Default:** -1 | Next quota reset as a Unix timestamp in seconds; -1 means no reset | Yes |
| usage | integer | | Yes |
#### QuotaConfiguration

View File

@ -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

View File

@ -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):

View File

@ -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"]

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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 (

View File

@ -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)

View File

@ -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
}

View File

@ -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,