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
12 changes: 11 additions & 1 deletion decart/tokens/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ class TokensClient:
# With metadata:
token = await client.tokens.create(metadata={"role": "viewer"})

# With expiry, model restrictions, and constraints:
# With expiry, model restrictions, origin restrictions, and constraints:
token = await client.tokens.create(
expires_in=120,
allowed_models=["lucy-2.1"],
allowed_origins=["https://example.com"],
constraints={"realtime": {"maxSessionDuration": 300}},
)
```
Expand All @@ -46,6 +47,7 @@ async def create(
metadata: dict[str, Any] | None = None,
expires_in: int | None = None,
allowed_models: list[Union[Model, str]] | None = None,
allowed_origins: list[str] | None = None,
constraints: TokenConstraints | None = None,
) -> CreateTokenResponse:
"""
Expand All @@ -55,6 +57,11 @@ async def create(
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).
allowed_origins: Restrict which web origins this token can be used
from (max 20). Each entry must be a full origin including
scheme, e.g. ``https://example.com``. Enforced on realtime
sessions by matching the WebSocket ``Origin`` header verbatim.
Defense-in-depth — only effective for browser-based clients.
constraints: Operational limits, e.g.
``{"realtime": {"maxSessionDuration": 120}}``.

Expand All @@ -71,6 +78,7 @@ async def create(
metadata={"role": "viewer"},
expires_in=120,
allowed_models=["lucy-2.1"],
allowed_origins=["https://example.com"],
constraints={"realtime": {"maxSessionDuration": 300}},
)
```
Expand All @@ -93,6 +101,8 @@ async def create(
body["expiresIn"] = expires_in
if allowed_models is not None:
body["allowedModels"] = list(allowed_models)
if allowed_origins is not None:
body["allowedOrigins"] = list(allowed_origins)
if constraints is not None:
body["constraints"] = constraints

Expand Down
3 changes: 2 additions & 1 deletion decart/tokens/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ class TokenConstraints(TypedDict, total=False):
realtime: RealtimeConstraints


class TokenPermissions(TypedDict):
class TokenPermissions(TypedDict, total=False):
models: list[str]
origins: list[str]


class CreateTokenResponse(BaseModel):
Expand Down
6 changes: 5 additions & 1 deletion examples/create_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ async def main() -> None:
async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client:
print("Creating client token...")

token = await server_client.tokens.create()
token = await server_client.tokens.create(
allowed_origins=["https://example.com"],
)

print("Token created successfully:")
print(f" API Key: {token.api_key[:10]}...")
print(f" Expires At: {token.expires_at}")
origins = (token.permissions or {}).get("origins")
print(f" Allowed Origins: {', '.join(origins) if origins else '(any)'}")

# Client-side: Use the client token
# In a real app, you would send token.api_key to the frontend
Expand Down
39 changes: 37 additions & 2 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,33 @@ async def test_create_token_with_allowed_models() -> None:
assert call_kwargs.kwargs["json"] == {"allowedModels": ["lucy-2.1"]}


@pytest.mark.asyncio
async def test_create_token_with_allowed_origins() -> None:
"""Sends allowedOrigins 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_origins=["https://example.com", "https://app.example.com"]
)

call_kwargs = mock_session.post.call_args
assert call_kwargs.kwargs["json"] == {
"allowedOrigins": ["https://example.com", "https://app.example.com"]
}


@pytest.mark.asyncio
async def test_create_token_with_constraints() -> None:
"""Sends constraints in request body."""
Expand Down Expand Up @@ -199,7 +226,10 @@ async def test_create_token_with_all_v2_fields() -> None:
return_value={
"apiKey": "ek_test123",
"expiresAt": "2024-12-15T12:10:00Z",
"permissions": {"models": ["lucy-2.1"]},
"permissions": {
"models": ["lucy-2.1"],
"origins": ["https://example.com"],
},
"constraints": {"realtime": {"maxSessionDuration": 120}},
}
)
Expand All @@ -214,18 +244,23 @@ async def test_create_token_with_all_v2_fields() -> None:
metadata={"role": "viewer"},
expires_in=120,
allowed_models=["lucy-2.1"],
allowed_origins=["https://example.com"],
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.1"]}
assert result.permissions == {
"models": ["lucy-2.1"],
"origins": ["https://example.com"],
}
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.1"],
"allowedOrigins": ["https://example.com"],
"constraints": {"realtime": {"maxSessionDuration": 120}},
}
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading