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,