avartha-python-sdk 0.0.2__py3-none-any.whl → 0.0.4__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.
avartha/__init__.py CHANGED
@@ -28,7 +28,7 @@ from ._openai_http_errors import (
28
28
  )
29
29
  from ._openai_http_errors import UnprocessableEntityError as OpenAIUnprocessableEntityError
30
30
  from ._pacing import WebSocketLimits
31
- from ._realtime_asr import AudioFormat, CommitStrategy, RealtimeEvents
31
+ from ._realtime_asr import AudioFormat, CommitStrategy, RealtimeAudioOptions, RealtimeEvents
32
32
  from ._realtime_tts import DEFAULT_OUTPUT_FORMAT as DEFAULT_TTS_OUTPUT_FORMAT
33
33
  from ._realtime_tts import AsyncMultiContextSession, MultiContextSession, audio_bytes
34
34
  from ._sentinels import NOT_GIVEN, NotGiven, Omit, not_given, omit
@@ -89,6 +89,7 @@ __all__ = [
89
89
  "MultiContextSession",
90
90
  "OpenAI",
91
91
  "PlatformAPIError",
92
+ "RealtimeAudioOptions",
92
93
  "RealtimeEvents",
93
94
  "VoiceSettings",
94
95
  "WebSocketLimits",
avartha/_exceptions.py CHANGED
@@ -22,7 +22,11 @@ class WebSocketConnectionClosedError(OpenAIError):
22
22
 
23
23
 
24
24
  class ApiError(Exception):
25
- """ElevenLabs-compatible speech error, retaining the original server payload."""
25
+ """Speech API error or speech/management HTTP request failure.
26
+
27
+ Server errors retain their response payload. HTTP request failures have no
28
+ status or headers and retain the underlying exception as ``__cause__``.
29
+ """
26
30
 
27
31
  def __init__(
28
32
  self,
@@ -0,0 +1,16 @@
1
+ """Translate speech and management HTTP request failures to public API errors."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+
6
+ import httpx
7
+
8
+ from ._exceptions import ApiError
9
+
10
+
11
+ @contextmanager
12
+ def wrap_httpx_errors() -> Iterator[None]:
13
+ try:
14
+ yield
15
+ except httpx.RequestError as error:
16
+ raise ApiError(body=str(error) or type(error).__name__) from error
avartha/_realtime_tts.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """Gateway-compatible sync and async realtime TTS helpers.
2
2
 
3
3
  The pinned upstream helper omits `auto_mode` and sends generation controls the
4
- managed gateway rejects. These clients share a compatible envelope, preserve
5
- upstream chunking and errors, and use owned multi-context message types.
4
+ managed gateway rejects. These clients share a compatible envelope, send
5
+ text fragments unchanged, preserve errors, and use owned multi-context message types.
6
6
  """
7
7
 
8
8
  import asyncio
@@ -65,10 +65,6 @@ OMIT = cast(Any, ...)
65
65
  # so their native PCM rate is the only workable default.
66
66
  DEFAULT_OUTPUT_FORMAT: OutputFormat = "pcm_24000"
67
67
 
68
- # Upstream's splitter set, copied because the chunk boundaries are wire
69
- # behaviour and upstream ships no async chunker to reuse.
70
- SPLITTERS = (".", ",", "?", "!", ";", ":", "—", "-", "(", ")", "[", "]", "}", " ")
71
-
72
68
 
73
69
  def _given[T](value: T) -> T | None:
74
70
  """Upstream's OMIT sentinel means "not supplied" and must never reach the wire."""
@@ -130,7 +126,7 @@ def _close_text(iterator: Iterator[str], *, concurrent: bool = False) -> bool:
130
126
 
131
127
 
132
128
  def _until_stopped(source: Iterator[str], stopped: Event) -> Iterator[str]:
133
- """Check between raw reads, even while the chunker buffers an unfinished word."""
129
+ """Stop reading caller input once the audio stream closes."""
134
130
  while not stopped.is_set():
135
131
  text = next(source, None)
136
132
  if text is None or stopped.is_set():
@@ -171,22 +167,11 @@ async def _sync_chunks(chunks: Iterable[str]) -> AsyncGenerator[str]:
171
167
 
172
168
 
173
169
  async def _achunks(chunks: AsyncIterable[str]) -> AsyncGenerator[str]:
174
- """Apply upstream chunk boundaries and release the underlying async source."""
170
+ """Pass text through unchanged and release the underlying async source."""
175
171
  source = aiter(chunks)
176
- buffer = ""
177
172
  try:
178
173
  async for text in source:
179
- if buffer.endswith(SPLITTERS):
180
- yield buffer if buffer.endswith(" ") else buffer + " "
181
- buffer = text
182
- elif text.startswith(SPLITTERS):
183
- output = buffer + text[0]
184
- yield output if output.endswith(" ") else output + " "
185
- buffer = text[1:]
186
- else:
187
- buffer += text
188
- if buffer:
189
- yield buffer + " "
174
+ yield text
190
175
  finally:
191
176
  if close := getattr(source, "aclose", None):
192
177
  await close()
@@ -217,6 +202,10 @@ class AvarthaRealtimeTextToSpeechClient:
217
202
  ) -> Iterator[bytes]:
218
203
  """Stream `text` to `voice_id` and yield raw audio frames.
219
204
 
205
+ Nonempty text fragments are forwarded unchanged, including whitespace.
206
+ The Avartha Platform handles buffering and segmentation. Empty items are
207
+ skipped; only iterator exhaustion sends EOF.
208
+
220
209
  `output_format` defaults to `DEFAULT_OUTPUT_FORMAT`. `voice_settings` is
221
210
  forwarded when supplied, but the gateway rejects any non-null value;
222
211
  leave it unset unless the platform has gained support for it.
@@ -249,6 +238,9 @@ class AvarthaRealtimeTextToSpeechClient:
249
238
  with closing(text_chunker(_until_stopped(text, stopped))) as chunks:
250
239
  while not stopped.is_set():
251
240
  chunk = next(chunks, None)
241
+ if chunk == "":
242
+ # Empty source items are not the wire's EOF control.
243
+ continue
252
244
  if stopped.wait(pacer.delay()):
253
245
  break
254
246
  socket.send(json.dumps({"text": chunk if chunk is not None else ""}))
@@ -365,6 +357,8 @@ class AvarthaAsyncRealtimeTextToSpeechClient:
365
357
  chunks = _achunks(text) if isinstance(text, AsyncIterable) else _sync_chunks(text)
366
358
  async with aclosing(chunks):
367
359
  async for chunk in chunks:
360
+ if chunk == "":
361
+ continue
368
362
  await pacer.pace_async()
369
363
  await socket.send(json.dumps({"text": chunk}))
370
364
  await pacer.pace_async()
@@ -510,10 +504,7 @@ class _MultiContextProtocol:
510
504
  if text == "":
511
505
  return _frame(SendTextMulti(text="", context_id=context_id))
512
506
  context.pending = True
513
- # Upstream's chunker guarantees a trailing space and the protocol wants
514
- # one, so supply it when the caller's chunk lacks it.
515
- spaced = text if text.endswith(" ") else text + " "
516
- return _frame(SendTextMulti(text=spaced, context_id=context_id))
507
+ return _frame(SendTextMulti(text=text, context_id=context_id))
517
508
 
518
509
  def flush(self, context_id: str) -> dict[str, Any]:
519
510
  context = self._open(context_id)
@@ -596,7 +587,7 @@ class MultiContextSession:
596
587
  self.send(context_id, text)
597
588
 
598
589
  def send(self, context_id: str, text: str) -> None:
599
- """Append text, or send an empty-text keepalive, to an open context.
590
+ """Append text unchanged, or send an empty-text keepalive, to an open context.
600
591
 
601
592
  A keepalive preserves pending speech without generating or flushing it.
602
593
  """
@@ -764,7 +755,7 @@ class AsyncMultiContextSession:
764
755
  await self._send(context_id, lambda protocol: protocol.send(context_id, text))
765
756
 
766
757
  async def send(self, context_id: str, text: str) -> None:
767
- """Append text, or send an empty-text keepalive, to an open context.
758
+ """Append text unchanged, or send an empty-text keepalive, to an open context.
768
759
 
769
760
  A keepalive preserves pending speech without generating or flushing it.
770
761
  """
avartha/_speech_config.py CHANGED
@@ -54,21 +54,6 @@ def remove_none_from_dict(value: dict[str, Any]) -> dict[str, Any]:
54
54
  return {key: item for key, item in value.items() if item is not None}
55
55
 
56
56
 
57
- SPLITTERS = (".", ",", "?", "!", ";", ":", "—", "-", "(", ")", "[", "]", "}", " ")
58
-
59
-
60
57
  def text_chunker(chunks: Iterable[str]) -> Generator[str]:
61
- """Preserve the frozen helper's punctuation and trailing-space boundaries."""
62
- pending = ""
63
- for chunk in chunks:
64
- if pending.endswith(SPLITTERS):
65
- yield pending if pending.endswith(" ") else pending + " "
66
- pending = chunk
67
- elif chunk.startswith(SPLITTERS):
68
- part = pending + chunk[0]
69
- yield part if part.endswith(" ") else part + " "
70
- pending = chunk[1:]
71
- else:
72
- pending += chunk
73
- if pending:
74
- yield pending + " "
58
+ """Pass text through unchanged; the Avartha Platform handles buffering and segmentation."""
59
+ yield from chunks
avartha/_speech_http.py CHANGED
@@ -14,6 +14,7 @@ import httpx
14
14
 
15
15
  from ._config import not_vendor_host
16
16
  from ._exceptions import ApiError, UnprocessableEntityError
17
+ from ._http_errors import wrap_httpx_errors
17
18
  from ._models import construct
18
19
  from ._speech_config import RequestOptions, jsonable_encoder
19
20
  from .errors import ConfigurationError
@@ -193,19 +194,20 @@ class SpeechHTTPClient:
193
194
  options = request_options or {}
194
195
  for attempt in range(max(0, options.get("max_retries", 2)) + 1):
195
196
  request = self._settings.request(self._client, path, params, options)
196
- for redirects in range(self._client.max_redirects + 1):
197
- response = self._client.send(request, follow_redirects=False)
198
- if not self._follow_redirects or not response.has_redirect_location:
199
- break
200
- try:
201
- target = _redirect(request, response)
202
- if redirects == self._client.max_redirects:
203
- raise httpx.TooManyRedirects(
204
- "Exceeded maximum allowed redirects.", request=request
205
- )
206
- request.url = target
207
- finally:
208
- response.close()
197
+ with wrap_httpx_errors():
198
+ for redirects in range(self._client.max_redirects + 1):
199
+ response = self._client.send(request, follow_redirects=False)
200
+ if not self._follow_redirects or not response.has_redirect_location:
201
+ break
202
+ try:
203
+ target = _redirect(request, response)
204
+ if redirects == self._client.max_redirects:
205
+ raise httpx.TooManyRedirects(
206
+ "Exceeded maximum allowed redirects.", request=request
207
+ )
208
+ request.url = target
209
+ finally:
210
+ response.close()
209
211
  if (
210
212
  response.status_code >= 500 or response.status_code in (408, 409, 429)
211
213
  ) and attempt < options.get("max_retries", 2):
@@ -258,19 +260,20 @@ class AsyncSpeechHTTPClient:
258
260
  options = request_options or {}
259
261
  for attempt in range(max(0, options.get("max_retries", 2)) + 1):
260
262
  request = self._settings.request(self._client, path, params, options)
261
- for redirects in range(self._client.max_redirects + 1):
262
- response = await self._client.send(request, follow_redirects=False)
263
- if not self._follow_redirects or not response.has_redirect_location:
264
- break
265
- try:
266
- target = _redirect(request, response)
267
- if redirects == self._client.max_redirects:
268
- raise httpx.TooManyRedirects(
269
- "Exceeded maximum allowed redirects.", request=request
270
- )
271
- request.url = target
272
- finally:
273
- await response.aclose()
263
+ with wrap_httpx_errors():
264
+ for redirects in range(self._client.max_redirects + 1):
265
+ response = await self._client.send(request, follow_redirects=False)
266
+ if not self._follow_redirects or not response.has_redirect_location:
267
+ break
268
+ try:
269
+ target = _redirect(request, response)
270
+ if redirects == self._client.max_redirects:
271
+ raise httpx.TooManyRedirects(
272
+ "Exceeded maximum allowed redirects.", request=request
273
+ )
274
+ request.url = target
275
+ finally:
276
+ await response.aclose()
274
277
  if (
275
278
  response.status_code >= 500 or response.status_code in (408, 409, 429)
276
279
  ) and attempt < options.get("max_retries", 2):
avartha/client.py CHANGED
@@ -43,6 +43,7 @@ class Avartha:
43
43
  **(openai_options or {}),
44
44
  )
45
45
  self.realtime = self.openai.realtime
46
+ self.models = self.openai.models
46
47
 
47
48
  @cached_property
48
49
  def elevenlabs(self) -> ElevenLabs:
@@ -111,6 +112,7 @@ class AsyncAvartha:
111
112
  **(openai_options or {}),
112
113
  )
113
114
  self.realtime = self.openai.realtime
115
+ self.models = self.openai.models
114
116
 
115
117
  @cached_property
116
118
  def elevenlabs(self) -> AsyncElevenLabs:
avartha/control.py CHANGED
@@ -9,16 +9,18 @@ import math
9
9
  import time
10
10
  from collections.abc import Mapping
11
11
  from types import TracebackType
12
- from typing import Self
12
+ from typing import Literal, Self
13
13
  from urllib.parse import quote, unquote, urlsplit
14
14
 
15
15
  import httpx
16
16
  from pydantic import JsonValue
17
17
 
18
18
  from . import _config
19
+ from ._http_errors import wrap_httpx_errors
19
20
  from .errors import EndpointFailedError, PlatformAPIError
20
21
 
21
22
  type Object = dict[str, JsonValue]
23
+ type OrganizationRole = Literal["owner", "admin", "member"]
22
24
 
23
25
 
24
26
  def _object(value: JsonValue) -> Object:
@@ -80,7 +82,7 @@ def _ready(endpoint: Object) -> bool:
80
82
 
81
83
 
82
84
  class Control:
83
- """Platform root configuration plus catalog, organizations and endpoints."""
85
+ """Customer catalog, workspaces, invitations, and dedicated endpoints."""
84
86
 
85
87
  def __init__(
86
88
  self,
@@ -91,13 +93,17 @@ class Control:
91
93
  http_client: httpx.Client | None = None,
92
94
  ) -> None:
93
95
  _positive(timeout, "timeout")
94
- self.base_url = _config.platform_url(base_url) + "/control/v1"
96
+ self.base_url = (
97
+ _config.not_vendor_host(_config.platform_url(base_url), field="base_url")
98
+ + "/control/v1"
99
+ )
95
100
  self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
96
101
  self._timeout = timeout
97
102
  self._owns_client = http_client is None
98
103
  self._client = http_client if http_client is not None else httpx.Client()
99
104
  self.catalog = Catalog(self)
100
105
  self.organizations = Organizations(self)
106
+ self.invitations = Invitations(self)
101
107
  self.endpoints = Endpoints(self)
102
108
 
103
109
  def request(
@@ -110,15 +116,16 @@ class Control:
110
116
  timeout: float | None = None,
111
117
  ) -> JsonValue:
112
118
  """Call another verified customer route relative to /control/v1."""
113
- response = self._client.request(
114
- method,
115
- _target(self.base_url, path),
116
- headers=self._headers,
117
- json=json,
118
- params=params,
119
- timeout=self._timeout if timeout is None else timeout,
120
- follow_redirects=False,
121
- )
119
+ with wrap_httpx_errors():
120
+ response = self._client.request(
121
+ method,
122
+ _target(self.base_url, path),
123
+ headers=self._headers,
124
+ json=json,
125
+ params=params,
126
+ timeout=self._timeout if timeout is None else timeout,
127
+ follow_redirects=False,
128
+ )
122
129
  return _decode(response)
123
130
 
124
131
  def close(self) -> None:
@@ -149,8 +156,12 @@ class Catalog:
149
156
 
150
157
 
151
158
  class Organizations:
159
+ """Workspaces visible to the authenticated customer; permissions are server-enforced."""
160
+
152
161
  def __init__(self, control: Control) -> None:
153
162
  self._control = control
163
+ self.members = OrganizationMembers(control)
164
+ self.invitations = OrganizationInvitations(control)
154
165
 
155
166
  def list(self) -> list[Object]:
156
167
  return _objects(self._control.request("GET", "organizations"))
@@ -158,6 +169,26 @@ class Organizations:
158
169
  def get(self, organization_id: str) -> Object:
