meshagent-api 0.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1974 @@
1
+ import aiohttp
2
+ from typing import Any, Dict, List, Optional, Literal, cast
3
+ from pydantic import BaseModel, ValidationError, JsonValue, Field, ConfigDict
4
+ from meshagent.api import RoomException
5
+ from meshagent.api.participant_token import ApiScope
6
+ from meshagent.api.helpers import meshagent_base_url
7
+ from datetime import datetime
8
+ from meshagent.api.specs.service import (
9
+ ServiceSpec,
10
+ )
11
+ import os
12
+
13
+ # ------------------------------------------------------------------
14
+ # Secret models
15
+ # ------------------------------------------------------------------
16
+
17
+
18
+ class NotFoundError(RoomException):
19
+ """404 – resource does not exist."""
20
+
21
+
22
+ class PermissionDeniedError(RoomException):
23
+ """403 – permission denied."""
24
+
25
+
26
+ class ConflictError(RoomException):
27
+ """409 – conflicting or duplicate resource."""
28
+
29
+
30
+ class ValidationErrorResponse(RoomException):
31
+ """400 – invalid request payload."""
32
+
33
+
34
+ class ServerError(RoomException):
35
+ """5xx – server-side failure."""
36
+
37
+
38
+ class OAuthClient(BaseModel):
39
+ client_id: str
40
+ client_secret: str
41
+ grant_types: list[str]
42
+ response_types: list[str]
43
+ redirect_uris: list[str]
44
+ scope: str
45
+ project_id: str
46
+ metadata: dict[str, str]
47
+ official: bool
48
+
49
+
50
+ class RoomConnectionInfo(BaseModel):
51
+ jwt: str
52
+ room_name: str
53
+ project_id: str
54
+ room_url: str
55
+
56
+
57
+ class RoomShare(BaseModel):
58
+ id: str
59
+ project_id: str
60
+ settings: dict[str, JsonValue]
61
+
62
+
63
+ class RoomShareConnectionInfo(BaseModel):
64
+ jwt: str
65
+ room_name: str
66
+ project_id: str
67
+ settings: dict[str, JsonValue]
68
+ room_url: str
69
+
70
+
71
+ class RoomSession(BaseModel):
72
+ id: str
73
+ room_id: Optional[str]
74
+ room_name: str
75
+ created_at: datetime
76
+ is_active: bool
77
+ participants: Optional[dict[str, int]] = None
78
+
79
+
80
+ class _ListRoomSessionsResponse(BaseModel):
81
+ sessions: list[RoomSession]
82
+
83
+
84
+ class Room(BaseModel):
85
+ id: str
86
+ name: str
87
+ metadata: dict[str, JsonValue]
88
+
89
+
90
+ class ProjectRoomGrant(BaseModel):
91
+ room: Room
92
+ user_id: str
93
+ permissions: ApiScope
94
+
95
+
96
+ class ProjectRoomGrantCount(BaseModel):
97
+ room: Room
98
+ count: int
99
+
100
+
101
+ class ProjectUserGrantCount(BaseModel):
102
+ user_id: str
103
+ count: int
104
+ first_name: Optional[str] = None
105
+ last_name: Optional[str] = None
106
+ email: str
107
+
108
+
109
+ class _CreateRoomGrantRequest(BaseModel):
110
+ room_id: str
111
+ user_id: Optional[str] = None
112
+ email: Optional[str] = None
113
+ permissions: ApiScope
114
+
115
+
116
+ class _UpdateRoomGrantRequest(BaseModel):
117
+ room_id: str
118
+ user_id: str
119
+ permissions: ApiScope
120
+
121
+
122
+ class _BaseSecret(BaseModel):
123
+ """Common fields shared by all secrets."""
124
+
125
+ id: str
126
+ name: str
127
+
128
+
129
+ class PullSecret(_BaseSecret):
130
+ """
131
+ A Docker-registry credential.
132
+
133
+ When you call `model_dump() / dict()` this object produces the same
134
+ structure consumed by `map_secret_data("docker", …)` in the room
135
+ provisioner.
136
+ """
137
+
138
+ type: Literal["docker"] = "docker"
139
+
140
+ server: str = Field(..., description="Registry host (e.g. registry-1.docker.io)")
141
+ username: str
142
+ password: str
143
+ email: str = Field(
144
+ "none@example.com",
145
+ description="Email is required by the Docker spec, but is unused",
146
+ )
147
+
148
+ def to_payload(self) -> Dict[str, str]:
149
+ return {
150
+ "server": self.server,
151
+ "username": self.username,
152
+ "password": self.password,
153
+ "email": self.email,
154
+ }
155
+
156
+
157
+ class KeysSecret(_BaseSecret):
158
+ """
159
+ An *opaque* secret that will be exposed to containers as individual
160
+ environment variables.
161
+
162
+ Example:
163
+ KeysSecret(
164
+ id="sec-123",
165
+ name="openai",
166
+ data={"OPENAI_API_KEY": "sk-...", "ORG": "myorg"}
167
+ )
168
+ """
169
+
170
+ type: Literal["keys"] = "keys"
171
+ data: Dict[str, str]
172
+
173
+ def to_payload(self) -> Dict[str, str]:
174
+ return self.data
175
+
176
+
177
+ SecretLike = PullSecret | KeysSecret
178
+
179
+
180
+ def _parse_secret(raw: dict) -> SecretLike:
181
+ """
182
+ Decide which concrete Pydantic class to use based on the 'type' field.
183
+ """
184
+ if raw.get("type") == "docker":
185
+ return PullSecret.model_validate(
186
+ {"id": raw["id"], "name": raw["name"], "type": raw["type"], **raw["data"]}
187
+ )
188
+ else: # defaults to keys_secret
189
+ return KeysSecret.model_validate(
190
+ {
191
+ "id": raw["id"],
192
+ "name": raw["name"],
193
+ "type": raw["type"],
194
+ "data": raw["data"],
195
+ }
196
+ )
197
+
198
+
199
+ ProjectRole = Literal["member", "admin", "developer"]
200
+
201
+
202
+ class _CreateMailboxRequest(BaseModel):
203
+ room: str
204
+ queue: str
205
+ address: str
206
+
207
+
208
+ class _UpdateMailboxRequest(BaseModel):
209
+ room: str
210
+ queue: str
211
+
212
+
213
+ class Mailbox(BaseModel):
214
+ """
215
+ Minimal shape returned by the server for a mailbox.
216
+ Extra fields (if any) from the server response will be ignored.
217
+ """
218
+
219
+ address: str
220
+ room: str
221
+ queue: str
222
+
223
+
224
+ class Balance(BaseModel):
225
+ balance: float
226
+ auto_recharge_threshold: Optional[float] = Field(
227
+ default=None, alias="auto_recharge_threshold"
228
+ )
229
+ auto_recharge_amount: Optional[float] = Field(
230
+ default=None, alias="auto_recharge_amount"
231
+ )
232
+ last_recharge: Optional[datetime] = Field(default=None, alias="last_recharge")
233
+
234
+ model_config = ConfigDict(populate_by_name=True)
235
+
236
+
237
+ class Transaction(BaseModel):
238
+ id: str
239
+ amount: float
240
+ reference: Optional[str] = None
241
+ reference_type: Optional[str] = Field(default=None, alias="referenceType")
242
+ description: str
243
+ created_at: datetime = Field(alias="created_at")
244
+
245
+ model_config = ConfigDict(populate_by_name=True)
246
+
247
+
248
+ class ScheduledTask(BaseModel):
249
+ id: str
250
+ project_id: str
251
+ room_name: str
252
+ queue_name: str
253
+ payload: dict
254
+ schedule: str
255
+ active: bool
256
+ once: bool
257
+ annotations: dict[str, str]
258
+
259
+ last_run_id: Optional[int] = None
260
+ last_start_time: Optional[datetime] = None
261
+ last_end_time: Optional[datetime] = None
262
+ last_status: Optional[str] = None
263
+ last_return_message: Optional[str] = None
264
+
265
+
266
+ class _CreateScheduledTaskRequest(BaseModel):
267
+ id: Optional[str] = None
268
+ room_name: str
269
+ queue_name: str
270
+ payload: dict # dict or json-string
271
+ schedule: str
272
+ active: bool = True
273
+ once: bool = False
274
+ annotations: dict[str, str]
275
+
276
+
277
+ class _UpdateScheduledTaskRequest(BaseModel):
278
+ room_name: Optional[str] = None
279
+ queue_name: Optional[str] = None
280
+ payload: Optional[dict] = None # dict or json-string
281
+ schedule: Optional[str] = None
282
+ active: Optional[bool] = None
283
+ annotations: dict[str, str]
284
+
285
+
286
+ class _ListScheduledTasksResponse(BaseModel):
287
+ tasks: List[ScheduledTask]
288
+
289
+
290
+ class Meshagent:
291
+ """
292
+ A simple asynchronous client to interact with the accounts routes.
293
+ """
294
+
295
+ def __init__(
296
+ self,
297
+ *,
298
+ base_url: str = meshagent_base_url(),
299
+ token: str = os.getenv("MESHAGENT_API_KEY"),
300
+ ):
301
+ """
302
+ :param base_url: The root URL of your server, e.g. 'http://localhost:8080'.
303
+ :param token: A Bearer token for the Authorization header.
304
+ """
305
+ self.base_url = base_url.rstrip("/")
306
+ self.token = token # The "Bearer" token
307
+
308
+ session = aiohttp.ClientSession()
309
+ self._session = session
310
+
311
+ async def close(self):
312
+ await self._session.close()
313
+
314
+ def _get_headers(self) -> Dict[str, str]:
315
+ """
316
+ Returns the default headers including Bearer Authorization.
317
+ """
318
+ return {
319
+ "Authorization": f"Bearer {self.token}",
320
+ "Content-Type": "application/json",
321
+ }
322
+
323
+ async def _raise_for_status(
324
+ self,
325
+ resp: aiohttp.ClientResponse,
326
+ ) -> None:
327
+ if resp.status < 400:
328
+ return
329
+
330
+ try:
331
+ body = await resp.text()
332
+ except Exception:
333
+ body = "<unable to read body>"
334
+
335
+ msg = f"Status={resp.status}, body={body}"
336
+
337
+ if resp.status == 404:
338
+ raise NotFoundError(msg)
339
+ if resp.status == 403:
340
+ raise PermissionDeniedError(msg)
341
+ if resp.status == 409:
342
+ raise ConflictError(msg)
343
+ if resp.status == 400:
344
+ raise ValidationErrorResponse(msg)
345
+ if resp.status >= 500:
346
+ raise ServerError(msg)
347
+
348
+ raise RoomException(msg)
349
+
350
+ async def _ensure_success(
351
+ self, resp: aiohttp.ClientResponse, *, action: str
352
+ ) -> None:
353
+ if resp.status < 400:
354
+ return
355
+ try:
356
+ body = await resp.text()
357
+ except Exception:
358
+ body = "<unable to read body>"
359
+ raise RoomException(
360
+ f"Failed to {action}. Status code: {resp.status}, body: {body}"
361
+ )
362
+
363
+ async def upload(self, *, project_id: str, path: str, data: bytes) -> None:
364
+ """Upload a file to project storage.
365
+
366
+ Corresponds to: **POST /projects/:project_id/storage/upload**
367
+ Query params: `path`
368
+ Body: raw binary data (bytes)
369
+ Raises RoomException on HTTP >= 400.
370
+ """
371
+ url = f"{self.base_url}/projects/{project_id}/storage/upload"
372
+ params = {"path": path}
373
+
374
+ async with self._session.post(
375
+ url,
376
+ params=params,
377
+ headers={**self._get_headers(), "Content-Type": "application/octet-stream"},
378
+ data=data,
379
+ ) as resp:
380
+ if resp.status >= 400:
381
+ body = await resp.text()
382
+ raise RoomException(
383
+ f"Failed to upload file. Status code: {resp.status}, body: {body}"
384
+ )
385
+
386
+ async def download(self, *, project_id: str, path: str) -> bytes:
387
+ """Download a file from project storage.
388
+
389
+ Corresponds to: **POST /projects/:project_id/storage/download** (HTTP GET in client)
390
+ Query params: `path`
391
+ Returns raw bytes of the file.
392
+ Raises NotFoundException for 404, RoomException for other HTTP errors.
393
+ """
394
+ url = f"{self.base_url}/projects/{project_id}/storage/download"
395
+ params = {"path": path}
396
+
397
+ async with self._session.get(
398
+ url, params=params, headers=self._get_headers()
399
+ ) as resp:
400
+ if resp.status == 404:
401
+ raise RoomException("file was not found")
402
+ if resp.status >= 400:
403
+ body = await resp.text()
404
+ raise RoomException(
405
+ f"Failed to download file. Status code: {resp.status}, body: {body}"
406
+ )
407
+ return await resp.read()
408
+
409
+ async def get_project_role(self, project_id: str) -> ProjectRole:
410
+ """
411
+ Corresponds to: GET /accounts/projects/{id}/role
412
+ Returns a JSON dict with { "role" : "member" | "admin" } on success.
413
+ """
414
+ url = f"{self.base_url}/accounts/projects/{project_id}/role"
415
+
416
+ async with self._session.get(
417
+ url,
418
+ headers=self._get_headers(),
419
+ ) as resp:
420
+ await self._raise_for_status(resp)
421
+ payload = await resp.json()
422
+ return cast(ProjectRole, payload["role"])
423
+
424
+ async def create_share(
425
+ self, project_id: str, settings: Optional[dict] = None
426
+ ) -> Dict[str, Any]:
427
+ """
428
+ Corresponds to: POST /accounts/projects
429
+ Body: { "name": "<name>" }
430
+ Returns a JSON dict with { "id" } on success.
431
+ """
432
+ url = f"{self.base_url}/accounts/projects/{project_id}/shares"
433
+
434
+ payload = {"settings": settings or {}}
435
+
436
+ async with self._session.post(
437
+ url,
438
+ headers=self._get_headers(),
439
+ json=payload,
440
+ ) as resp:
441
+ await self._raise_for_status(resp)
442
+ return await resp.json()
443
+
444
+ async def delete_share(self, project_id: str, share_id: str) -> None:
445
+ """
446
+ Corresponds to: DELETE /accounts/projects/:id/shares/:share_id
447
+ """
448
+ url = f"{self.base_url}/accounts/projects/{project_id}/shares/{share_id}"
449
+
450
+ async with self._session.delete(
451
+ url,
452
+ headers=self._get_headers(),
453
+ ) as resp:
454
+ await self._raise_for_status(resp)
455
+ return None
456
+
457
+ async def update_share(
458
+ self, project_id: str, share_id: str, settings: Optional[dict] = None
459
+ ) -> None:
460
+ """
461
+ Corresponds to: PUT /accounts/projects/:id/shares/:share_id
462
+ Body: { "settings" }
463
+ """
464
+ url = f"{self.base_url}/accounts/projects/{project_id}/shares/{share_id}"
465
+
466
+ payload = {"settings": settings or {}}
467
+
468
+ async with self._session.put(
469
+ url,
470
+ headers=self._get_headers(),
471
+ json=payload,
472
+ ) as resp:
473
+ await self._raise_for_status(resp)
474
+ return None
475
+
476
+ async def list_shares(self, project_id: str) -> list[RoomShare]:
477
+ """
478
+ Corresponds to: GET /accounts/projects/:id/shares
479
+ Returns a JSON dict with { "shares" : [{ "id", "settings" }] } on success.
480
+ """
481
+ url = f"{self.base_url}/accounts/projects/{project_id}/shares"
482
+
483
+ async with self._session.get(
484
+ url,
485
+ headers=self._get_headers(),
486
+ ) as resp:
487
+ await self._raise_for_status(resp)
488
+ data = await resp.json()
489
+ try:
490
+ return [RoomShare.model_validate(item) for item in data["shares"]]
491
+ except (KeyError, ValidationError) as exc:
492
+ raise RoomException(f"Invalid shares payload: {exc}") from exc
493
+
494
+ async def connect_share(self, share_id: str) -> RoomShareConnectionInfo:
495
+ """
496
+ Corresponds to: POST /shares/:share_id/connect
497
+ Body: {}
498
+ Returns a JSON dict with { "jwt", "room_url" } on success.
499
+ """
500
+ url = f"{self.base_url}/shares/{share_id}/connect"
501
+
502
+ async with self._session.post(
503
+ url,
504
+ headers=self._get_headers(),
505
+ json={},
506
+ ) as resp:
507
+ await self._raise_for_status(resp)
508
+ data = await resp.json()
509
+ try:
510
+ return RoomShareConnectionInfo.model_validate(data)
511
+ except ValidationError as exc:
512
+ raise RoomException(f"Invalid share connection payload: {exc}") from exc
513
+
514
+ async def create_project(
515
+ self, name: str, settings: Optional[dict] = None
516
+ ) -> Dict[str, Any]:
517
+ """
518
+ Corresponds to: POST /accounts/projects
519
+ Body: { "name": "<name>" }
520
+ Returns a JSON dict with { "id", "owner_user_id", "name" } on success.
521
+ """
522
+ url = f"{self.base_url}/accounts/projects"
523
+
524
+ async with self._session.post(
525
+ url,
526
+ headers=self._get_headers(),
527
+ json={"name": name, "settings": settings},
528
+ ) as resp:
529
+ await self._raise_for_status(resp)
530
+ return await resp.json()
531
+
532
+ async def add_user_to_project(
533
+ self,
534
+ project_id: str,
535
+ user_id: str,
536
+ is_admin: bool = False,
537
+ is_developer: bool = False,
538
+ can_create_rooms: bool = False,
539
+ ) -> Dict[str, Any]:
540
+ """
541
+ Corresponds to: POST /accounts/projects/:id/users
542
+ Body: { "project_id", "user_id" }
543
+ Returns a JSON dict with { "ok": True } on success.
544
+ """
545
+ url = f"{self.base_url}/accounts/projects/{project_id}/users"
546
+ body = {
547
+ "project_id": project_id,
548
+ "user_id": user_id,
549
+ "is_admin": is_admin,
550
+ "is_developer": is_developer,
551
+ "can_create_rooms": can_create_rooms,
552
+ }
553
+ async with self._session.post(
554
+ url, headers=self._get_headers(), json=body
555
+ ) as resp:
556
+ await self._raise_for_status(resp)
557
+ return await resp.json()
558
+
559
+ async def remove_user_from_project(
560
+ self, project_id: str, user_id: str
561
+ ) -> Dict[str, Any]:
562
+ """
563
+ Corresponds to: DELETE /accounts/projects/:project_id/users/:user_id
564
+ Returns a JSON dict with { "ok": True } on success.
565
+ """
566
+ url = f"{self.base_url}/accounts/projects/{project_id}/users/{user_id}"
567
+
568
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
569
+ await self._raise_for_status(resp)
570
+ return await resp.json()
571
+
572
+ async def update_project_settings(
573
+ self, project_id: str, settings: dict
574
+ ) -> Dict[str, Any]:
575
+ """
576
+ Corresponds to: PUT /accounts/projects/:id/settings
577
+ """
578
+ url = f"{self.base_url}/accounts/projects/{project_id}/settings"
579
+
580
+ async with self._session.put(
581
+ url, headers=self._get_headers(), json=settings
582
+ ) as resp:
583
+ await self._raise_for_status(resp)
584
+ return await resp.json()
585
+
586
+ async def get_users_in_project(self, project_id: str) -> Dict[str, Any]:
587
+ """
588
+ Corresponds to: GET /accounts/projects/:id/users
589
+ Returns a JSON dict with { "users": [...] }.
590
+ """
591
+ url = f"{self.base_url}/accounts/projects/{project_id}/users"
592
+
593
+ async with self._session.get(url, headers=self._get_headers()) as resp:
594
+ await self._raise_for_status(resp)
595
+ return await resp.json()
596
+
597
+ async def get_user_profile(self, user_id: str) -> Dict[str, Any]:
598
+ """
599
+ Corresponds to: GET /accounts/profiles/:id
600
+ Returns the user profile JSON, e.g. { "id", "first_name", "last_name", "email" } or raises 404 if not found.
601
+ """
602
+ url = f"{self.base_url}/accounts/profiles/{user_id}"
603
+
604
+ async with self._session.get(url, headers=self._get_headers()) as resp:
605
+ await self._raise_for_status(resp)
606
+ return await resp.json()
607
+
608
+ async def update_user_profile(
609
+ self, user_id: str, first_name: str, last_name: str
610
+ ) -> Dict[str, Any]:
611
+ """
612
+ Corresponds to: PUT /accounts/profiles/:id
613
+ Body: { "first_name", "last_name" }
614
+ Returns a JSON dict with { "ok": True } on success.
615
+ """
616
+ url = f"{self.base_url}/accounts/profiles/{user_id}"
617
+ body = {"first_name": first_name, "last_name": last_name}
618
+
619
+ async with self._session.put(
620
+ url, headers=self._get_headers(), json=body
621
+ ) as resp:
622
+ await self._raise_for_status(resp)
623
+ return await resp.json()
624
+
625
+ async def list_projects(self) -> Dict[str, Any]:
626
+ """
627
+ Corresponds to: GET /accounts/projects
628
+ Returns a JSON dict with { "projects": [...] }.
629
+ """
630
+ url = f"{self.base_url}/accounts/projects"
631
+
632
+ async with self._session.get(url, headers=self._get_headers()) as resp:
633
+ await self._raise_for_status(resp)
634
+ return await resp.json()
635
+
636
+ async def get_project(self, project_id: str) -> Dict[str, Any]:
637
+ """
638
+ Corresponds to: GET /accounts/projects
639
+ Returns a JSON dict with { "projects": [...] }.
640
+ """
641
+ url = f"{self.base_url}/accounts/projects/{project_id}"
642
+
643
+ async with self._session.get(url, headers=self._get_headers()) as resp:
644
+ await self._raise_for_status(resp)
645
+ return await resp.json()
646
+
647
+ async def create_api_key(
648
+ self, project_id: str, name: str, description: str
649
+ ) -> Dict[str, Any]:
650
+ """
651
+ Corresponds to: POST /accounts/projects/{project_id}/api-keys
652
+ Body: { "name": "<>", "description": "<>" }
653
+ Returns a JSON dict with { "id", "name", "description", "value" }.
654
+ """
655
+ url = f"{self.base_url}/accounts/projects/{project_id}/api-keys"
656
+ payload = {"name": name, "description": description}
657
+
658
+ async with self._session.post(
659
+ url, headers=self._get_headers(), json=payload
660
+ ) as resp:
661
+ await self._raise_for_status(resp)
662
+ return await resp.json()
663
+
664
+ async def get_pricing(self) -> Dict[str, Any]:
665
+ """GET /pricing"""
666
+ url = f"{self.base_url}/pricing"
667
+ async with self._session.get(url, headers=self._get_headers()) as resp:
668
+ await self._ensure_success(resp, action="fetch pricing data")
669
+ return await resp.json()
670
+
671
+ async def get_status(self, project_id: str) -> bool:
672
+ """GET /accounts/projects/{project_id}/status"""
673
+ url = f"{self.base_url}/accounts/projects/{project_id}/status"
674
+ async with self._session.get(url, headers=self._get_headers()) as resp:
675
+ await self._ensure_success(resp, action="fetch project status")
676
+ data = await resp.json()
677
+ enabled = data.get("enabled")
678
+ if not isinstance(enabled, bool):
679
+ raise RoomException(
680
+ "Invalid status payload: expected boolean 'enabled'"
681
+ )
682
+ return enabled
683
+
684
+ async def get_balance(self, project_id: str) -> Balance:
685
+ """GET /accounts/projects/{project_id}/balance"""
686
+ url = f"{self.base_url}/accounts/projects/{project_id}/balance"
687
+ async with self._session.get(url, headers=self._get_headers()) as resp:
688
+ await self._ensure_success(resp, action="fetch balance")
689
+ data = await resp.json()
690
+ try:
691
+ return Balance.model_validate(data)
692
+ except ValidationError as exc:
693
+ raise RoomException(f"Invalid balance payload: {exc}") from exc
694
+
695
+ async def get_recent_transactions(self, project_id: str) -> List[Transaction]:
696
+ """GET /accounts/projects/{project_id}/transactions"""
697
+ url = f"{self.base_url}/accounts/projects/{project_id}/transactions"
698
+ async with self._session.get(url, headers=self._get_headers()) as resp:
699
+ await self._ensure_success(resp, action="fetch transactions")
700
+ data = await resp.json()
701
+ transactions = data.get("transactions", [])
702
+ if not isinstance(transactions, list):
703
+ raise RoomException(
704
+ "Invalid transactions payload: expected 'transactions' list"
705
+ )
706
+ try:
707
+ return [Transaction.model_validate(item) for item in transactions]
708
+ except ValidationError as exc:
709
+ raise RoomException(f"Invalid transaction payload: {exc}") from exc
710
+
711
+ async def set_auto_recharge(
712
+ self,
713
+ *,
714
+ project_id: str,
715
+ enabled: bool,
716
+ amount: float,
717
+ threshold: float,
718
+ ) -> None:
719
+ """POST /accounts/projects/{project_id}/recharge"""
720
+ url = f"{self.base_url}/accounts/projects/{project_id}/recharge"
721
+ payload = {
722
+ "enabled": enabled,
723
+ "amount": amount,
724
+ "threshold": threshold,
725
+ }
726
+ async with self._session.post(
727
+ url, headers=self._get_headers(), json=payload
728
+ ) as resp:
729
+ await self._ensure_success(resp, action="update auto recharge settings")
730
+
731
+ async def get_checkout_url(
732
+ self,
733
+ project_id: str,
734
+ *,
735
+ success_url: str,
736
+ cancel_url: str,
737
+ ) -> str:
738
+ """POST /accounts/projects/{project_id}/subscription"""
739
+ url = f"{self.base_url}/accounts/projects/{project_id}/subscription"
740
+ payload = {"success_url": success_url, "cancel_url": cancel_url}
741
+ async with self._session.post(
742
+ url, headers=self._get_headers(), json=payload
743
+ ) as resp:
744
+ await self._ensure_success(resp, action="create subscription checkout")
745
+ data = await resp.json()
746
+ checkout_url = data.get("checkout_url")
747
+ if not isinstance(checkout_url, str):
748
+ raise RoomException(
749
+ "Invalid subscription payload: expected 'checkout_url' string"
750
+ )
751
+ return checkout_url
752
+
753
+ async def get_credits_checkout_url(
754
+ self,
755
+ project_id: str,
756
+ *,
757
+ success_url: str,
758
+ cancel_url: str,
759
+ quantity: float,
760
+ ) -> str:
761
+ """POST /accounts/projects/{project_id}/credits"""
762
+ url = f"{self.base_url}/accounts/projects/{project_id}/credits"
763
+ payload = {
764
+ "quantity": quantity,
765
+ "success_url": success_url,
766
+ "cancel_url": cancel_url,
767
+ }
768
+ async with self._session.post(
769
+ url, headers=self._get_headers(), json=payload
770
+ ) as resp:
771
+ await self._ensure_success(resp, action="create credits checkout")
772
+ data = await resp.json()
773
+ checkout_url = data.get("checkout_url")
774
+ if not isinstance(checkout_url, str):
775
+ raise RoomException(
776
+ "Invalid credits payload: expected 'checkout_url' string"
777
+ )
778
+ return checkout_url
779
+
780
+ async def get_subscription(self, project_id: str) -> Dict[str, Any]:
781
+ """GET /accounts/projects/{project_id}/subscription"""
782
+ url = f"{self.base_url}/accounts/projects/{project_id}/subscription"
783
+ async with self._session.get(url, headers=self._get_headers()) as resp:
784
+ await self._ensure_success(resp, action="fetch subscription")
785
+ return await resp.json()
786
+
787
+ async def get_usage(
788
+ self,
789
+ project_id: str,
790
+ *,
791
+ start: Optional[datetime] = None,
792
+ end: Optional[datetime] = None,
793
+ interval: Optional[str] = None,
794
+ report: Optional[str] = None,
795
+ ) -> List[Dict[str, Any]]:
796
+ """
797
+ Corresponds to: GET /accounts/projects/{project_id}/usage
798
+ Allows filtering using optional start/end timestamps, interval, and report name.
799
+ """
800
+ url = f"{self.base_url}/accounts/projects/{project_id}/usage"
801
+ params: Dict[str, str] = {}
802
+ if start is not None:
803
+ params["start"] = start.isoformat()
804
+ if end is not None:
805
+ params["end"] = end.isoformat()
806
+ if interval is not None:
807
+ params["interval"] = interval
808
+ if report is not None:
809
+ params["report"] = report
810
+
811
+ async with self._session.get(
812
+ url,
813
+ headers=self._get_headers(),
814
+ params=params or None,
815
+ ) as resp:
816
+ await self._ensure_success(resp, action="retrieve usage")
817
+ data = await resp.json()
818
+ usage = data.get("usage", [])
819
+ if not isinstance(usage, list):
820
+ raise RoomException(
821
+ "Invalid usage payload: expected 'usage' to be a list"
822
+ )
823
+ return [item for item in usage if isinstance(item, dict)]
824
+
825
+ async def delete_api_key(self, project_id: str, id: str) -> None:
826
+ """
827
+ Corresponds to: DELETE /accounts/projects/{project_id}/api-keys/{token_id}
828
+ Returns 204 No Content on success (no JSON body).
829
+ """
830
+ url = f"{self.base_url}/accounts/projects/{project_id}/api-keys/{id}"
831
+
832
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
833
+ await self._raise_for_status(resp)
834
+ # The server returns status 204 with no content, so no need to parse JSON.
835
+
836
+ async def list_api_keys(self, project_id: str) -> Dict[str, Any]:
837
+ """
838
+ Corresponds to: GET /accounts/projects/{project_id}/api-keys
839
+ Returns a JSON dict like: { "tokens": [ { ... }, ... ] }.
840
+ """
841
+ url = f"{self.base_url}/accounts/projects/{project_id}/api-keys"
842
+
843
+ async with self._session.get(url, headers=self._get_headers()) as resp:
844
+ await self._raise_for_status(resp)
845
+ return await resp.json()
846
+
847
+ async def get_session(self, project_id: str, session_id: str) -> Dict[str, Any]:
848
+ """
849
+ Corresponds to: GET /accounts/projects/{project_id}/sessions/{session_id}
850
+ Returns a JSON dict: { "id", "room_name", "created_at }
851
+ """
852
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/{session_id}"
853
+
854
+ async with self._session.get(url, headers=self._get_headers()) as resp:
855
+ await self._raise_for_status(resp)
856
+ return await resp.json()
857
+
858
+ async def list_active_sessions(self, project_id: str) -> list[RoomSession]:
859
+ """
860
+ Corresponds to: GET /accounts/projects/{project_id}/sessions
861
+ Returns a JSON dict: { "sessions": [...] }
862
+ """
863
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/active"
864
+
865
+ async with self._session.get(url, headers=self._get_headers()) as resp:
866
+ await self._raise_for_status(resp)
867
+ data = await resp.json()
868
+ sessions = data.get("sessions", [])
869
+ return [RoomSession.model_validate(session) for session in sessions]
870
+
871
+ async def list_recent_sessions(self, project_id: str) -> list[RoomSession]:
872
+ """
873
+ Corresponds to: GET /accounts/projects/{project_id}/sessions
874
+ Returns a JSON dict: { "sessions": [...] }
875
+ """
876
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions"
877
+
878
+ async with self._session.get(url, headers=self._get_headers()) as resp:
879
+ await self._raise_for_status(resp)
880
+ data = await resp.json()
881
+ sessions = data.get("sessions", [])
882
+ return [RoomSession.model_validate(session) for session in sessions]
883
+
884
+ async def list_session_events(
885
+ self, project_id: str, session_id: str
886
+ ) -> list[Dict[str, Any]]:
887
+ """
888
+ Corresponds to: GET /accounts/projects/{project_id}/sessions/{session_id}/events
889
+ Returns a JSON dict: { "events": [...] }
890
+ """
891
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/{session_id}/events"
892
+
893
+ async with self._session.get(url, headers=self._get_headers()) as resp:
894
+ await self._raise_for_status(resp)
895
+ data = await resp.json()
896
+ return data.get("events", [])
897
+
898
+ async def terminate(self, project_id: str, session_id: str) -> None:
899
+ """
900
+ Corresponds to: POST /accounts/projects/{project_id}/sessions/{session_id}/terminate
901
+ Returns 204 No Content on success.
902
+ """
903
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/{session_id}/terminate"
904
+
905
+ async with self._session.post(url, headers=self._get_headers()) as resp:
906
+ await self._raise_for_status(resp)
907
+
908
+ async def list_session_spans(
909
+ self, project_id: str, session_id: str
910
+ ) -> list[Dict[str, Any]]:
911
+ """
912
+ Corresponds to: GET /accounts/projects/{project_id}/sessions/{session_id}/spans
913
+ Returns a JSON dict: { "spans": [...] }
914
+ """
915
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/{session_id}/spans"
916
+
917
+ async with self._session.get(url, headers=self._get_headers()) as resp:
918
+ await self._raise_for_status(resp)
919
+ data = await resp.json()
920
+ return data.get("spans", [])
921
+
922
+ async def list_session_metrics(
923
+ self, project_id: str, session_id: str
924
+ ) -> list[Dict[str, Any]]:
925
+ """
926
+ Corresponds to: GET /accounts/projects/{project_id}/sessions/{session_id}/metrics
927
+ Returns a JSON dict: { "metrics": [...] }
928
+ """
929
+ url = f"{self.base_url}/accounts/projects/{project_id}/sessions/{session_id}/metrics"
930
+
931
+ async with self._session.get(url, headers=self._get_headers()) as resp:
932
+ await self._raise_for_status(resp)
933
+ data = await resp.json()
934
+ return data.get("metrics", [])
935
+
936
+ async def create_webhook(
937
+ self,
938
+ project_id: str,
939
+ name: str,
940
+ url: str,
941
+ events: List[str],
942
+ description: str = "",
943
+ action: Optional[str] = "",
944
+ payload: Optional[dict] = "",
945
+ ) -> Dict[str, Any]:
946
+ """
947
+ Corresponds to: POST /accounts/projects/{project_id}/webhooks
948
+ Body: { "name", "description", "url", "events" }
949
+ The server might generate an internal webhook_id (or retrieve it from the request).
950
+ Returns whatever JSON object the server responds with (likely empty or your new resource data).
951
+ """
952
+ endpoint = f"{self.base_url}/accounts/projects/{project_id}/webhooks"
953
+ payload = {
954
+ "name": name,
955
+ "description": description,
956
+ "url": url,
957
+ "events": events,
958
+ "action": action,
959
+ "payload": payload,
960
+ }
961
+
962
+ async with self._session.post(
963
+ endpoint, headers=self._get_headers(), json=payload
964
+ ) as resp:
965
+ await self._raise_for_status(resp)
966
+ # If the server returns JSON with newly created webhook info, parse it:
967
+ return await resp.json()
968
+
969
+ async def update_webhook(
970
+ self,
971
+ project_id: str,
972
+ webhook_id: str,
973
+ name: str,
974
+ url: str,
975
+ events: List[str],
976
+ description: str = "",
977
+ action: Optional[str] = None,
978
+ payload: Optional[dict] = None,
979
+ ) -> Dict[str, Any]:
980
+ """
981
+ Corresponds to: PUT /accounts/projects/{project_id}/webhooks/{webhook_id}
982
+ Body: { "name", "description", "url", "events" }
983
+ Returns JSON (could be the updated resource or an empty dict).
984
+ """
985
+ endpoint = (
986
+ f"{self.base_url}/accounts/projects/{project_id}/webhooks/{webhook_id}"
987
+ )
988
+ payload = {
989
+ "name": name,
990
+ "description": description,
991
+ "url": url,
992
+ "events": events,
993
+ "action": action,
994
+ "payload": payload,
995
+ }
996
+
997
+ async with self._session.put(
998
+ endpoint, headers=self._get_headers(), json=payload
999
+ ) as resp:
1000
+ await self._raise_for_status(resp)
1001
+ return await resp.json()
1002
+
1003
+ async def list_webhooks(self, project_id: str) -> Dict[str, Any]:
1004
+ """
1005
+ Corresponds to: GET /accounts/projects/{project_id}/webhooks
1006
+ Returns a JSON dict like: { "webhooks": [ { ... }, ... ] }
1007
+ """
1008
+ endpoint = f"{self.base_url}/accounts/projects/{project_id}/webhooks"
1009
+
1010
+ async with self._session.get(endpoint, headers=self._get_headers()) as resp:
1011
+ await self._raise_for_status(resp)
1012
+ return await resp.json()
1013
+
1014
+ async def delete_webhook(self, project_id: str, webhook_id: str) -> None:
1015
+ """
1016
+ Corresponds to: DELETE /accounts/projects/{project_id}/webhooks/{webhook_id}
1017
+ Typically returns 200 or 204 on success (no JSON body).
1018
+ """
1019
+ endpoint = (
1020
+ f"{self.base_url}/accounts/projects/{project_id}/webhooks/{webhook_id}"
1021
+ )
1022
+
1023
+ async with self._session.delete(endpoint, headers=self._get_headers()) as resp:
1024
+ await self._raise_for_status(resp)
1025
+
1026
+ async def create_mailbox(
1027
+ self, *, project_id: str, address: str, room: str, queue: str
1028
+ ) -> None:
1029
+ """
1030
+ POST /accounts/projects/{project_id}/mailboxes
1031
+ Body: { "address", "room", "queue" }
1032
+ Returns {} on success.
1033
+ """
1034
+ url = f"{self.base_url}/accounts/projects/{project_id}/mailboxes"
1035
+ payload = _CreateMailboxRequest(
1036
+ address=address, room=room, queue=queue
1037
+ ).model_dump(mode="json")
1038
+ async with self._session.post(
1039
+ url, headers=self._get_headers(), json=payload
1040
+ ) as resp:
1041
+ await self._raise_for_status(resp)
1042
+
1043
+ async def update_mailbox(
1044
+ self, *, project_id: str, address: str, room: str, queue: str
1045
+ ) -> None:
1046
+ """
1047
+ PUT /accounts/projects/{project_id}/mailboxes/{address}
1048
+ Body: { "room", "queue" }
1049
+ Returns {} on success.
1050
+ """
1051
+ url = f"{self.base_url}/accounts/projects/{project_id}/mailboxes/{address}"
1052
+ payload = _UpdateMailboxRequest(room=room, queue=queue).model_dump(mode="json")
1053
+ async with self._session.put(
1054
+ url, headers=self._get_headers(), json=payload
1055
+ ) as resp:
1056
+ await self._raise_for_status(resp)
1057
+
1058
+ async def get_mailbox(self, *, project_id: str, address: str) -> Mailbox:
1059
+ """
1060
+ GET /accounts/projects/{project_id}/mailboxes/{address}
1061
+ Returns a list[Mailbox].
1062
+ """
1063
+ url = f"{self.base_url}/accounts/projects/{project_id}/mailboxes/{address}"
1064
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1065
+ await self._raise_for_status(resp)
1066
+ return Mailbox.model_validate((await resp.json())["mailbox"])
1067
+
1068
+ async def list_mailboxes(self, *, project_id: str) -> List[Mailbox]:
1069
+ """
1070
+ GET /accounts/projects/{project_id}/mailboxes
1071
+ Returns a list[Mailbox].
1072
+ """
1073
+ url = f"{self.base_url}/accounts/projects/{project_id}/mailboxes"
1074
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1075
+ await self._raise_for_status(resp)
1076
+ data = await resp.json()
1077
+ try:
1078
+ return [Mailbox.model_validate(item) for item in data["mailboxes"]]
1079
+ except ValidationError as exc:
1080
+ raise RoomException(f"Invalid mailboxes payload: {exc}") from exc
1081
+
1082
+ async def delete_mailbox(self, *, project_id: str, address: str) -> None:
1083
+ """
1084
+ DELETE /accounts/projects/{project_id}/mailboxes/{address}
1085
+ Returns {} on success.
1086
+ """
1087
+ url = f"{self.base_url}/accounts/projects/{project_id}/mailboxes/{address}"
1088
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1089
+ await self._raise_for_status(resp)
1090
+
1091
+ async def create_service(
1092
+ self,
1093
+ *,
1094
+ project_id: str,
1095
+ service: ServiceSpec,
1096
+ ) -> str:
1097
+ """
1098
+ POST /accounts/projects/{project_id}/services
1099
+ Body: full service spec, e.g.
1100
+ {
1101
+ "name": "...",
1102
+ "image": "...",
1103
+ "pull_secret": "...",
1104
+ "environment": {...},
1105
+ "environment_secrets": [...],
1106
+ "runtime_secrets": {...},
1107
+ "command": "...",
1108
+ "ports": {...}
1109
+ }
1110
+ Returns: { "id": "<service_id>" }
1111
+ """
1112
+ url = f"{self.base_url}/accounts/projects/{project_id}/services"
1113
+ async with self._session.post(
1114
+ url,
1115
+ headers=self._get_headers(),
1116
+ json=service.model_dump(mode="json", exclude_unset=True),
1117
+ ) as resp:
1118
+ await self._raise_for_status(resp)
1119
+ return (await resp.json())["id"]
1120
+
1121
+ async def update_service(
1122
+ self,
1123
+ *,
1124
+ project_id: str,
1125
+ service_id: str,
1126
+ service: ServiceSpec,
1127
+ ) -> None:
1128
+ """
1129
+ PUT /accounts/projects/{project_id}/services/{service_id}
1130
+ Body: same structure as create_service (fields you wish to change).
1131
+ Returns: {} on success.
1132
+ """
1133
+
1134
+ if service.id is None:
1135
+ raise RoomException("Service id must be set")
1136
+
1137
+ url = f"{self.base_url}/accounts/projects/{project_id}/services/{service_id}"
1138
+ async with self._session.put(
1139
+ url, headers=self._get_headers(), json=service.model_dump(mode="json")
1140
+ ) as resp:
1141
+ await self._raise_for_status(resp)
1142
+ await resp.json()
1143
+
1144
+ async def get_service(self, *, project_id: str, service_id: str) -> ServiceSpec:
1145
+ """
1146
+ GET /accounts/projects/{project_id}/services/{service_id}
1147
+ Returns a `Service` instance.
1148
+ """
1149
+ url = f"{self.base_url}/accounts/projects/{project_id}/services/{service_id}"
1150
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1151
+ await self._raise_for_status(resp)
1152
+ # Handler returns a JSON string, so we read text then validate
1153
+ raw = await resp.text()
1154
+ try:
1155
+ return ServiceSpec.model_validate_json(raw)
1156
+ except ValidationError as exc:
1157
+ raise RoomException(f"Invalid service payload: {exc}") from exc
1158
+
1159
+ async def list_services(self, *, project_id: str) -> List[ServiceSpec]:
1160
+ """
1161
+ GET /accounts/projects/{project_id}/services
1162
+ Returns a list of `Service` instances.
1163
+ """
1164
+ url = f"{self.base_url}/accounts/projects/{project_id}/services"
1165
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1166
+ await self._raise_for_status(resp)
1167
+ data = await resp.json()
1168
+ try:
1169
+ return [ServiceSpec.model_validate(item) for item in data["services"]]
1170
+ except ValidationError as exc:
1171
+ raise RoomException(f"Invalid services payload: {exc}") from exc
1172
+
1173
+ async def delete_service(self, *, project_id: str, service_id: str) -> None:
1174
+ """
1175
+ DELETE /accounts/projects/{project_id}/services/{service_id}
1176
+ Returns nothing on success.
1177
+ """
1178
+ url = f"{self.base_url}/accounts/projects/{project_id}/services/{service_id}"
1179
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1180
+ await self._raise_for_status(resp)
1181
+
1182
+ async def create_room_service(
1183
+ self,
1184
+ *,
1185
+ project_id: str,
1186
+ room_name: str,
1187
+ service: ServiceSpec,
1188
+ ) -> str:
1189
+ """
1190
+ POST /accounts/projects/{project_id}/services
1191
+ Body: full service spec, e.g.
1192
+ {
1193
+ "name": "...",
1194
+ "image": "...",
1195
+ "pull_secret": "...",
1196
+ "environment": {...},
1197
+ "environment_secrets": [...],
1198
+ "runtime_secrets": {...},
1199
+ "command": "...",
1200
+ "ports": {...}
1201
+ }
1202
+ Returns: { "id": "<service_id>" }
1203
+ """
1204
+ url = (
1205
+ f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_name}/services"
1206
+ )
1207
+ async with self._session.post(
1208
+ url,
1209
+ headers=self._get_headers(),
1210
+ json=service.model_dump(mode="json", exclude_unset=True),
1211
+ ) as resp:
1212
+ await self._raise_for_status(resp)
1213
+ return (await resp.json())["id"]
1214
+
1215
+ async def update_room_service(
1216
+ self,
1217
+ *,
1218
+ project_id: str,
1219
+ room_name: str,
1220
+ service_id: str,
1221
+ service: ServiceSpec,
1222
+ ) -> NotImplemented:
1223
+ """
1224
+ PUT /accounts/projects/{project_id}/services/{service_id}
1225
+ Body: same structure as create_service (fields you wish to change).
1226
+ Returns: {} on success.
1227
+ """
1228
+
1229
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_name}/services/{service_id}"
1230
+ async with self._session.put(
1231
+ url, headers=self._get_headers(), json=service.model_dump(mode="json")
1232
+ ) as resp:
1233
+ await self._raise_for_status(resp)
1234
+ await resp.json()
1235
+
1236
+ async def get_room_service(
1237
+ self, *, project_id: str, room_name: str, service_id: str
1238
+ ) -> ServiceSpec:
1239
+ """
1240
+ GET /accounts/projects/{project_id}/services/{service_id}
1241
+ Returns a `Service` instance.
1242
+ """
1243
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_name}/services/{service_id}"
1244
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1245
+ await self._raise_for_status(resp)
1246
+ # Handler returns a JSON string, so we read text then validate
1247
+ raw = await resp.text()
1248
+ try:
1249
+ return ServiceSpec.model_validate_json(raw)
1250
+ except ValidationError as exc:
1251
+ raise RoomException(f"Invalid service payload: {exc}") from exc
1252
+
1253
+ async def list_room_services(
1254
+ self, *, project_id: str, room_name: str
1255
+ ) -> List[ServiceSpec]:
1256
+ """
1257
+ GET /accounts/projects/{project_id}/services
1258
+ Returns a list of `Service` instances.
1259
+ """
1260
+ url = (
1261
+ f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_name}/services"
1262
+ )
1263
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1264
+ await self._raise_for_status(resp)
1265
+ data = await resp.json()
1266
+ try:
1267
+ return [ServiceSpec.model_validate(item) for item in data["services"]]
1268
+ except ValidationError as exc:
1269
+ raise RoomException(f"Invalid services payload: {exc}") from exc
1270
+
1271
+ async def delete_room_service(
1272
+ self, *, project_id: str, room_name: str, service_id: str
1273
+ ) -> None:
1274
+ """
1275
+ DELETE /accounts/projects/{project_id}/services/{service_id}
1276
+ Returns nothing on success.
1277
+ """
1278
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_name}/services/{service_id}"
1279
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1280
+ await self._raise_for_status(resp)
1281
+
1282
+ async def create_secret(
1283
+ self,
1284
+ *,
1285
+ project_id: str,
1286
+ secret: SecretLike,
1287
+ ) -> str:
1288
+ """
1289
+ POST /accounts/projects/{project_id}/secrets
1290
+ Returns the new secret_id.
1291
+ """
1292
+ url = f"{self.base_url}/accounts/projects/{project_id}/secrets"
1293
+ payload = {
1294
+ "name": secret.name,
1295
+ "type": secret.type, # "docker" | "keys"
1296
+ "data": secret.to_payload(), # already shaped for the provisioner
1297
+ }
1298
+ async with self._session.post(
1299
+ url, headers=self._get_headers(), json=payload
1300
+ ) as resp:
1301
+ await self._raise_for_status(resp)
1302
+ return (await resp.json())["id"]
1303
+
1304
+ async def update_secret(
1305
+ self,
1306
+ *,
1307
+ project_id: str,
1308
+ secret: SecretLike,
1309
+ ) -> None:
1310
+ """
1311
+ PUT /accounts/projects/{project_id}/secrets/{secret.id}
1312
+ Body ➜ { "name", "type", "data" }
1313
+ """
1314
+ url = f"{self.base_url}/accounts/projects/{project_id}/secrets/{secret.id}"
1315
+ payload = {
1316
+ "name": secret.name,
1317
+ "type": secret.type,
1318
+ "data": secret.to_payload(),
1319
+ }
1320
+ async with self._session.put(
1321
+ url, headers=self._get_headers(), json=payload
1322
+ ) as resp:
1323
+ await self._raise_for_status(resp)
1324
+
1325
+ async def delete_secret(self, *, project_id: str, secret_id: str) -> None:
1326
+ """
1327
+ DELETE /accounts/projects/{project_id}/secrets/{secret_id}
1328
+ Returns {} (or 204 No Content) on success.
1329
+ """
1330
+ url = f"{self.base_url}/accounts/projects/{project_id}/secrets/{secret_id}"
1331
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1332
+ await self._raise_for_status(resp)
1333
+
1334
+ async def list_secrets(self, project_id: str) -> List[SecretLike]:
1335
+ """
1336
+ GET /accounts/projects/{project_id}/secrets
1337
+ Returns [PullSecret | KeysSecret, …]
1338
+ """
1339
+ url = f"{self.base_url}/accounts/projects/{project_id}/secrets"
1340
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1341
+ await self._raise_for_status(resp)
1342
+ raw = await resp.json()
1343
+ return [_parse_secret(item) for item in raw["secrets"]]
1344
+
1345
+ async def create_room(
1346
+ self,
1347
+ *,
1348
+ project_id: str,
1349
+ name: str,
1350
+ if_not_exists: bool = False,
1351
+ metadata: Optional[dict[str, any]] = None,
1352
+ permissions: Optional[dict[str, ApiScope]] = None,
1353
+ ) -> Room:
1354
+ """
1355
+ POST /accounts/projects/{project_id}/rooms
1356
+ Body: { "name": str, "if_not_exists?": bool }
1357
+ Returns Room.
1358
+ """
1359
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms"
1360
+ payload = {
1361
+ "name": name,
1362
+ "if_not_exists": bool(if_not_exists),
1363
+ "metadata": metadata,
1364
+ "permissions": permissions,
1365
+ }
1366
+ async with self._session.post(
1367
+ url, headers=self._get_headers(), json=payload
1368
+ ) as resp:
1369
+ await self._raise_for_status(resp)
1370
+ try:
1371
+ return Room.model_validate(await resp.json())
1372
+ except ValidationError as exc:
1373
+ raise RoomException(f"Invalid room payload: {exc}") from exc
1374
+
1375
+ async def get_room(self, *, project_id: str, name: str) -> Room:
1376
+ """
1377
+ GET /accounts/projects/{project_id}/rooms/{room_name}
1378
+ Returns Room.
1379
+ """
1380
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{name}"
1381
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1382
+ if resp.status == 404:
1383
+ raise RoomException("room not found")
1384
+ await self._raise_for_status(resp)
1385
+ try:
1386
+ return Room.model_validate(await resp.json())
1387
+ except ValidationError as exc:
1388
+ raise RoomException(f"Invalid room payload: {exc}") from exc
1389
+
1390
+ async def update_room(
1391
+ self,
1392
+ *,
1393
+ project_id: str,
1394
+ room_id: str,
1395
+ name: str,
1396
+ metadata: Optional[dict[str, any]] = None,
1397
+ ) -> None:
1398
+ """
1399
+ PUT /accounts/projects/{project_id}/rooms/{room_id}
1400
+ Body: { "name": str }
1401
+ """
1402
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_id}"
1403
+ payload = {"name": name}
1404
+
1405
+ if metadata is not None:
1406
+ payload["metadata"] = metadata
1407
+
1408
+ async with self._session.put(
1409
+ url, headers=self._get_headers(), json=payload
1410
+ ) as resp:
1411
+ await self._raise_for_status(resp)
1412
+
1413
+ async def delete_room(self, *, project_id: str, room_id: str) -> None:
1414
+ """
1415
+ DELETE /accounts/projects/{project_id}/rooms/{room_id}
1416
+ """
1417
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room_id}"
1418
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1419
+ await self._raise_for_status(resp)
1420
+
1421
+ async def connect_room(self, *, project_id: str, room: str) -> RoomConnectionInfo:
1422
+ """
1423
+ POST /accounts/projects/{project_id}/rooms/{room_name}/connect
1424
+ Returns: { "jwt", "room_name", "project_id", "room_url" }
1425
+ """
1426
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms/{room}/connect"
1427
+ async with self._session.post(
1428
+ url, headers=self._get_headers(), json={}
1429
+ ) as resp:
1430
+ await self._raise_for_status(resp)
1431
+ return RoomConnectionInfo.model_validate(await resp.json())
1432
+
1433
+ async def create_room_grant(
1434
+ self,
1435
+ *,
1436
+ project_id: str,
1437
+ room_id: str,
1438
+ user_id: str,
1439
+ permissions: Dict[str, Any],
1440
+ ) -> None:
1441
+ """
1442
+ POST /accounts/projects/{project_id}/room-grants
1443
+ Body: { "room_id", "user_id", "permissions" }
1444
+ Returns {} on success.
1445
+ """
1446
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants"
1447
+ payload = _CreateRoomGrantRequest(
1448
+ room_id=room_id,
1449
+ user_id=user_id,
1450
+ permissions=permissions,
1451
+ ).model_dump(mode="json")
1452
+
1453
+ async with self._session.post(
1454
+ url, headers=self._get_headers(), json=payload
1455
+ ) as resp:
1456
+ await self._raise_for_status(resp)
1457
+
1458
+ async def create_room_grant_by_email(
1459
+ self,
1460
+ *,
1461
+ project_id: str,
1462
+ room_id: str,
1463
+ email: str,
1464
+ permissions: ApiScope,
1465
+ ) -> None:
1466
+ """
1467
+ POST /accounts/projects/{project_id}/room-grants
1468
+ Body: { "room_id", "user_id", "permissions" }
1469
+ Returns {} on success.
1470
+ """
1471
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants"
1472
+ payload = _CreateRoomGrantRequest(
1473
+ room_id=room_id,
1474
+ email=email,
1475
+ permissions=permissions,
1476
+ ).model_dump(mode="json")
1477
+
1478
+ async with self._session.post(
1479
+ url, headers=self._get_headers(), json=payload
1480
+ ) as resp:
1481
+ await self._raise_for_status(resp)
1482
+
1483
+ async def update_room_grant(
1484
+ self,
1485
+ *,
1486
+ project_id: str,
1487
+ room_id: str,
1488
+ user_id: str,
1489
+ permissions: ApiScope,
1490
+ grant_id: Optional[str] = None,
1491
+ ) -> None:
1492
+ """
1493
+ PUT /accounts/projects/{project_id}/room-grants/{grant_id}
1494
+ Body: { "room_id", "user_id", "permissions" }
1495
+ NOTE: The server handler currently ignores grant_id and updates by (project_id, room_id, user_id).
1496
+ """
1497
+ gid = grant_id or "unused"
1498
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants/{gid}"
1499
+ payload = _UpdateRoomGrantRequest(
1500
+ room_id=room_id,
1501
+ user_id=user_id,
1502
+ permissions=permissions,
1503
+ ).model_dump(mode="json")
1504
+
1505
+ async with self._session.put(
1506
+ url, headers=self._get_headers(), json=payload
1507
+ ) as resp:
1508
+ await self._raise_for_status(resp)
1509
+
1510
+ async def delete_room_grant(
1511
+ self, *, project_id: str, room_id: str, user_id: str
1512
+ ) -> None:
1513
+ """
1514
+ DELETE /accounts/projects/{project_id}/room-grants/{room_id}/{user_id}
1515
+ Returns {} on success.
1516
+ """
1517
+ from urllib.parse import quote
1518
+
1519
+ url = (
1520
+ f"{self.base_url}/accounts/projects/{project_id}"
1521
+ f"/room-grants/{quote(room_id, safe='')}/{quote(user_id, safe='')}"
1522
+ )
1523
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1524
+ await self._raise_for_status(resp)
1525
+
1526
+ async def get_room_grant(
1527
+ self, *, project_id: str, room_id: str, user_id: str
1528
+ ) -> ProjectRoomGrant:
1529
+ """
1530
+ GET /accounts/projects/{project_id}/room-grants/{room_id}/{user_id}
1531
+ Returns ProjectRoomGrant
1532
+ """
1533
+ from urllib.parse import quote
1534
+
1535
+ url = (
1536
+ f"{self.base_url}/accounts/projects/{project_id}"
1537
+ f"/room-grants/{quote(room_id, safe='')}/{quote(user_id, safe='')}"
1538
+ )
1539
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1540
+ await self._raise_for_status(resp)
1541
+ data = await resp.json()
1542
+ try:
1543
+ return ProjectRoomGrant.model_validate(data)
1544
+ except ValidationError as exc:
1545
+ raise RoomException(f"Invalid room grant payload: {exc}") from exc
1546
+
1547
+ async def list_rooms(
1548
+ self,
1549
+ *,
1550
+ project_id: str,
1551
+ limit: int = 50,
1552
+ offset: int = 0,
1553
+ order_by: str = "room_name",
1554
+ ) -> List[ProjectRoomGrant]:
1555
+ """
1556
+ GET /accounts/projects/{project_id}/rooms?limit=&offset=&order_by=
1557
+ Returns [Rooms]
1558
+ """
1559
+ params = {"limit": str(limit), "offset": str(offset), "order_by": order_by}
1560
+ url = f"{self.base_url}/accounts/projects/{project_id}/rooms"
1561
+ async with self._session.get(
1562
+ url, headers=self._get_headers(), params=params
1563
+ ) as resp:
1564
+ await self._raise_for_status(resp)
1565
+ data = await resp.json()
1566
+ try:
1567
+ return [Room.model_validate(item) for item in data["rooms"]]
1568
+ except ValidationError as exc:
1569
+ raise RoomException(f"Invalid rooms list payload: {exc}") from exc
1570
+
1571
+ async def list_room_grants(
1572
+ self,
1573
+ *,
1574
+ project_id: str,
1575
+ limit: int = 50,
1576
+ offset: int = 0,
1577
+ order_by: str = "room_name",
1578
+ ) -> List[ProjectRoomGrant]:
1579
+ """
1580
+ GET /accounts/projects/{project_id}/room-grants?limit=&offset=&order_by=
1581
+ Returns [ProjectRoomGrant]
1582
+ """
1583
+ params = {"limit": str(limit), "offset": str(offset), "order_by": order_by}
1584
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants"
1585
+ async with self._session.get(
1586
+ url, headers=self._get_headers(), params=params
1587
+ ) as resp:
1588
+ await self._raise_for_status(resp)
1589
+ data = await resp.json()
1590
+ try:
1591
+ return [
1592
+ ProjectRoomGrant.model_validate(item)
1593
+ for item in data["room_grants"]
1594
+ ]
1595
+ except ValidationError as exc:
1596
+ raise RoomException(f"Invalid room grants list payload: {exc}") from exc
1597
+
1598
+ async def list_room_grants_by_user(
1599
+ self,
1600
+ *,
1601
+ project_id: str,
1602
+ user_id: str,
1603
+ limit: int = 50,
1604
+ offset: int = 0,
1605
+ ) -> List[ProjectRoomGrant]:
1606
+ """
1607
+ GET /accounts/projects/{project_id}/room-grants/by-user/{user_id}?limit=&offset=&order_by=
1608
+ Returns [ProjectRoomGrant]
1609
+ """
1610
+ from urllib.parse import quote
1611
+
1612
+ params = {"limit": str(limit), "offset": str(offset)}
1613
+ url = (
1614
+ f"{self.base_url}/accounts/projects/{project_id}"
1615
+ f"/room-grants/by-user/{quote(user_id, safe='')}"
1616
+ )
1617
+ async with self._session.get(
1618
+ url, headers=self._get_headers(), params=params
1619
+ ) as resp:
1620
+ await self._raise_for_status(resp)
1621
+ data = await resp.json()
1622
+ try:
1623
+ return [
1624
+ ProjectRoomGrant.model_validate(item)
1625
+ for item in data["room_grants"]
1626
+ ]
1627
+ except ValidationError as exc:
1628
+ raise RoomException(
1629
+ f"Invalid room grants-by-user payload: {exc}"
1630
+ ) from exc
1631
+
1632
+ async def list_room_grants_by_room(
1633
+ self,
1634
+ *,
1635
+ project_id: str,
1636
+ room_name: str,
1637
+ limit: int = 50,
1638
+ offset: int = 0,
1639
+ ) -> List[ProjectRoomGrant]:
1640
+ """
1641
+ GET /accounts/projects/{project_id}/room-grants/by-room/{room_id}?limit=&offset=
1642
+ Returns [ProjectRoomGrant]
1643
+ """
1644
+ from urllib.parse import quote
1645
+
1646
+ params = {"limit": str(limit), "offset": str(offset)}
1647
+ url = (
1648
+ f"{self.base_url}/accounts/projects/{project_id}"
1649
+ f"/room-grants/by-room/{quote(room_name, safe='')}"
1650
+ )
1651
+ async with self._session.get(
1652
+ url, headers=self._get_headers(), params=params
1653
+ ) as resp:
1654
+ await self._raise_for_status(resp)
1655
+ data = await resp.json()
1656
+ try:
1657
+ return [
1658
+ ProjectRoomGrant.model_validate(item)
1659
+ for item in data["room_grants"]
1660
+ ]
1661
+ except ValidationError as exc:
1662
+ raise RoomException(
1663
+ f"Invalid room grants-by-room payload: {exc}"
1664
+ ) from exc
1665
+
1666
+ async def list_unique_rooms_with_grants(
1667
+ self,
1668
+ *,
1669
+ project_id: str,
1670
+ limit: int = 50,
1671
+ offset: int = 0,
1672
+ ) -> List[ProjectRoomGrantCount]:
1673
+ """
1674
+ GET /accounts/projects/{project_id}/room-grants/by-room?limit=&offset=
1675
+ Returns [ProjectRoomGrantCount]; accepts either {"room": "..."} or {"room_name": "..."} shapes.
1676
+ """
1677
+ params = {"limit": str(limit), "offset": str(offset)}
1678
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants/by-room"
1679
+ async with self._session.get(
1680
+ url, headers=self._get_headers(), params=params
1681
+ ) as resp:
1682
+ await self._raise_for_status(resp)
1683
+ data = await resp.json()
1684
+ items = data.get("rooms", [])
1685
+ out: List[ProjectRoomGrantCount] = []
1686
+ for item in items:
1687
+ # tolerate either key name
1688
+ out.append(ProjectRoomGrantCount.model_validate(item))
1689
+ return out
1690
+
1691
+ async def list_unique_users_with_grants(
1692
+ self,
1693
+ *,
1694
+ project_id: str,
1695
+ limit: int = 50,
1696
+ offset: int = 0,
1697
+ ) -> List[ProjectUserGrantCount]:
1698
+ """
1699
+ GET /accounts/projects/{project_id}/room-grants/by-user?limit=&offset=
1700
+ Returns [ProjectUserGrantCount]
1701
+ """
1702
+ params = {"limit": str(limit), "offset": str(offset)}
1703
+ url = f"{self.base_url}/accounts/projects/{project_id}/room-grants/by-user"
1704
+ async with self._session.get(
1705
+ url, headers=self._get_headers(), params=params
1706
+ ) as resp:
1707
+ await self._raise_for_status(resp)
1708
+ data = await resp.json()
1709
+ items = data.get("users", [])
1710
+ out: List[ProjectUserGrantCount] = []
1711
+ for item in items:
1712
+ out.append(ProjectUserGrantCount.model_validate(item))
1713
+ return out
1714
+
1715
+ async def create_oauth_client(
1716
+ self,
1717
+ *,
1718
+ project_id: str,
1719
+ grant_types: List[str],
1720
+ response_types: List[str],
1721
+ redirect_uris: List[str],
1722
+ scope: str,
1723
+ metadata: Optional[Dict[str, Any]] = None,
1724
+ official: bool = False,
1725
+ ) -> OAuthClient:
1726
+ """
1727
+ POST /accounts/projects/{project_id}/oauth/clients
1728
+ Body: { grant_types, response_types, redirect_uris, scope, metadata? }
1729
+ Returns the newly created client (including client_secret).
1730
+ """
1731
+ url = f"{self.base_url}/accounts/projects/{project_id}/oauth/clients"
1732
+ payload = {
1733
+ "grant_types": grant_types,
1734
+ "response_types": response_types,
1735
+ "redirect_uris": redirect_uris,
1736
+ "scope": scope,
1737
+ "metadata": metadata or {},
1738
+ "official": official,
1739
+ }
1740
+ async with self._session.post(
1741
+ url, headers=self._get_headers(), json=payload
1742
+ ) as resp:
1743
+ await self._raise_for_status(resp)
1744
+ raw = await resp.json()
1745
+ try:
1746
+ return OAuthClient.model_validate(raw)
1747
+ except ValidationError as exc:
1748
+ raise RoomException(
1749
+ f"Invalid create-oauth-client payload: {exc}"
1750
+ ) from exc
1751
+
1752
+ async def update_oauth_client(
1753
+ self,
1754
+ *,
1755
+ project_id: str,
1756
+ client_id: str,
1757
+ grant_types: Optional[List[str]] = None,
1758
+ response_types: Optional[List[str]] = None,
1759
+ redirect_uris: Optional[List[str]] = None,
1760
+ scope: Optional[str] = None,
1761
+ metadata: Optional[Dict[str, Any]] = None,
1762
+ official: Optional[bool] = None,
1763
+ ) -> Dict[str, Any]:
1764
+ """
1765
+ PUT /accounts/projects/{project_id}/oauth/clients/{client_id}
1766
+ Body: any subset of { grant_types, response_types, redirect_uris, scope, metadata }
1767
+ Returns { "ok": True } on success.
1768
+ """
1769
+ url = (
1770
+ f"{self.base_url}/accounts/projects/{project_id}/oauth/clients/{client_id}"
1771
+ )
1772
+ body: Dict[str, Any] = {}
1773
+ if grant_types is not None:
1774
+ body["grant_types"] = grant_types
1775
+ if response_types is not None:
1776
+ body["response_types"] = response_types
1777
+ if redirect_uris is not None:
1778
+ body["redirect_uris"] = redirect_uris
1779
+ if scope is not None:
1780
+ body["scope"] = scope
1781
+ if metadata is not None:
1782
+ body["metadata"] = metadata
1783
+ if official is not None:
1784
+ body["official"] = official
1785
+
1786
+ async with self._session.put(
1787
+ url, headers=self._get_headers(), json=body
1788
+ ) as resp:
1789
+ await self._raise_for_status(resp)
1790
+ return await resp.json()
1791
+
1792
+ async def list_oauth_clients(self, *, project_id: str) -> List[OAuthClient]:
1793
+ """
1794
+ GET /accounts/projects/{project_id}/oauth/clients
1795
+ Returns a list of OAuthClient (no secrets).
1796
+ """
1797
+ url = f"{self.base_url}/accounts/projects/{project_id}/oauth/clients"
1798
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1799
+ await self._raise_for_status(resp)
1800
+ raw = await resp.json()
1801
+ try:
1802
+ return [
1803
+ OAuthClient.model_validate(item) for item in raw.get("clients", [])
1804
+ ]
1805
+ except ValidationError as exc:
1806
+ raise RoomException(
1807
+ f"Invalid oauth-clients list payload: {exc}"
1808
+ ) from exc
1809
+
1810
+ async def get_oauth_client(
1811
+ self, *, project_id: Optional[str], client_id: str
1812
+ ) -> OAuthClient:
1813
+ """
1814
+ GET /accounts/projects/{project_id}/oauth/clients/{client_id}
1815
+ Returns the OAuthClient (no secret).
1816
+ """
1817
+
1818
+ url = (
1819
+ f"{self.base_url}/accounts/projects/{project_id}/oauth/clients/{client_id}"
1820
+ )
1821
+ async with self._session.get(url, headers=self._get_headers()) as resp:
1822
+ if resp.status == 404:
1823
+ raise RoomException("oauth client not found")
1824
+ await self._raise_for_status(resp)
1825
+ raw = await resp.json()
1826
+ try:
1827
+ return OAuthClient.model_validate(raw)
1828
+ except ValidationError as exc:
1829
+ raise RoomException(f"Invalid oauth-client payload: {exc}") from exc
1830
+
1831
+ async def delete_oauth_client(self, *, project_id: str, client_id: str) -> None:
1832
+ """
1833
+ DELETE /accounts/projects/{project_id}/oauth/clients/{client_id}
1834
+ Returns 204 No Content on success.
1835
+ """
1836
+ url = (
1837
+ f"{self.base_url}/accounts/projects/{project_id}/oauth/clients/{client_id}"
1838
+ )
1839
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1840
+ await self._raise_for_status(resp)
1841
+
1842
+ # ---------------------------
1843
+ # Scheduled Tasks
1844
+ # ---------------------------
1845
+
1846
+ async def create_scheduled_task(
1847
+ self,
1848
+ *,
1849
+ project_id: str,
1850
+ room_name: str,
1851
+ queue_name: str,
1852
+ payload: Any,
1853
+ schedule: str,
1854
+ active: bool = True,
1855
+ task_id: Optional[str] = None,
1856
+ once: bool = False,
1857
+ annotations: Optional[dict[str, str]] = None,
1858
+ ) -> str:
1859
+ """
1860
+ POST /accounts/projects/{project_id}/scheduled-tasks
1861
+
1862
+ payload can be dict (preferred) or json-string.
1863
+ Returns the created ScheduledTask when the server returns it.
1864
+ """
1865
+ url = f"{self.base_url}/accounts/projects/{project_id}/scheduled-tasks"
1866
+
1867
+ body = _CreateScheduledTaskRequest(
1868
+ id=task_id,
1869
+ room_name=room_name,
1870
+ queue_name=queue_name,
1871
+ payload=payload,
1872
+ schedule=schedule,
1873
+ active=active,
1874
+ once=once,
1875
+ annotations=annotations,
1876
+ ).model_dump(mode="json", exclude_none=True)
1877
+
1878
+ async with self._session.post(
1879
+ url, headers=self._get_headers(), json=body
1880
+ ) as resp:
1881
+ await self._ensure_success(resp, action="create scheduled task")
1882
+ data = await resp.json()
1883
+ return data["task_id"]
1884
+
1885
+ async def update_scheduled_task(
1886
+ self,
1887
+ *,
1888
+ project_id: str,
1889
+ task_id: str,
1890
+ room_name: Optional[str] = None,
1891
+ queue_name: Optional[str] = None,
1892
+ payload: Optional[Any] = None,
1893
+ schedule: Optional[str] = None,
1894
+ active: Optional[bool] = None,
1895
+ annotations: Optional[dict[str, str]] = None,
1896
+ ) -> None:
1897
+ """
1898
+ PUT /accounts/projects/{project_id}/scheduled-tasks/{task_id}
1899
+
1900
+ Patch-like update. Any omitted fields are left unchanged.
1901
+ Returns the updated ScheduledTask when the server returns it; otherwise fetches it.
1902
+ """
1903
+ url = (
1904
+ f"{self.base_url}/accounts/projects/{project_id}/scheduled-tasks/{task_id}"
1905
+ )
1906
+
1907
+ body = _UpdateScheduledTaskRequest(
1908
+ room_name=room_name,
1909
+ queue_name=queue_name,
1910
+ payload=payload,
1911
+ schedule=schedule,
1912
+ active=active,
1913
+ annotations=annotations,
1914
+ ).model_dump(mode="json", exclude_none=True)
1915
+
1916
+ async with self._session.put(
1917
+ url, headers=self._get_headers(), json=body
1918
+ ) as resp:
1919
+ await self._ensure_success(resp, action="update scheduled task")
1920
+
1921
+ async def delete_scheduled_task(self, *, project_id: str, task_id: str) -> None:
1922
+ """
1923
+ DELETE /accounts/projects/{project_id}/scheduled-tasks/{task_id}
1924
+ Returns 204 No Content on success.
1925
+ """
1926
+ url = (
1927
+ f"{self.base_url}/accounts/projects/{project_id}/scheduled-tasks/{task_id}"
1928
+ )
1929
+ async with self._session.delete(url, headers=self._get_headers()) as resp:
1930
+ await self._ensure_success(resp, action="delete scheduled task")
1931
+ return None
1932
+
1933
+ async def list_scheduled_tasks(
1934
+ self,
1935
+ *,
1936
+ project_id: str,
1937
+ room_name: Optional[str] = None,
1938
+ task_id: Optional[str] = None,
1939
+ active: Optional[bool] = None,
1940
+ limit: int = 200,
1941
+ offset: int = 0,
1942
+ ) -> List[ScheduledTask]:
1943
+ """
1944
+ GET /accounts/projects/{project_id}/scheduled-tasks?room_name=&task_id=&active=&limit=&offset=
1945
+ Returns a list[ScheduledTask].
1946
+ """
1947
+ url = f"{self.base_url}/accounts/projects/{project_id}/scheduled-tasks"
1948
+ params: Dict[str, str] = {
1949
+ "limit": str(limit),
1950
+ "offset": str(offset),
1951
+ }
1952
+ if room_name is not None:
1953
+ params["room_name"] = room_name
1954
+ if task_id is not None:
1955
+ params["task_id"] = task_id
1956
+ if active is not None:
1957
+ params["active"] = "true" if active else "false"
1958
+
1959
+ async with self._session.get(
1960
+ url, headers=self._get_headers(), params=params
1961
+ ) as resp:
1962
+ await self._ensure_success(resp, action="list scheduled tasks")
1963
+ data = await resp.json()
1964
+
1965
+ tasks_raw = data.get("tasks", [])
1966
+ if not isinstance(tasks_raw, list):
1967
+ raise RoomException(
1968
+ "Invalid scheduled-tasks payload: expected 'tasks' to be a list"
1969
+ )
1970
+
1971
+ try:
1972
+ return [ScheduledTask.model_validate(item) for item in tasks_raw]
1973
+ except ValidationError as exc:
1974
+ raise RoomException(f"Invalid scheduled-tasks payload: {exc}") from exc