Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions decart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
from .tokens import (
TokensClient,
CreateTokenResponse,
RealtimeConstraints,
TokenConstraints,
TokenPermissions,
)

try:
Expand Down Expand Up @@ -77,6 +80,9 @@
"QueueJobResult",
"TokensClient",
"CreateTokenResponse",
"RealtimeConstraints",
"TokenConstraints",
"TokenPermissions",
"TokenCreateError",
]

Expand Down
15 changes: 13 additions & 2 deletions decart/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
40 changes: 35 additions & 5 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}},
)
```
"""

Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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"),
)
16 changes: 16 additions & 0 deletions decart/tokens/types.py
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}},
}
Loading