159
170
  return _object(self._control.request("GET", f"organizations/{_segment(organization_id)}"))
160
171
 
172
+ def create(self, *, name: str) -> Object:
173
+ """Create a workspace with the platform's default allocation."""
174
+ return _object(self._control.request("POST", "organizations", json={"name": name}))
175
+
176
+ def update(self, organization_id: str, *, name: str) -> Object:
177
+ """Rename a workspace as an owner or admin."""
178
+ return _object(
179
+ self._control.request(
180
+ "PATCH", f"organizations/{_segment(organization_id)}", json={"name": name}
181
+ )
182
+ )
183
+
184
+ def delete(self, organization_id: str) -> None:
185
+ """Delete a workspace as an owner."""
186
+ self._control.request("DELETE", f"organizations/{_segment(organization_id)}")
187
+
188
+ def leave(self, organization_id: str) -> None:
189
+ """Leave a workspace; the server refuses removal of its last owner."""
190
+ self._control.request("POST", f"organizations/{_segment(organization_id)}/leave")
191
+
161
192
  def limits(self, organization_id: str) -> Object:
162
193
  """Return quota policy, current usage, and any pending increase request."""
163
194
  return _object(
@@ -165,6 +196,88 @@ class Organizations:
165
196
  )
166
197
 
167
198
 
199
+ class OrganizationMembers:
200
+ def __init__(self, control: Control) -> None:
201
+ self._control = control
202
+
203
+ def list(self, organization_id: str) -> list[Object]:
204
+ return _objects(
205
+ self._control.request("GET", f"organizations/{_segment(organization_id)}/members")
206
+ )
207
+
208
+ def update(self, organization_id: str, user_id: str, *, role: OrganizationRole) -> Object:
209
+ """Change a member's role, subject to owner/admin and last-owner rules."""
210
+ return _object(
211
+ self._control.request(
212
+ "PATCH",
213
+ f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}",
214
+ json={"role": role},
215
+ )
216
+ )
217
+
218
+ def remove(self, organization_id: str, user_id: str) -> None:
219
+ self._control.request(
220
+ "DELETE", f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}"
221
+ )
222
+
223
+
224
+ class OrganizationInvitations:
225
+ def __init__(self, control: Control) -> None:
226
+ self._control = control
227
+
228
+ def list(self, organization_id: str) -> list[Object]:
229
+ return _objects(
230
+ self._control.request("GET", f"organizations/{_segment(organization_id)}/invitations")
231
+ )
232
+
233
+ def create(
234
+ self,
235
+ organization_id: str,
236
+ *,
237
+ role: OrganizationRole,
238
+ email: str | None = None,
239
+ entitlement_policy: Object | None = None,
240
+ ) -> Object:
241
+ """Create an invite; supplying email also asks the server to send it.
242
+
243
+ Targeted customer invites require a member entitlement policy. Inspect
244
+ delivery_warning in the response even when creation succeeds.
245
+ """
246
+ body: Object = {"role": role}
247
+ if email is not None:
248
+ body["email"] = email
249
+ if entitlement_policy is not None:
250
+ body["entitlement_policy"] = entitlement_policy
251
+ return _object(
252
+ self._control.request(
253
+ "POST", f"organizations/{_segment(organization_id)}/invitations", json=body
254
+ )
255
+ )
256
+
257
+ def revoke(self, organization_id: str, invitation_id: str) -> Object:
258
+ return _object(
259
+ self._control.request(
260
+ "DELETE",
261
+ f"organizations/{_segment(organization_id)}/invitations/{_segment(invitation_id)}",
262
+ )
263
+ )
264
+
265
+
266
+ class Invitations:
267
+ """Preview and accept an invitation as the authenticated recipient."""
268
+
269
+ def __init__(self, control: Control) -> None:
270
+ self._control = control
271
+
272
+ def preview(self, token: str) -> Object:
273
+ """Read the invitation; the platform's preview route carries its token in the URL."""
274
+ return _object(self._control.request("GET", f"invitations/{_segment(token)}"))
275
+
276
+ def accept(self, *, token: str) -> Object:
277
+ """Join a workspace, sending the invitation token in the request body."""
278
+ return _object(self._control.request("POST", "invitations/accept", json={"token": token}))
279
+
280
+
168
281
  class Endpoints:
