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
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
19 changes: 19 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,23 @@ 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
client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
token = await client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
```
"""
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"]
68 changes: 68 additions & 0 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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
client = DecartClient(api_key=os.getenv("DECART_API_KEY"))
token = await client.tokens.create()
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
```
"""

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