Skip to content

Commit 5d8101a

Browse files
AdirAmsalemclaude
andcommitted
feat: support ephemeral keys v2 in tokens client
Add expiresIn, allowedModels, and constraints to the create token request, and permissions/constraints to the response. All new fields are optional for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dc96b3a commit 5d8101a

5 files changed

Lines changed: 187 additions & 7 deletions

File tree

decart/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from .tokens import (
2525
TokensClient,
2626
CreateTokenResponse,
27+
RealtimeConstraints,
28+
TokenConstraints,
29+
TokenPermissions,
2730
)
2831

2932
try:
@@ -77,6 +80,9 @@
7780
"QueueJobResult",
7881
"TokensClient",
7982
"CreateTokenResponse",
83+
"RealtimeConstraints",
84+
"TokenConstraints",
85+
"TokenPermissions",
8086
"TokenCreateError",
8187
]
8288

decart/tokens/__init__.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
from .client import TokensClient
2-
from .types import CreateTokenResponse
2+
from .types import (
3+
CreateTokenResponse,
4+
RealtimeConstraints,
5+
TokenConstraints,
6+
TokenPermissions,
7+
)
38

4-
__all__ = ["TokensClient", "CreateTokenResponse"]
9+
__all__ = [
10+
"TokensClient",
11+
"CreateTokenResponse",
12+
"RealtimeConstraints",
13+
"TokenConstraints",
14+
"TokenPermissions",
15+
]

decart/tokens/client.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import TYPE_CHECKING, Any, Union
22

33
import aiohttp
44

55
from ..errors import TokenCreateError
6+
from ..models import Model
67
from .._user_agent import build_user_agent
7-
from .types import CreateTokenResponse
8+
from .types import CreateTokenResponse, TokenConstraints
89

910
if TYPE_CHECKING:
1011
from ..client import DecartClient
@@ -23,6 +24,13 @@ class TokensClient:
2324
2425
# With metadata:
2526
token = await client.tokens.create(metadata={"role": "viewer"})
27+
28+
# With expiry, model restrictions, and constraints:
29+
token = await client.tokens.create(
30+
expires_in=120,
31+
allowed_models=["lucy_2_rt"],
32+
constraints={"realtime": {"maxSessionDuration": 300}},
33+
)
2634
```
2735
"""
2836

@@ -36,12 +44,19 @@ async def create(
3644
self,
3745
*,
3846
metadata: dict[str, Any] | None = None,
47+
expires_in: int | None = None,
48+
allowed_models: list[Union[Model, str]] | None = None,
49+
constraints: TokenConstraints | None = None,
3950
) -> CreateTokenResponse:
4051
"""
4152
Create a client token.
4253
4354
Args:
4455
metadata: Optional custom key-value pairs to attach to the token.
56+
expires_in: Seconds until the token expires (1-3600, default 60).
57+
allowed_models: Restrict which models this token can access (max 20).
58+
constraints: Operational limits, e.g.
59+
``{"realtime": {"maxSessionDuration": 120}}``.
4560
4661
Returns:
4762
A short-lived API key safe for client-side use.
@@ -51,8 +66,13 @@ async def create(
5166
token = await client.tokens.create()
5267
# Returns: CreateTokenResponse(api_key="ek_...", expires_at="...")
5368
54-
# With metadata:
55-
token = await client.tokens.create(metadata={"role": "viewer"})
69+
# With all options:
70+
token = await client.tokens.create(
71+
metadata={"role": "viewer"},
72+
expires_in=120,
73+
allowed_models=["lucy_2_rt"],
74+
constraints={"realtime": {"maxSessionDuration": 300}},
75+
)
5676
```
5777
5878
Raises:
@@ -66,7 +86,15 @@ async def create(
6686
"User-Agent": build_user_agent(self._parent.integration),
6787
}
6888

69-
body = {"metadata": metadata} if metadata is not None else {}
89+
body: dict[str, Any] = {}
90+
if metadata is not None:
91+
body["metadata"] = metadata
92+
if expires_in is not None:
93+
body["expiresIn"] = expires_in
94+
if allowed_models is not None:
95+
body["allowedModels"] = list(allowed_models)
96+
if constraints is not None:
97+
body["constraints"] = constraints
7098

