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/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """Avartha Python SDK: upstream protocol compatibility and platform management."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _dist_version
5
+
6
+ from elevenlabs import AudioFormat, CommitStrategy, RealtimeEvents, VoiceSettings
7
+ from elevenlabs.play import play, save, stream
8
+
9
+ from ._realtime_tts import DEFAULT_OUTPUT_FORMAT as DEFAULT_TTS_OUTPUT_FORMAT
10
+ from .client import AsyncAvartha, Avartha
11
+ from .control import AsyncControl, Control
12
+ from .elevenlabs import AsyncElevenLabs, ElevenLabs
13
+ from .errors import ConfigurationError, EndpointFailedError, PlatformAPIError
14
+ from .openai import AsyncOpenAI, OpenAI
15
+
16
+ try:
17
+ # Populated from the git tag at build time by setuptools_scm.
18
+ __version__ = _dist_version("avartha-python-sdk")
19
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
20
+ __version__ = "0.0.0.dev0"
21
+
22
+ __all__ = [
23
+ "AsyncAvartha",
24
+ "AsyncControl",
25
+ "AsyncElevenLabs",
26
+ "AsyncOpenAI",
27
+ "AudioFormat",
28
+ "Avartha",
29
+ "CommitStrategy",
30
+ "ConfigurationError",
31
+ "DEFAULT_TTS_OUTPUT_FORMAT",
32
+ "Control",
33
+ "ElevenLabs",
34
+ "EndpointFailedError",
35
+ "OpenAI",
36
+ "PlatformAPIError",
37
+ "RealtimeEvents",
38
+ "VoiceSettings",
39
+ "play",
40
+ "save",
41
+ "stream",
42
+ ]
avartha/_config.py ADDED
@@ -0,0 +1,140 @@
1
+ """Avartha configuration; vendor environment variables are never credentials."""
2
+
3
+ import os
4
+ import re
5
+ from ipaddress import IPv6Address
6
+ from typing import Literal
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ import httpx
10
+
11
+ Tier = Literal["serverless", "dedicated"]
12
+ DEFAULT_BASE_URL = "https://platform.avartha.ai"
13
+
14
+
15
+ class ConfigurationError(ValueError):
16
+ """Missing credentials or an invalid service URL."""
17
+
18
+
19
+ def api_key(value: str | None) -> str:
20
+ key = value if value is not None else os.environ.get("AVARTHA_API_KEY")
21
+ if not key or not key.strip():
22
+ raise ConfigurationError("Set api_key or AVARTHA_API_KEY to an Avartha API key.")
23
+ return key
24
+
25
+
26
+ def http_url(value: str) -> str:
27
+ parts = urlsplit(value)
28
+ if (
29
+ parts.scheme not in {"http", "https"}
30
+ or not parts.hostname
31
+ or parts.username is not None
32
+ or parts.password is not None
33
+ or parts.query
34
+ or parts.fragment
35
+ ):
36
+ raise ConfigurationError(
37
+ "Use an absolute http(s) base URL without credentials, query, or fragment."
38
+ )
39
+ return value.rstrip("/")
40
+
41
+
42
+ def platform_url(value: str | None = None) -> str:
43
+ url = http_url(value if value is not None else os.getenv("AVARTHA_BASE_URL", DEFAULT_BASE_URL))
44
+ path = urlsplit(url).path
45
+ if "/inference/" in path or path.endswith("/control/v1"):
46
+ raise ConfigurationError(
47
+ "base_url must be the platform root, before /inference or /control."
48
+ )
49
+ return url
50
+
51
+
52
+ def openai_url(root: str | None = None, tier: Tier = "serverless") -> str:
53
+ if tier not in {"serverless", "dedicated"}:
54
+ raise ConfigurationError("tier must be 'serverless' or 'dedicated'.")
55
+ return f"{platform_url(root)}/inference/{tier}/openai/v1"
56
+
57
+
58
+ def elevenlabs_url(
59
+ value: str | None = None, *, root: str | None = None, tier: Tier = "serverless"
60
+ ) -> str:
61
+ override = value if value is not None else os.getenv("AVARTHA_ELEVENLABS_BASE_URL")
62
+ if override is not None:
63
+ return http_url(override)
64
+ if tier not in {"serverless", "dedicated"}:
65
+ raise ConfigurationError("tier must be 'serverless' or 'dedicated'.")
66
+ return f"{platform_url(root)}/inference/{tier}/elevenlabs"
67
+
68
+
69
+ def websocket_url(value: str) -> str:
70
+ parts = urlsplit(value)
71
+ return urlunsplit(parts._replace(scheme={"https": "wss", "http": "ws"}[parts.scheme]))
72
+
73
+
74
+ VENDOR_HOSTS = ("openai.com", "openai.azure.com")
75
+
76
+
77
+ def not_vendor_host(value: str, *, field: str) -> str:
78
+ """Refuse a URL that would send an Avartha credential to a vendor endpoint."""
79
+ host = (urlsplit(value).hostname or "").lower().rstrip(".")
80
+ if any(host == vendor or host.endswith(f".{vendor}") for vendor in VENDOR_HOSTS):
81
+ raise ConfigurationError(
82
+ f"{field} resolves to the vendor host {host!r}. An Avartha API key must never "
83
+ "be sent to a vendor endpoint; use the official vendor package for that."
84
+ )
85
+ return value
86
+
87
+
88
+ def websocket_override(value: str) -> str:
89
+ """Return a canonical WebSocket service base suitable for upstream joining.
90
+
91
+ OpenAI appends `/realtime` to this URL's raw path. Query strings would be
92
+ corrupted by that join, so request query parameters belong in connect().
93
+ Validate before parsing as urllib silently removes some whitespace/controls.
94
+ """
95
+ message = (
96
+ "websocket_base_url must be an absolute wss:// service URL with a "
97
+ "valid hostname and port (1-65535), without credentials, whitespace, "
98
+ "backslashes, query, or fragment."
99
+ )
100
+ try:
101
+ if (
102
+ not value
103
+ or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value)
104
+ or any(char in value for char in "\\?#")
105
+ or re.search(r"%(?![0-9a-fA-F]{2})", value)
106
+ ):
107
+ raise ValueError("Invalid URL characters")
108
+ parts = urlsplit(value)
109
+ if (
110
+ parts.scheme != "wss"
111
+ or not parts.hostname
112
+ or parts.username is not None
113
+ or parts.password is not None
114
+ or parts.netloc.endswith(":")
115
+ or (parts.port is not None and not 1 <= parts.port <= 65535)
116
+ ):
117
+ raise ValueError("Invalid URL authority")
118
+ # Use the declared HTTPX dependency for IDNA, IPv6, and path escaping.
119
+ url = httpx.URL(urlunsplit(parts))
120
+ host = url.raw_host.decode("ascii")
121
+ if ":" in host:
122
+ url = url.copy_with(host=str(IPv6Address(host)))
123
+ else:
124
+ hostname = host.removesuffix(".")
125
+ if len(hostname) > 253 or any(
126
+ re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is None
127
+ for label in hostname.split(".")
128
+ ):
129
+ raise ValueError("Invalid hostname")
130
+ except (ValueError, httpx.InvalidURL):
131
+ # Do not expose userinfo or credential-like query values in diagnostics.
132
+ raise ConfigurationError(message) from None
133
+
134
+ normalized = str(url).rstrip("/")
135
+ if url.path.rstrip("/").endswith("/realtime"):
136
+ raise ConfigurationError(
137
+ "websocket_base_url must be the service base before /realtime; "
138
+ "the SDK appends /realtime when connecting."
139
+ )
140
+ return not_vendor_host(normalized, field="websocket_base_url")
@@ -0,0 +1,138 @@
1
+ """Realtime text-to-speech for the Avartha gateway.
2
+
3
+ The official `convert_realtime` hard-codes three values the gateway refuses and
4
+ `request_options` cannot reach: it omits `auto_mode`, sends
5
+ `try_trigger_generation: true`, and sends a populated `generation_config`. Its
6
+ `mp3_44100_128` default format is refused too, and every rejection closes the
7
+ socket 1011, so upstream's helper fails on every Avartha TTS model. Only the
8
+ request envelope is corrected here; chunking, the message sequence, the yielded
9
+ audio and the raised `ApiError` stay upstream's.
10
+ """
11
+
12
+ import base64
13
+ import json
14
+ from collections.abc import Iterator, Mapping
15
+ from typing import Any, cast
16
+
17
+ import websockets
18
+ from elevenlabs.core.api_error import ApiError
19
+ from elevenlabs.core.jsonable_encoder import jsonable_encoder
20
+ from elevenlabs.core.remove_none_from_dict import remove_none_from_dict
21
+ from elevenlabs.core.request_options import RequestOptions
22
+ from elevenlabs.realtime_tts import RealtimeTextToSpeechClient, text_chunker
23
+ from elevenlabs.types import OutputFormat
24
+ from elevenlabs.types.voice_settings import VoiceSettings
25
+ from elevenlabs.url_utils import build_ws_url
26
+ from websockets.sync.client import connect
27
+
28
+ OMIT = cast(Any, ...)
29
+
30
+ # Avartha publishes no sample rate and the TTS models refuse every other format,
31
+ # so their native PCM rate is the only workable default.
32
+ DEFAULT_OUTPUT_FORMAT: OutputFormat = "pcm_24000"
33
+
34
+ # How long a send loop looks for audio before pushing the next chunk.
35
+ DRAIN_TIMEOUT = 1e-2
36
+
37
+
38
+ def _given[T](value: T) -> T | None:
39
+ """Upstream's OMIT sentinel means "not supplied" and must never reach the wire."""
40
+ return None if value is OMIT else value
41
+
42
+
43
+ def _path(voice_id: str, endpoint: str) -> list[str]:
44
+ return ["v1", "text-to-speech", voice_id, endpoint]
45
+
46
+
47
+ def _query(model_id: str | None, output_format: OutputFormat | None) -> dict[str, Any]:
48
+ return remove_none_from_dict(
49
+ {
50
+ "model_id": model_id,
51
+ "output_format": output_format or DEFAULT_OUTPUT_FORMAT,
52
+ "auto_mode": "true",
53
+ }
54
+ )
55
+
56
+
57
+ def _headers(base: Mapping[str, str], request_options: RequestOptions | None) -> dict[str, str]:
58
+ extra = request_options.get("additional_headers", {}) if request_options else {}
59
+ return cast(dict[str, str], jsonable_encoder(remove_none_from_dict({**base, **extra})))
60
+
61
+
62
+ def _opening(voice_settings: VoiceSettings | None) -> str:
63
+ """The single-space message the gateway requires before any text."""
64
+ frame: dict[str, Any] = {"text": " "}
65
+ if voice_settings is not None:
66
+ frame["voice_settings"] = voice_settings.dict()
67
+ return json.dumps(frame)
68
+
69
+
70
+ def _audio(payload: Any) -> bytes | None:
71
+ encoded = payload.get("audio") if isinstance(payload, Mapping) else None
72
+ return base64.b64decode(encoded) if encoded else None
73
+
74
+
75
+ def _failure(received: Any, closed: websockets.exceptions.ConnectionClosed) -> ApiError | None:
76
+ """The error a closed socket deserves, or None once the stream finished cleanly.
77
+
78
+ Upstream inspects the last payload *sent* here, so the gateway's own
79
+ diagnosis is thrown away; report what actually arrived instead.
80
+ """
81
+ if isinstance(received, Mapping) and "message" in received:
82
+ return ApiError(body=dict(received), status_code=closed.code)
83
+ if closed.code != 1000:
84
+ return ApiError(body=closed.reason, status_code=closed.code)
85
+ return None
86
+
87
+
88
+ class AvarthaRealtimeTextToSpeechClient(RealtimeTextToSpeechClient):
89
+ """Upstream realtime TTS with the gateway-compatible request envelope."""
90
+
91
+ def convert_realtime(
92
+ self,
93
+ voice_id: str,
94
+ *,
95
+ text: Iterator[str],
96
+ model_id: str | None = OMIT,
97
+ output_format: OutputFormat | None = None,
98
+ voice_settings: VoiceSettings | None = OMIT,
99
+ request_options: RequestOptions | None = None,
100
+ ) -> Iterator[bytes]:
101
+ """Stream `text` to `voice_id` and yield raw audio frames.
102
+
103
+ `output_format` defaults to `DEFAULT_OUTPUT_FORMAT`. `voice_settings` is
104
+ forwarded when supplied, but the gateway rejects any non-null value;
105
+ leave it unset unless the platform has gained support for it.
106
+ """
107
+ with connect(
108
+ build_ws_url(
109
+ self._ws_base_url,
110
+ _path(voice_id, "stream-input"),
111
+ _query(_given(model_id), output_format),
112
+ ),
113
+ additional_headers=_headers(self._client_wrapper.get_headers(), request_options),
114
+ ) as socket:
115
+ received: Any = None
116
+ try:
117
+ socket.send(_opening(_given(voice_settings)))
118
+ except websockets.exceptions.ConnectionClosedError as closed:
119
+ raise ApiError(body=closed.reason, status_code=closed.code) from closed
120
+
121
+ try:
122
+ for chunk in text_chunker(text):
123
+ socket.send(json.dumps({"text": chunk}))
124
+ try:
125
+ received = json.loads(socket.recv(DRAIN_TIMEOUT))
126
+ except TimeoutError:
127
+ continue
128
+ if audio := _audio(received):
129
+ yield audio
130
+
131
+ socket.send(json.dumps({"text": ""}))
132
+ while True:
133
+ received = json.loads(socket.recv())
134
+ if audio := _audio(received):
135
+ yield audio
136
+ except websockets.exceptions.ConnectionClosed as closed:
137
+ if failure := _failure(received, closed):
138
+ raise failure from closed
avartha/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
avartha/client.py ADDED
@@ -0,0 +1,159 @@
1
+ """Convenience clients combining protocol clients with Avartha management."""
2
+
3
+ from functools import cached_property
4
+ from types import TracebackType
5
+ from typing import Any, Self
6
+
7
+ from . import _config
8
+ from ._config import Tier
9
+ from .control import AsyncControl, Control
10
+ from .elevenlabs import AsyncElevenLabs, ElevenLabs
11
+ from .openai import AsyncOpenAI, OpenAI
12
+
13
+
14
+ class Avartha:
15
+ """Use a platform root URL; protocol-specific clients accept full service URLs.
16
+
17
+ openai_options, elevenlabs_options and control_options are forwarded to
18
+ their respective constructors (for timeouts, transports, retry settings).
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ api_key: str | None = None,
25
+ base_url: str | None = None,
26
+ tier: Tier = "serverless",
27
+ elevenlabs_base_url: str | None = None,
28
+ elevenlabs_api_key: str | None = None,
29
+ openai_options: dict[str, Any] | None = None,
30
+ elevenlabs_options: dict[str, Any] | None = None,
31
+ control_options: dict[str, Any] | None = None,
32
+ ) -> None:
33
+ key, root = _config.api_key(api_key), _config.platform_url(base_url)
34
+ self._elevenlabs_base_url = _config.elevenlabs_url(
35
+ elevenlabs_base_url, root=root, tier=tier
36
+ )
37
+ self._elevenlabs_api_key = elevenlabs_api_key if elevenlabs_api_key is not None else key
38
+ self._elevenlabs_options = dict(elevenlabs_options or {})
39
+ self._control_config = {"api_key": key, "base_url": root, **(control_options or {})}
40
+ self.openai = OpenAI(
41
+ api_key=key,
42
+ base_url=_config.openai_url(root, tier),
43
+ **(openai_options or {}),
44
+ )
45
+ # These are the actual upstream resources, preserving their typed methods.
46
+ self.chat = self.openai.chat
47
+ self.responses = self.openai.responses
48
+ self.completions = self.openai.completions
49
+ self.realtime = self.openai.realtime
50
+ self.audio = self.openai.audio
51
+ self.models = self.openai.models
52
+
53
+ @cached_property
54
+ def elevenlabs(self) -> ElevenLabs:
55
+ return ElevenLabs(
56
+ api_key=self._elevenlabs_api_key,
57
+ base_url=self._elevenlabs_base_url,
58
+ **self._elevenlabs_options,
59
+ )
60
+
61
+ @cached_property
62
+ def control(self) -> Control:
63
+ return Control(**self._control_config)
64
+
65
+ def close(self) -> None:
66
+ try:
67
+ self.openai.close()
68
+ finally:
69
+ try:
70
+ if "elevenlabs" in self.__dict__:
71
+ self.elevenlabs.close()
72
+ finally:
73
+ if "control" in self.__dict__:
74
+ self.control.close()
75
+
76
+ def __enter__(self) -> Self:
77
+ return self
78
+
79
+ def __exit__(
80
+ self,
81
+ exc_type: type[BaseException] | None,
82
+ exc: BaseException | None,
83
+ traceback: TracebackType | None,
84
+ ) -> None:
85
+ self.close()
86
+
87
+
88
+ class AsyncAvartha:
89
+ """Use a platform root URL; protocol-specific clients accept full service URLs.
90
+
91
+ openai_options, elevenlabs_options and control_options are forwarded to
92
+ their respective constructors (for timeouts, transports, retry settings).
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ api_key: str | None = None,
99
+ base_url: str | None = None,
100
+ tier: Tier = "serverless",
101
+ elevenlabs_base_url: str | None = None,
102
+ elevenlabs_api_key: str | None = None,
103
+ openai_options: dict[str, Any] | None = None,
104
+ elevenlabs_options: dict[str, Any] | None = None,
105
+ control_options: dict[str, Any] | None = None,
106
+ ) -> None:
107
+ key, root = _config.api_key(api_key), _config.platform_url(base_url)
108
+ self._elevenlabs_base_url = _config.elevenlabs_url(
109
+ elevenlabs_base_url, root=root, tier=tier
110
+ )
111
+ self._elevenlabs_api_key = elevenlabs_api_key if elevenlabs_api_key is not None else key
112
+ self._elevenlabs_options = dict(elevenlabs_options or {})
113
+ self._control_config = {"api_key": key, "base_url": root, **(control_options or {})}
114
+ self.openai = AsyncOpenAI(
115
+ api_key=key,
116
+ base_url=_config.openai_url(root, tier),
117
+ **(openai_options or {}),
118
+ )
119
+ # These are the actual upstream resources, preserving their typed methods.
120
+ self.chat = self.openai.chat
121
+ self.responses = self.openai.responses
122
+ self.completions = self.openai.completions
123
+ self.realtime = self.openai.realtime
124
+ self.audio = self.openai.audio
125
+ self.models = self.openai.models
126
+
127
+ @cached_property
128
+ def elevenlabs(self) -> AsyncElevenLabs:
129
+ return AsyncElevenLabs(
130
+ api_key=self._elevenlabs_api_key,
131
+ base_url=self._elevenlabs_base_url,
132
+ **self._elevenlabs_options,
133
+ )
134
+
135
+ @cached_property
136
+ def control(self) -> AsyncControl:
137
+ return AsyncControl(**self._control_config)
138
+
139
+ async def close(self) -> None:
140
+ try:
141
+ await self.openai.close()
142
+ finally:
143
+ try:
144
+ if "elevenlabs" in self.__dict__:
145
+ await self.elevenlabs.close()
146
+ finally:
147
+ if "control" in self.__dict__:
148
+ await self.control.close()
149
+
150
+ async def __aenter__(self) -> Self:
151
+ return self
152
+
153
+ async def __aexit__(
154
+ self,
155
+ exc_type: type[BaseException] | None,
156
+ exc: BaseException | None,
157
+ traceback: TracebackType | None,
158
+ ) -> None:
159
+ await self.close()