diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 9bcddb6e2..59c091917 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -2,6 +2,7 @@ from __future__ import annotations import quart +from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError from langbot_plugin.box.errors import BoxError from ...authz import Permission @@ -23,6 +24,11 @@ class SkillsRouterGroup(group.RouterGroup): async def list_skills(request_context: RequestContext) -> quart.Response: try: skills = await self.ap.skill_service.list_skills(request_context) + except EntitlementFeatureUnavailableError: + # Plans without managed sandbox support have no runnable skills. + # Treat that capability absence as an empty collection so the + # shared UI can render normally instead of surfacing a 500. + return self.success(data={'skills': []}) except (ValueError, BoxError) as exc: return self.http_status(400, -1, str(exc)) return self.success(data={'skills': skills}) diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py index b9476e6ba..d6d34afd5 100644 --- a/src/langbot/pkg/cloud/entitlements.py +++ b/src/langbot/pkg/cloud/entitlements.py @@ -17,6 +17,22 @@ class EntitlementUnavailableError(RuntimeError): self.entitlement_revision = entitlement_revision +class EntitlementFeatureUnavailableError(EntitlementUnavailableError): + """Raised only when an active entitlement does not grant one feature.""" + + def __init__( + self, + feature: str, + *, + entitlement_revision: int | None = None, + ) -> None: + self.feature = feature + super().__init__( + f'Workspace entitlement does not grant {feature}', + entitlement_revision=entitlement_revision, + ) + + class EntitlementSnapshot(pydantic.BaseModel): """Capability projection consumed by open-source Core. @@ -82,7 +98,10 @@ class EntitlementSnapshot(pydantic.BaseModel): def require_feature(self, feature: str) -> None: if self.features.get(feature) is not True: - raise EntitlementUnavailableError(f'Workspace entitlement does not grant {feature}') + raise EntitlementFeatureUnavailableError( + feature, + entitlement_revision=self.entitlement_revision, + ) def limit(self, name: str) -> int: value = self.limits.get(name) diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py new file mode 100644 index 000000000..baafe1f18 --- /dev/null +++ b/tests/integration/api/test_skills_entitlements.py @@ -0,0 +1,79 @@ +"""Skills API behavior when a workspace plan has no managed sandbox.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import quart + +from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup +from langbot.pkg.cloud.entitlements import ( + EntitlementFeatureUnavailableError, + EntitlementUnavailableError, +) + +pytestmark = pytest.mark.integration +WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111' + + +@pytest.fixture +async def skills_api(): + account = SimpleNamespace(uuid='owner-account', user='owner@example.com') + access = SimpleNamespace( + workspace=SimpleNamespace(uuid=WORKSPACE_UUID), + membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1), + execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), + ) + application = Mock() + application.deployment = SimpleNamespace(multi_workspace_enabled=False) + application.persistence_mgr = SimpleNamespace(tenant_uow=None) + application.user_service.get_authenticated_account = AsyncMock(return_value=account) + application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) + application.skill_service.list_skills = AsyncMock( + side_effect=EntitlementFeatureUnavailableError( + 'managed_sandbox', + entitlement_revision=1, + ) + ) + + quart_app = quart.Quart(__name__) + router = SkillsRouterGroup(application, quart_app) + await router.initialize() + return application, quart_app.test_client() + + +@pytest.mark.asyncio +async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api): + application, client = skills_api + response = await client.get( + '/api/v1/skills', + headers={ + 'Authorization': 'Bearer owner-token', + 'X-Workspace-Id': WORKSPACE_UUID, + }, + ) + + assert response.status_code == 200 + payload = await response.get_json() + assert payload['data'] == {'skills': []} + application.skill_service.list_skills.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api): + application, client = skills_api + application.skill_service.list_skills.side_effect = EntitlementUnavailableError( + 'Workspace entitlement revision rolled back' + ) + + response = await client.get( + '/api/v1/skills', + headers={ + 'Authorization': 'Bearer owner-token', + 'X-Workspace-Id': WORKSPACE_UUID, + }, + ) + + assert response.status_code == 500