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