Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions decart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
QueueSubmitError,
QueueStatusError,
QueueResultError,
TokenCreateError,
)
from .models import models, ModelDefinition
from .types import FileInput, ModelState, Prompt
Expand All @@ -20,6 +21,10 @@
JobStatusResponse,
QueueJobResult,
)
from .tokens import (
TokensClient,
CreateTokenResponse,
)

try:
from .realtime import (
Expand Down Expand Up @@ -59,6 +64,9 @@
"JobSubmitResponse",
"JobStatusResponse",
"QueueJobResult",
"TokensClient",
"CreateTokenResponse",
"TokenCreateError",
]

if REALTIME_AVAILABLE:
Expand Down
24 changes: 24 additions & 0 deletions decart/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .models import ImageModelDefinition, _MODELS
from .process.request import send_request
from .queue.client import QueueClient
from .tokens.client import TokensClient

try:
from .realtime.client import RealtimeClient
Expand Down Expand Up @@ -66,6 +67,7 @@ def __init__(
self.integration = integration
self._session: Optional[aiohttp.ClientSession] = None
self._queue: Optional[QueueClient] = None
self._tokens: Optional[TokensClient] = None

@property
def queue(self) -> QueueClient:
Expand All @@ -91,6 +93,28 @@ def queue(self) -> QueueClient:
self._queue = QueueClient(self)
return self._queue

@property
def tokens(self) -> TokensClient:
"""
Client for creating client tokens.
Client tokens are short-lived API keys safe for client-side use.

Example:
```python
# Server-side: Create a client token
server_client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
token = await server_client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")

# Client-side: Use the client token
client = DecartClient(api_key=token.api_key)
realtime_client = await client.realtime.connect(...)
```
"""
if self._tokens is None:
self._tokens = TokensClient(self)
return self._tokens

async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create the aiohttp session."""
if self._session is None or self._session.closed:
Expand Down
6 changes: 6 additions & 0 deletions decart/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,9 @@ class QueueResultError(DecartSDKError):
"""Raised when getting queue job result fails."""

pass


class TokenCreateError(DecartSDKError):
"""Raised when token creation fails."""

pass
4 changes: 4 additions & 0 deletions decart/tokens/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import TokensClient
from .types import CreateTokenResponse

__all__ = ["TokensClient", "CreateTokenResponse"]
73 changes: 73 additions & 0 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from typing import TYPE_CHECKING

import aiohttp

from ..errors import TokenCreateError
from .._user_agent import build_user_agent
from .types import CreateTokenResponse

if TYPE_CHECKING:
from ..client import DecartClient


class TokensClient:
"""
Client for creating client tokens.
Client tokens are short-lived API keys safe for client-side use.

Example:
```python
# Server-side: Create a client token
server_client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
token = await server_client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")

# Client-side: Use the client token
client = DecartClient(api_key=token.api_key)
realtime_client = await client.realtime.connect(...)
```
"""

def __init__(self, parent: "DecartClient") -> None:
self._parent = parent

async def _get_session(self) -> aiohttp.ClientSession:
return await self._parent._get_session()

async def create(self) -> CreateTokenResponse:
"""
Create a client token.

Returns:
A short-lived API key safe for client-side use.

Example:
```python
token = await client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
```

Raises:
TokenCreateError: If token creation fails (401, 403, etc.)
"""
session = await self._get_session()
endpoint = f"{self._parent.base_url}/v1/client/tokens"

async with session.post(
endpoint,
headers={
"X-API-KEY": self._parent.api_key,
"User-Agent": build_user_agent(self._parent.integration),
},
) as response:
if not response.ok:
error_text = await response.text()
raise TokenCreateError(
f"Failed to create token: {response.status} - {error_text}",
data={"status": response.status},
)
data = await response.json()
return CreateTokenResponse(
api_key=data["apiKey"],
expires_at=data["expiresAt"],
)
8 changes: 8 additions & 0 deletions decart/tokens/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pydantic import BaseModel


class CreateTokenResponse(BaseModel):
"""Response from creating a client token."""

api_key: str
expires_at: str
26 changes: 26 additions & 0 deletions examples/create_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import asyncio
import os
from decart import DecartClient


async def main() -> None:
# Server-side: Create client token using API key
async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client:
print("Creating client token...")

token = await server_client.tokens.create()

print("Token created successfully:")
print(f" API Key: {token.api_key[:10]}...")
print(f" Expires At: {token.expires_at}")

# Client-side: Use the client token
# In a real app, you would send token.api_key to the frontend
_client = DecartClient(api_key=token.api_key)

print("Client created with client token.")
print("This token can now be used for realtime connections.")


if __name__ == "__main__":
asyncio.run(main())
68 changes: 68 additions & 0 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for the tokens API."""

import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from decart import DecartClient, TokenCreateError


@pytest.mark.asyncio
async def test_create_token() -> None:
"""Creates a client token successfully."""
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)):
result = await client.tokens.create()

assert result.api_key == "ek_test123"
assert result.expires_at == "2024-12-15T12:10:00Z"


@pytest.mark.asyncio
async def test_create_token_401_error() -> None:
"""Handles 401 error."""
client = DecartClient(api_key="test-api-key")

mock_response = AsyncMock()
mock_response.ok = False
mock_response.status = 401
mock_response.text = AsyncMock(return_value="Invalid API key")

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)):
with pytest.raises(TokenCreateError, match="Failed to create token"):
await client.tokens.create()


@pytest.mark.asyncio
async def test_create_token_403_error() -> None:
"""Handles 403 error."""
client = DecartClient(api_key="test-api-key")

mock_response = AsyncMock()
mock_response.ok = False
mock_response.status = 403
mock_response.text = AsyncMock(return_value="Cannot create token from client token")

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)):
with pytest.raises(TokenCreateError, match="Failed to create token"):
await client.tokens.create()
4 changes: 2 additions & 2 deletions uv.lock

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

Loading