diff --git a/decart/__init__.py b/decart/__init__.py index 9048030..b64d7f6 100644 --- a/decart/__init__.py +++ b/decart/__init__.py @@ -24,6 +24,9 @@ from .tokens import ( TokensClient, CreateTokenResponse, + RealtimeConstraints, + TokenConstraints, + TokenPermissions, ) try: @@ -77,6 +80,9 @@ "QueueJobResult", "TokensClient", "CreateTokenResponse", + "RealtimeConstraints", + "TokenConstraints", + "TokenPermissions", "TokenCreateError", ] diff --git a/decart/tokens/__init__.py b/decart/tokens/__init__.py index 1f7d477..214a5f3 100644 --- a/decart/tokens/__init__.py +++ b/decart/tokens/__init__.py @@ -1,4 +1,15 @@ from .client import TokensClient -from .types import CreateTokenResponse +from .types import ( + CreateTokenResponse, + RealtimeConstraints, + TokenConstraints, + TokenPermissions, +) -__all__ = ["TokensClient", "CreateTokenResponse"] +__all__ = [ + "TokensClient", + "CreateTokenResponse", + "RealtimeConstraints", + "TokenConstraints", + "TokenPermissions", +] diff --git a/decart/tokens/client.py b/decart/tokens/client.py index 8421b87..aa36a80 100644 --- a/decart/tokens/client.py +++ b/decart/tokens/client.py @@ -1,10 +1,11 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union import aiohttp from ..errors import TokenCreateError +from ..models import Model from .._user_agent import build_user_agent -from .types import CreateTokenResponse +from .types import CreateTokenResponse, TokenConstraints if TYPE_CHECKING: from ..client import DecartClient @@ -23,6 +24,13 @@ class TokensClient: # With metadata: token = await client.tokens.create(metadata={"role": "viewer"}) + + # With expiry, model restrictions, and constraints: + token = await client.tokens.create( + expires_in=120, + allowed_models=["lucy_2_rt"], + constraints={"realtime": {"maxSessionDuration": 300}}, + ) ``` """ @@ -36,12 +44,19 @@ async def create( self, *, metadata: dict[str, Any] | None = None, + expires_in: int | None = None, + allowed_models: list[Union[Model, str]] | None = None, + constraints: TokenConstraints | None = None, ) -> CreateTokenResponse: """ Create a client token. Args: metadata: Optional custom key-value pairs to attach to the token. + expires_in: Seconds until the token expires (1-3600, default 60). + allowed_models: Restrict which models this token can access (max 20). + constraints: Operational limits, e.g. + ``{"realtime": {"maxSessionDuration": 120}}``. Returns: A short-lived API key safe for client-side use. @@ -51,8 +66,13 @@ async def create( token = await client.tokens.create() # Returns: CreateTokenResponse(api_key="ek_...", expires_at="...") - # With metadata: - token = await client.tokens.create(metadata={"role": "viewer"}) + # With all options: + token = await client.tokens.create( + metadata={"role": "viewer"}, + expires_in=120, + allowed_models=["lucy_2_rt"], + constraints={"realtime": {"maxSessionDuration": 300}}, + ) ``` Raises: @@ -66,7 +86,15 @@ async def create( "User-Agent": build_user_agent(self._parent.integration), } - body = {"metadata": metadata} if metadata is not None else {} + body: dict[str, Any] = {} + if metadata is not None: + body["metadata"] = metadata + if expires_in is not None: + body["expiresIn"] = expires_in + if allowed_models is not None: + body["allowedModels"] = list(allowed_models) + if constraints is not None: + body["constraints"] = constraints async with session.post( endpoint, @@ -83,4 +111,6 @@ async def create( return CreateTokenResponse( api_key=data["apiKey"], expires_at=data["expiresAt"], + permissions=data.get("permissions"), + constraints=data.get("constraints"), ) diff --git a/decart/tokens/types.py b/decart/tokens/types.py index f0f1c8c..109c5f4 100644 --- a/decart/tokens/types.py +++ b/decart/tokens/types.py @@ -1,8 +1,24 @@ +from typing_extensions import TypedDict + from pydantic import BaseModel +class RealtimeConstraints(TypedDict, total=False): + maxSessionDuration: int + + +class TokenConstraints(TypedDict, total=False): + realtime: RealtimeConstraints + + +class TokenPermissions(TypedDict): + models: list[str] + + class CreateTokenResponse(BaseModel): """Response from creating a client token.""" api_key: str expires_at: str + permissions: TokenPermissions | None = None + constraints: TokenConstraints | None = None diff --git a/tests/test_tokens.py b/tests/test_tokens.py index a77cb5f..83a3f3d 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -26,6 +26,8 @@ async def test_create_token() -> None: assert result.api_key == "ek_test123" assert result.expires_at == "2024-12-15T12:10:00Z" + assert result.permissions is None + assert result.constraints is None @pytest.mark.asyncio @@ -114,3 +116,116 @@ async def test_create_token_without_metadata_sends_null() -> None: call_kwargs = mock_session.post.call_args assert call_kwargs.kwargs["json"] == {} + + +@pytest.mark.asyncio +async def test_create_token_with_expires_in() -> None: + """Sends expiresIn in request body.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + await client.tokens.create(expires_in=120) + + call_kwargs = mock_session.post.call_args + assert call_kwargs.kwargs["json"] == {"expiresIn": 120} + + +@pytest.mark.asyncio +async def test_create_token_with_allowed_models() -> None: + """Sends allowedModels in request body.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + await client.tokens.create(allowed_models=["lucy_2_rt"]) + + call_kwargs = mock_session.post.call_args + assert call_kwargs.kwargs["json"] == {"allowedModels": ["lucy_2_rt"]} + + +@pytest.mark.asyncio +async def test_create_token_with_constraints() -> None: + """Sends constraints in request body.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + constraints = {"realtime": {"maxSessionDuration": 120}} + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + await client.tokens.create(constraints=constraints) + + call_kwargs = mock_session.post.call_args + assert call_kwargs.kwargs["json"] == {"constraints": {"realtime": {"maxSessionDuration": 120}}} + + +@pytest.mark.asyncio +async def test_create_token_with_all_v2_fields() -> None: + """Sends all v2 fields and parses permissions/constraints from response.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={ + "apiKey": "ek_test123", + "expiresAt": "2024-12-15T12:10:00Z", + "permissions": {"models": ["lucy_2_rt"]}, + "constraints": {"realtime": {"maxSessionDuration": 120}}, + } + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + result = await client.tokens.create( + metadata={"role": "viewer"}, + expires_in=120, + allowed_models=["lucy_2_rt"], + constraints={"realtime": {"maxSessionDuration": 120}}, + ) + + assert result.api_key == "ek_test123" + assert result.expires_at == "2024-12-15T12:10:00Z" + assert result.permissions == {"models": ["lucy_2_rt"]} + assert result.constraints == {"realtime": {"maxSessionDuration": 120}} + + call_kwargs = mock_session.post.call_args + assert call_kwargs.kwargs["json"] == { + "metadata": {"role": "viewer"}, + "expiresIn": 120, + "allowedModels": ["lucy_2_rt"], + "constraints": {"realtime": {"maxSessionDuration": 120}}, + }