169
282
  def __init__(self, control: Control) -> None:
170
283
  self._control = control
@@ -258,7 +371,7 @@ class Endpoints:
258
371
 
259
372
 
260
373
  class AsyncControl:
261
- """Platform root configuration plus catalog, organizations and endpoints."""
374
+ """Async customer catalog, workspaces, invitations, and dedicated endpoints."""
262
375
 
263
376
  def __init__(
264
377
  self,
@@ -269,13 +382,17 @@ class AsyncControl:
269
382
  http_client: httpx.AsyncClient | None = None,
270
383
  ) -> None:
271
384
  _positive(timeout, "timeout")
272
- self.base_url = _config.platform_url(base_url) + "/control/v1"
385
+ self.base_url = (
386
+ _config.not_vendor_host(_config.platform_url(base_url), field="base_url")
387
+ + "/control/v1"
388
+ )
273
389
  self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
274
390
  self._timeout = timeout
275
391
  self._owns_client = http_client is None
276
392
  self._client = http_client if http_client is not None else httpx.AsyncClient()
277
393
  self.catalog = AsyncCatalog(self)
278
394
  self.organizations = AsyncOrganizations(self)
395
+ self.invitations = AsyncInvitations(self)
279
396
  self.endpoints = AsyncEndpoints(self)
280
397
 
281
398
  async def request(
@@ -288,15 +405,16 @@ class AsyncControl:
288
405
  timeout: float | None = None,
289
406
  ) -> JsonValue:
290
407
  """Call another verified customer route relative to /control/v1."""
291
- response = await self._client.request(
292
- method,
293
- _target(self.base_url, path),
294
- headers=self._headers,
295
- json=json,
296
- params=params,
297
- timeout=self._timeout if timeout is None else timeout,
298
- follow_redirects=False,
299
- )
408
+ with wrap_httpx_errors():
409
+ response = await self._client.request(
410
+ method,
411
+ _target(self.base_url, path),
412
+ headers=self._headers,
413
+ json=json,
414
+ params=params,
415
+ timeout=self._timeout if timeout is None else timeout,
416
+ follow_redirects=False,
417
+ )
300
418
  return _decode(response)
301
419
 
302
420
  async def close(self) -> None:
@@ -327,8 +445,12 @@ class AsyncCatalog:
327
445
 
328
446
 
329
447
  class AsyncOrganizations:
448
+ """Workspaces visible to the authenticated customer; permissions are server-enforced."""
449
+
330
450
  def __init__(self, control: AsyncControl) -> None:
331
451
  self._control = control
452
+ self.members = AsyncOrganizationMembers(control)
453
+ self.invitations = AsyncOrganizationInvitations(control)
332
454
 
333
455
  async def list(self) -> list[Object]:
334
456
  return _objects(await self._control.request("GET", "organizations"))
@@ -338,6 +460,26 @@ class AsyncOrganizations:
338
460
  await self._control.request("GET", f"organizations/{_segment(organization_id)}")
339
461
  )
340
462
 