7199
async with session.post(
72100
endpoint,
@@ -83,4 +111,6 @@ async def create(
83111
return CreateTokenResponse(
84112
api_key=data["apiKey"],
85113
expires_at=data["expiresAt"],
114+
permissions=data.get("permissions"),
115+
constraints=data.get("constraints"),
86116
)

decart/tokens/types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,24 @@
1+
from typing import TypedDict
2+
13
from pydantic import BaseModel
24

35

6+
class RealtimeConstraints(TypedDict, total=False):
7+
maxSessionDuration: int
8+
9+
10+
class TokenConstraints(TypedDict, total=False):
11+
realtime: RealtimeConstraints
12+
13+
14+
class TokenPermissions(TypedDict):
15+
models: list[str]
16+
17+
418
class CreateTokenResponse(BaseModel):
519
"""Response from creating a client token."""
620

721
api_key: str
822
expires_at: str
23+
permissions: TokenPermissions | None = None
24+
constraints: TokenConstraints | None = None

tests/test_tokens.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ async def test_create_token() -> None:
2626

2727
assert result.api_key == "ek_test123"
2828
assert result.expires_at == "2024-12-15T12:10:00Z"
29+
assert result.permissions is None
30+
assert result.constraints is None
2931

3032

3133
@pytest.mark.asyncio
@@ -114,3 +116,118 @@ async def test_create_token_without_metadata_sends_null() -> None:
114116

115117
call_kwargs = mock_session.post.call_args
116118
assert call_kwargs.kwargs["json"] == {}
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_create_token_with_expires_in() -> None:
123+
"""Sends expiresIn in request body."""
124+
client = DecartClient(api_key="test-api-key")
125+
126+
mock_response = AsyncMock()
127+
mock_response.ok = True
128+
mock_response.json = AsyncMock(
129+
return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"}
130+
)
131+
132+
mock_session = MagicMock()
133+
mock_session.post = MagicMock(
134+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
135+
)
136+
137+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
138+
await client.tokens.create(expires_in=120)
139+
140+
call_kwargs = mock_session.post.call_args
141+
assert call_kwargs.kwargs["json"] == {"expiresIn": 120}
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_create_token_with_allowed_models() -> None:
146+
"""Sends allowedModels in request body."""
147+
client = DecartClient(api_key="test-api-key")
148+
149+
mock_response = AsyncMock()
150+
mock_response.ok = True
151+
mock_response.json = AsyncMock(
152+
return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"}
153+
)
154+
155+
mock_session = MagicMock()
156+
mock_session.post = MagicMock(
157+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
158+
)
159+
160+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
161+
await client.tokens.create(allowed_models=["lucy_2_rt"])
162+
163+
call_kwargs = mock_session.post.call_args
164+
assert call_kwargs.kwargs["json"] == {"allowedModels": ["lucy_2_rt"]}
165+
166+
167+
@pytest.mark.asyncio
168+
async def test_create_token_with_constraints() -> None:
169+
"""Sends constraints in request body."""
170+
client = DecartClient(api_key="test-api-key")
171+
172+
mock_response = AsyncMock()
173+
mock_response.ok = True
174+
mock_response.json = AsyncMock(
175+
return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"}
176+
)
177+
178+
mock_session = MagicMock()
179+
mock_session.post = MagicMock(
180+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
181+
)
182+
183+
constraints = {"realtime": {"maxSessionDuration": 120}}
184+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
185+
await client.tokens.create(constraints=constraints)
186+
187+
call_kwargs = mock_session.post.call_args
188+
assert call_kwargs.kwargs["json"] == {
189+
"constraints": {"realtime": {"maxSessionDuration": 120}}
190+
}
191+
192+
193+
@pytest.mark.asyncio
194+
async def test_create_token_with_all_v2_fields() -> None:
195+
"""Sends all v2 fields and parses permissions/constraints from response."""
196+
client = DecartClient(api_key="test-api-key")
197+
198+
mock_response = AsyncMock()
199+
mock_response.ok = True
200+
mock_response.json = AsyncMock(
201+
return_value={
202+
"apiKey": "ek_test123",
203+
"expiresAt": "2024-12-15T12:10:00Z",
204+
"permissions": {"models": ["lucy_2_rt"]},
205+
"constraints": {"realtime": {"maxSessionDuration": 120}},
206+
}
207+
)
208+
209+
mock_session = MagicMock()
210+
mock_session.post = MagicMock(
211+
return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response))
212+
)
213+
214+
with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)):
215+
result = await client.tokens.create(
216+
metadata={"role": "viewer"},
217+
expires_in=120,
218+
allowed_models=["lucy_2_rt"],
219+
constraints={"realtime": {"maxSessionDuration": 120}},
220+
)
221+
222+
assert result.api_key == "ek_test123"
223+
assert result.expires_at == "2024-12-15T12:10:00Z"
224+
assert result.permissions == {"models": ["lucy_2_rt"]}
225+
assert result.constraints == {"realtime": {"maxSessionDuration": 120}}
226+
227+
call_kwargs = mock_session.post.call_args
228+
assert call_kwargs.kwargs["json"] == {
229+
"metadata": {"role": "viewer"},
230+
"expiresIn": 120,
231+
"allowedModels": ["lucy_2_rt"],
232+
"constraints": {"realtime": {"maxSessionDuration": 120}},
233+
}

0 commit comments

Comments
 (0)