avartha-python-sdk 0.0.1__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/control.py ADDED
@@ -0,0 +1,441 @@
1
+ """Customer control plane at /control/v1, authenticated with Avartha bearer keys.
2
+
3
+ Responses retain their JSON fields so additive platform changes remain visible.
4
+ Writes are never automatically retried: endpoint creation may provision hardware.
5
+ """
6
+
7
+ import asyncio
8
+ import math
9
+ import time
10
+ from collections.abc import Mapping
11
+ from types import TracebackType
12
+ from typing import Self
13
+ from urllib.parse import quote, unquote, urlsplit
14
+
15
+ import httpx
16
+ from pydantic import JsonValue
17
+
18
+ from . import _config
19
+ from .errors import EndpointFailedError, PlatformAPIError
20
+
21
+ type Object = dict[str, JsonValue]
22
+
23
+
24
+ def _object(value: JsonValue) -> Object:
25
+ if not isinstance(value, dict):
26
+ raise ValueError("Expected a JSON object from the platform.")
27
+ return value
28
+
29
+
30
+ def _objects(value: JsonValue) -> list[Object]:
31
+ if not isinstance(value, list):
32
+ raise ValueError("Expected a JSON array from the platform.")
33
+ return [_object(item) for item in value]
34
+
35
+
36
+ def _segment(value: str) -> str:
37
+ if not value or value in {".", ".."} or any(c in value for c in "/\\"):
38
+ raise ValueError("Resource identifiers must be nonempty path segments.")
39
+ return quote(value, safe="")
40
+
41
+
42
+ def _target(base_url: str, path: str) -> str:
43
+ parts = urlsplit(path)
44
+ decoded = unquote(parts.path)
45
+ if (
46
+ parts.scheme
47
+ or parts.netloc
48
+ or parts.query
49
+ or parts.fragment
50
+ or "\\" in decoded
51
+ or any(part in {".", ".."} for part in decoded.split("/"))
52
+ ):
53
+ raise ValueError("Use a relative control-plane path without query or traversal segments.")
54
+ return f"{base_url}/{path.lstrip('/')}"
55
+
56
+
57
+ def _decode(response: httpx.Response) -> JsonValue:
58
+ if not response.is_success:
59
+ raise PlatformAPIError(response)
60
+ if response.status_code == 204 or not response.content:
61
+ return None
62
+ return response.json()
63
+
64
+
65
+ def _positive(value: float, name: str) -> None:
66
+ if not math.isfinite(value) or value <= 0:
67
+ raise ValueError(f"{name} must be finite and positive.")
68
+
69
+
70
+ def _ready(endpoint: Object) -> bool:
71
+ if endpoint.get("status") in {"failed", "stopped", "stopping"}:
72
+ raise EndpointFailedError(endpoint)
73
+ replicas = endpoint.get("replicas")
74
+ accepting_ready = replicas.get("accepting_ready") if isinstance(replicas, dict) else None
75
+ return (
76
+ endpoint.get("status") == "running"
77
+ and isinstance(accepting_ready, int)
78
+ and accepting_ready > 0
79
+ )
80
+
81
+
82
+ class Control:
83
+ """Platform root configuration plus catalog, organizations and endpoints."""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ api_key: str | None = None,
89
+ base_url: str | None = None,
90
+ timeout: float = 60,
91
+ http_client: httpx.Client | None = None,
92
+ ) -> None:
93
+ _positive(timeout, "timeout")
94
+ self.base_url = _config.platform_url(base_url) + "/control/v1"
95
+ self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
96
+ self._timeout = timeout
97
+ self._owns_client = http_client is None
98
+ self._client = http_client if http_client is not None else httpx.Client()
99
+ self.catalog = Catalog(self)
100
+ self.organizations = Organizations(self)
101
+ self.endpoints = Endpoints(self)
102
+
103
+ def request(
104
+ self,
105
+ method: str,
106
+ path: str,
107
+ *,
108
+ json: Object | None = None,
109
+ params: Mapping[str, str] | None = None,
110
+ timeout: float | None = None,
111
+ ) -> JsonValue:
112
+ """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
+ )
122
+ return _decode(response)
123
+
124
+ def close(self) -> None:
125
+ if self._owns_client:
126
+ self._client.close()
127
+
128
+ def __enter__(self) -> Self:
129
+ return self
130
+
131
+ def __exit__(
132
+ self,
133
+ exc_type: type[BaseException] | None,
134
+ exc: BaseException | None,
135
+ traceback: TracebackType | None,
136
+ ) -> None:
137
+ self.close()
138
+
139
+
140
+ class Catalog:
141
+ def __init__(self, control: Control) -> None:
142
+ self._control = control
143
+
144
+ def models(self) -> list[Object]:
145
+ return _objects(self._control.request("GET", "catalog/models"))
146
+
147
+ def skus(self, *, provider: str) -> JsonValue:
148
+ return self._control.request("GET", "catalog/skus", params={"provider": provider})
149
+
150
+
151
+ class Organizations:
152
+ def __init__(self, control: Control) -> None:
153
+ self._control = control
154
+
155
+ def list(self) -> list[Object]:
156
+ return _objects(self._control.request("GET", "organizations"))
157
+
158
+ def get(self, organization_id: str) -> Object:
159
+ return _object(self._control.request("GET", f"organizations/{_segment(organization_id)}"))
160
+
161
+ def limits(self, organization_id: str) -> Object:
162
+ """Return quota policy, current usage, and any pending increase request."""
163
+ return _object(
164
+ self._control.request("GET", f"organizations/{_segment(organization_id)}/limits")
165
+ )
166
+
167
+
168
+ class Endpoints:
169
+ def __init__(self, control: Control) -> None:
170
+ self._control = control
171
+
172
+ def list(self, *, workspace_id: str) -> list[Object]:
173
+ return _objects(
174
+ self._control.request("GET", "endpoints", params={"workspace_id": workspace_id})
175
+ )
176
+
177
+ def get(self, endpoint_id: str, *, timeout: float | None = None) -> Object:
178
+ return _object(
179
+ self._control.request("GET", f"endpoints/{_segment(endpoint_id)}", timeout=timeout)
180
+ )
181
+
182
+ def create(
183
+ self,
184
+ *,
185
+ workspace_id: str,
186
+ model_slug: str,
187
+ name: str,
188
+ sku: Object,
189
+ cloud_connection_id: str | None = None,
190
+ ) -> Object:
191
+ """Persist launch intent; use wait_until_ready to observe provisioning."""
192
+ body: Object = {
193
+ "workspace_id": workspace_id,
194
+ "model_slug": model_slug,
195
+ "name": name,
196
+ "sku": sku,
197
+ }
198
+ if cloud_connection_id is not None:
199
+ body["cloud_connection_id"] = cloud_connection_id
200
+ return _object(self._control.request("POST", "endpoints", json=body))
201
+
202
+ def stop(self, endpoint_id: str) -> Object:
203
+ return _object(self._control.request("POST", f"endpoints/{_segment(endpoint_id)}/stop"))
204
+
205
+ def delete(self, endpoint_id: str) -> None:
206
+ self._control.request("DELETE", f"endpoints/{_segment(endpoint_id)}")
207
+
208
+ def management(self, endpoint_id: str) -> Object:
209
+ return _object(
210
+ self._control.request("GET", f"endpoints/{_segment(endpoint_id)}/management")
211
+ )
212
+
213
+ def update_scaling(
214
+ self, endpoint_id: str, *, expected_generation: int, policy: Object
215
+ ) -> JsonValue:
216
+ return self._control.request(
217
+ "PATCH",
218
+ f"endpoints/{_segment(endpoint_id)}/scaling",
219
+ json={"expected_generation": expected_generation, "policy": policy},
220
+ )
221
+
222
+ def update_routing(
223
+ self, endpoint_id: str, *, expected_generation: int, policy: Object
224
+ ) -> JsonValue:
225
+ return self._control.request(
226
+ "PATCH",
227
+ f"endpoints/{_segment(endpoint_id)}/routing",
228
+ json={"expected_generation": expected_generation, "policy": policy},
229
+ )
230
+
231
+ def wait_until_ready(
232
+ self,
233
+ endpoint_id: str,
234
+ *,
235
+ timeout: float = 600,
236
+ poll_interval: float = 2,
237
+ ) -> Object:
238
+ """Wait for a running endpoint with an accepting replica; fail on teardown.
239
+
240
+ timeout bounds polling and each HTTP request's timeout budget. Network
241
+ errors propagate; no hidden retry or replay of a creation request occurs.
242
+ """
243
+ _positive(timeout, "timeout")
244
+ _positive(poll_interval, "poll_interval")
245
+ deadline = time.monotonic() + timeout
246
+ while True:
247
+ remaining = deadline - time.monotonic()
248
+ if remaining <= 0:
249
+ raise TimeoutError(
250
+ f"Endpoint {endpoint_id} was not ready within {timeout} seconds."
251
+ )
252
+ endpoint = self.get(endpoint_id, timeout=min(remaining, self._control._timeout))
253
+ if _ready(endpoint):
254
+ return endpoint
255
+ remaining = deadline - time.monotonic()
256
+ if remaining > 0:
257
+ time.sleep(min(poll_interval, remaining))
258
+
259
+
260
+ class AsyncControl:
261
+ """Platform root configuration plus catalog, organizations and endpoints."""
262
+
263
+ def __init__(
264
+ self,
265
+ *,
266
+ api_key: str | None = None,
267
+ base_url: str | None = None,
268
+ timeout: float = 60,
269
+ http_client: httpx.AsyncClient | None = None,
270
+ ) -> None:
271
+ _positive(timeout, "timeout")
272
+ self.base_url = _config.platform_url(base_url) + "/control/v1"
273
+ self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
274
+ self._timeout = timeout
275
+ self._owns_client = http_client is None
276
+ self._client = http_client if http_client is not None else httpx.AsyncClient()
277
+ self.catalog = AsyncCatalog(self)
278
+ self.organizations = AsyncOrganizations(self)
279
+ self.endpoints = AsyncEndpoints(self)
280
+
281
+ async def request(
282
+ self,
283
+ method: str,
284
+ path: str,
285
+ *,
286
+ json: Object | None = None,
287
+ params: Mapping[str, str] | None = None,
288
+ timeout: float | None = None,
289
+ ) -> JsonValue:
290
+ """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
+ )
300
+ return _decode(response)
301
+
302
+ async def close(self) -> None:
303
+ if self._owns_client:
304
+ await self._client.aclose()
305
+
306
+ async def __aenter__(self) -> Self:
307
+ return self
308
+
309
+ async def __aexit__(
310
+ self,
311
+ exc_type: type[BaseException] | None,
312
+ exc: BaseException | None,
313
+ traceback: TracebackType | None,
314
+ ) -> None:
315
+ await self.close()
316
+
317
+
318
+ class AsyncCatalog:
319
+ def __init__(self, control: AsyncControl) -> None:
320
+ self._control = control
321
+
322
+ async def models(self) -> list[Object]:
323
+ return _objects(await self._control.request("GET", "catalog/models"))
324
+
325
+ async def skus(self, *, provider: str) -> JsonValue:
326
+ return await self._control.request("GET", "catalog/skus", params={"provider": provider})
327
+
328
+
329
+ class AsyncOrganizations:
330
+ def __init__(self, control: AsyncControl) -> None:
331
+ self._control = control
332
+
333
+ async def list(self) -> list[Object]:
334
+ return _objects(await self._control.request("GET", "organizations"))
335
+
336
+ async def get(self, organization_id: str) -> Object:
337
+ return _object(
338
+ await self._control.request("GET", f"organizations/{_segment(organization_id)}")
339
+ )
340
+
341
+ async def limits(self, organization_id: str) -> Object:
342
+ """Return quota policy, current usage, and any pending increase request."""
343
+ return _object(
344
+ await self._control.request("GET", f"organizations/{_segment(organization_id)}/limits")
345
+ )
346
+
347
+
348
+ class AsyncEndpoints:
349
+ def __init__(self, control: AsyncControl) -> None:
350
+ self._control = control
351
+
352
+ async def list(self, *, workspace_id: str) -> list[Object]:
353
+ return _objects(
354
+ await self._control.request("GET", "endpoints", params={"workspace_id": workspace_id})
355
+ )
356
+
357
+ async def get(self, endpoint_id: str, *, timeout: float | None = None) -> Object:
358
+ return _object(
359
+ await self._control.request(
360
+ "GET", f"endpoints/{_segment(endpoint_id)}", timeout=timeout
361
+ )
362
+ )
363
+
364
+ async def create(
365
+ self,
366
+ *,
367
+ workspace_id: str,
368
+ model_slug: str,
369
+ name: str,
370
+ sku: Object,
371
+ cloud_connection_id: str | None = None,
372
+ ) -> Object:
373
+ """Persist launch intent; use wait_until_ready to observe provisioning."""
374
+ body: Object = {
375
+ "workspace_id": workspace_id,
376
+ "model_slug": model_slug,
377
+ "name": name,
378
+ "sku": sku,
379
+ }
380
+ if cloud_connection_id is not None:
381
+ body["cloud_connection_id"] = cloud_connection_id
382
+ return _object(await self._control.request("POST", "endpoints", json=body))
383
+
384
+ async def stop(self, endpoint_id: str) -> Object:
385
+ return _object(
386
+ await self._control.request("POST", f"endpoints/{_segment(endpoint_id)}/stop")
387
+ )
388
+
389
+ async def delete(self, endpoint_id: str) -> None:
390
+ await self._control.request("DELETE", f"endpoints/{_segment(endpoint_id)}")
391
+
392
+ async def management(self, endpoint_id: str) -> Object:
393
+ return _object(
394
+ await self._control.request("GET", f"endpoints/{_segment(endpoint_id)}/management")
395
+ )
396
+
397
+ async def update_scaling(
398
+ self, endpoint_id: str, *, expected_generation: int, policy: Object
399
+ ) -> JsonValue:
400
+ return await self._control.request(
401
+ "PATCH",
402
+ f"endpoints/{_segment(endpoint_id)}/scaling",
403
+ json={"expected_generation": expected_generation, "policy": policy},
404
+ )
405
+
406
+ async def update_routing(
407
+ self, endpoint_id: str, *, expected_generation: int, policy: Object
408
+ ) -> JsonValue:
409
+ return await self._control.request(
410
+ "PATCH",
411
+ f"endpoints/{_segment(endpoint_id)}/routing",
412
+ json={"expected_generation": expected_generation, "policy": policy},
413
+ )
414
+
415
+ async def wait_until_ready(
416
+ self,
417
+ endpoint_id: str,
418
+ *,
419
+ timeout: float = 600,
420
+ poll_interval: float = 2,
421
+ ) -> Object:
422
+ """Wait for a running endpoint with an accepting replica; fail on teardown.
423
+
424
+ timeout bounds polling and each HTTP request's timeout budget. Network
425
+ errors propagate; no hidden retry or replay of a creation request occurs.
426
+ """
427
+ _positive(timeout, "timeout")
428
+ _positive(poll_interval, "poll_interval")
429
+ deadline = time.monotonic() + timeout
430
+ while True:
431
+ remaining = deadline - time.monotonic()
432
+ if remaining <= 0:
433
+ raise TimeoutError(
434
+ f"Endpoint {endpoint_id} was not ready within {timeout} seconds."
435
+ )
436
+ endpoint = await self.get(endpoint_id, timeout=min(remaining, self._control._timeout))
437
+ if _ready(endpoint):
438
+ return endpoint
439
+ remaining = deadline - time.monotonic()
440
+ if remaining > 0:
441
+ await asyncio.sleep(min(poll_interval, remaining))
@@ -0,0 +1,19 @@
1
+ """ElevenLabs-compatible conversation runtime and client tool registration."""
2
+
3
+ from .conversation import (
4
+ AsyncAudioInterface,
5
+ AsyncConversation,
6
+ AudioInterface,
7
+ ClientTools,
8
+ Conversation,
9
+ ConversationInitiationData,
10
+ )
11
+
12
+ __all__ = [
13
+ "AsyncAudioInterface",
14
+ "AsyncConversation",
15
+ "AudioInterface",
16
+ "ClientTools",
17
+ "Conversation",
18
+ "ConversationInitiationData",
19
+ ]
@@ -0,0 +1,19 @@
1
+ """Use the upstream conversation runtime without changing its protocol."""
2
+
3
+ from elevenlabs.conversational_ai.conversation import (
4
+ AsyncAudioInterface,
5
+ AsyncConversation,
6
+ AudioInterface,
7
+ ClientTools,
8
+ Conversation,
9
+ ConversationInitiationData,
10
+ )
11
+
12
+ __all__ = [
13
+ "AsyncAudioInterface",
14
+ "AsyncConversation",
15
+ "AudioInterface",
16
+ "ClientTools",
17
+ "Conversation",
18
+ "ConversationInitiationData",
19
+ ]
@@ -0,0 +1,5 @@
1
+ """Optional microphone/speaker support; install avartha-python-sdk[audio]."""
2
+
3
+ from elevenlabs.conversational_ai.default_audio_interface import DefaultAudioInterface
4
+
5
+ __all__ = ["DefaultAudioInterface"]
avartha/elevenlabs.py ADDED
@@ -0,0 +1,120 @@
1
+ """ElevenLabs clients configured for Avartha inference.
2
+
3
+ All generated resources and wire types are upstream objects. A supplied HTTP
4
+ client remains caller-owned; close() only closes a client created here.
5
+
6
+ Realtime TTS is the exception, so the sync constructor replaces `_text_to_speech`
7
+ with the corrected client from `_realtime_tts`: upstream's helper sends an
8
+ envelope the gateway refuses. That assignment is the only private-attribute
9
+ access here besides the `_ws_base_url` upstream pins to wss even for
10
+ http://localhost.
11
+ """
12
+
13
+ from types import TracebackType
14
+ from typing import Any, Self, cast
15
+
16
+ import httpx
17
+ from elevenlabs.client import AsyncElevenLabs as _AsyncElevenLabs
18
+ from elevenlabs.client import ElevenLabs as _ElevenLabs
19
+
20
+ from . import _config
21
+ from ._config import Tier
22
+ from ._realtime_tts import AvarthaRealtimeTextToSpeechClient
23
+
24
+
25
+ class ElevenLabs(_ElevenLabs):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ api_key: str | None = None,
30
+ base_url: str | None = None,
31
+ tier: Tier = "serverless",
32
+ headers: dict[str, str] | None = None,
33
+ timeout: float | None = 240,
34
+ follow_redirects: bool | None = True,
35
+ httpx_client: httpx.Client | None = None,
36
+ **kwargs: Any,
37
+ ) -> None:
38
+ key, url = _config.api_key(api_key), _config.elevenlabs_url(base_url, tier=tier)
39
+ self._owned_http_client = None
40
+ if httpx_client is None:
41
+ httpx_client = httpx.Client(timeout=timeout, follow_redirects=bool(follow_redirects))
42
+ self._owned_http_client = httpx_client
43
+ super().__init__(
44
+ api_key=key,
45
+ base_url=url,
46
+ headers=headers,
47
+ timeout=timeout,
48
+ follow_redirects=follow_redirects,
49
+ httpx_client=httpx_client,
50
+ **kwargs,
51
+ )
52
+ self._text_to_speech = AvarthaRealtimeTextToSpeechClient(
53
+ client_wrapper=self._client_wrapper
54
+ )
55
+ self._text_to_speech._ws_base_url = _config.websocket_url(url)
56
+
57
+ @property
58
+ def text_to_speech(self) -> AvarthaRealtimeTextToSpeechClient:
59
+ return cast(AvarthaRealtimeTextToSpeechClient, self._text_to_speech)
60
+
61
+ def close(self) -> None:
62
+ if self._owned_http_client is not None:
63
+ self._owned_http_client.close()
64
+
65
+ def __enter__(self) -> Self:
66
+ return self
67
+
68
+ def __exit__(
69
+ self,
70
+ exc_type: type[BaseException] | None,
71
+ exc: BaseException | None,
72
+ traceback: TracebackType | None,
73
+ ) -> None:
74
+ self.close()
75
+
76
+
77
+ class AsyncElevenLabs(_AsyncElevenLabs):
78
+ def __init__(
79
+ self,
80
+ *,
81
+ api_key: str | None = None,
82
+ base_url: str | None = None,
83
+ tier: Tier = "serverless",
84
+ headers: dict[str, str] | None = None,
85
+ timeout: float | None = 240,
86
+ follow_redirects: bool | None = True,
87
+ httpx_client: httpx.AsyncClient | None = None,
88
+ **kwargs: Any,
89
+ ) -> None:
90
+ key, url = _config.api_key(api_key), _config.elevenlabs_url(base_url, tier=tier)
91
+ self._owned_http_client = None
92
+ if httpx_client is None:
93
+ httpx_client = httpx.AsyncClient(
94
+ timeout=timeout, follow_redirects=bool(follow_redirects)
95
+ )
96
+ self._owned_http_client = httpx_client
97
+ super().__init__(
98
+ api_key=key,
99
+ base_url=url,
100
+ headers=headers,
101
+ timeout=timeout,
102
+ follow_redirects=follow_redirects,
103
+ httpx_client=httpx_client,
104
+ **kwargs,
105
+ )
106
+
107
+ async def close(self) -> None:
108
+ if self._owned_http_client is not None:
109
+ await self._owned_http_client.aclose()
110
+
111
+ async def __aenter__(self) -> Self:
112
+ return self
113
+
114
+ async def __aexit__(
115
+ self,
116
+ exc_type: type[BaseException] | None,
117
+ exc: BaseException | None,
118
+ traceback: TracebackType | None,
119
+ ) -> None:
120
+ await self.close()
avartha/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Platform errors. Inference errors keep their upstream SDK exception classes."""
2
+
3
+ import httpx
4
+ from pydantic import JsonValue
5
+
6
+ from ._config import ConfigurationError
7
+
8
+
9
+ class PlatformAPIError(Exception):
10
+ """Non-success platform response, including field-scoped validation errors."""
11
+
12
+ def __init__(self, response: httpx.Response) -> None:
13
+ self.response = response
14
+ self.status_code = response.status_code
15
+ self.request_id = response.headers.get("x-request-id")
16
+ self.retry_after = response.headers.get("retry-after")
17
+ try:
18
+ self.body: JsonValue = response.json()
19
+ except ValueError:
20
+ self.body = response.text
21
+ self.field_errors = self.body.get("errors", []) if isinstance(self.body, dict) else []
22
+ message = self.body.get("error", self.body) if isinstance(self.body, dict) else self.body
23
+ super().__init__(f"Platform API returned HTTP {self.status_code}: {message}")
24
+
25
+
26
+ class EndpointFailedError(Exception):
27
+ def __init__(self, endpoint: dict[str, JsonValue]) -> None:
28
+ self.endpoint = endpoint
29
+ super().__init__(
30
+ f"Endpoint {endpoint.get('id')} entered {endpoint.get('status')}: "
31
+ f"{endpoint.get('error')}"
32
+ )
33
+
34
+
35
+ __all__ = ["ConfigurationError", "EndpointFailedError", "PlatformAPIError"]