463
+ async def create(self, *, name: str) -> Object:
464
+ """Create a workspace with the platform's default allocation."""
465
+ return _object(await self._control.request("POST", "organizations", json={"name": name}))
466
+
467
+ async def update(self, organization_id: str, *, name: str) -> Object:
468
+ """Rename a workspace as an owner or admin."""
469
+ return _object(
470
+ await self._control.request(
471
+ "PATCH", f"organizations/{_segment(organization_id)}", json={"name": name}
472
+ )
473
+ )
474
+
475
+ async def delete(self, organization_id: str) -> None:
476
+ """Delete a workspace as an owner."""
477
+ await self._control.request("DELETE", f"organizations/{_segment(organization_id)}")
478
+
479
+ async def leave(self, organization_id: str) -> None:
480
+ """Leave a workspace; the server refuses removal of its last owner."""
481
+ await self._control.request("POST", f"organizations/{_segment(organization_id)}/leave")
482
+
341
483
  async def limits(self, organization_id: str) -> Object:
342
484
  """Return quota policy, current usage, and any pending increase request."""
343
485
  return _object(
@@ -345,6 +487,92 @@ class AsyncOrganizations:
345
487
  )
346
488
 
347
489
 
490
+ class AsyncOrganizationMembers:
491
+ def __init__(self, control: AsyncControl) -> None:
492
+ self._control = control
493
+
494
+ async def list(self, organization_id: str) -> list[Object]:
495
+ return _objects(
496
+ await self._control.request("GET", f"organizations/{_segment(organization_id)}/members")
497
+ )
498
+
499
+ async def update(self, organization_id: str, user_id: str, *, role: OrganizationRole) -> Object:
500
+ """Change a member's role, subject to owner/admin and last-owner rules."""
501
+ return _object(
502
+ await self._control.request(
503
+ "PATCH",
504
+ f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}",
505
+ json={"role": role},
506
+ )
507
+ )
508
+
509
+ async def remove(self, organization_id: str, user_id: str) -> None:
510
+ await self._control.request(
511
+ "DELETE", f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}"
512
+ )
513
+
514
+
515
+ class AsyncOrganizationInvitations:
516
+ def __init__(self, control: AsyncControl) -> None:
517
+ self._control = control
518
+
519
+ async def list(self, organization_id: str) -> list[Object]:
520
+ return _objects(
521
+ await self._control.request(
522
+ "GET", f"organizations/{_segment(organization_id)}/invitations"
523
+ )
524
+ )
525
+
526
+ async def create(
527
+ self,
528
+ organization_id: str,
529
+ *,
530
+ role: OrganizationRole,
531
+ email: str | None = None,
532
+ entitlement_policy: Object | None = None,
533
+ ) -> Object:
534
+ """Create an invite; supplying email also asks the server to send it.
535
+
536
+ Targeted customer invites require a member entitlement policy. Inspect
537
+ delivery_warning in the response even when creation succeeds.
538
+ """
539
+ body: Object = {"role": role}
540
+ if email is not None:
541
+ body["email"] = email
542
+ if entitlement_policy is not None:
543
+ body["entitlement_policy"] = entitlement_policy
544
+ return _object(
545
+ await self._control.request(
546
+ "POST", f"organizations/{_segment(organization_id)}/invitations", json=body
547
+ )
548
+ )
549
+
550
+ async def revoke(self, organization_id: str, invitation_id: str) -> Object:
551
+ return _object(
552
+ await self._control.request(
553
+ "DELETE",
554
+ f"organizations/{_segment(organization_id)}/invitations/{_segment(invitation_id)}",
555
+ )
556
+ )
557
+
558
+
559
+ class AsyncInvitations:
560
+ """Preview and accept an invitation as the authenticated recipient."""
561
+
562
+ def __init__(self, control: AsyncControl) -> None:
563
+ self._control = control
564
+
565
+ async def preview(self, token: str) -> Object:
566
+ """Read the invitation; the platform's preview route carries its token in the URL."""
567
+ return _object(await self._control.request("GET", f"invitations/{_segment(token)}"))
568
+
569
+ async def accept(self, *, token: str) -> Object:
570
+ """Join a workspace, sending the invitation token in the request body."""
571
+ return _object(
572
+ await self._control.request("POST", "invitations/accept", json={"token": token})
573
+ )
574
+
575
+
348
576
  class AsyncEndpoints:
349
577
  def __init__(self, control: AsyncControl) -> None:
350
578
  self._control = control