pyai-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyai/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """PyAI — official Python SDK for the PyAI API."""
2
+
3
+ from .client import PyAI, PyAIError, new_idempotency_key
4
+
5
+ __all__ = ["PyAI", "PyAIError", "new_idempotency_key"]
6
+ __version__ = "0.1.0"
pyai/client.py ADDED
@@ -0,0 +1,269 @@
1
+ """Official Python SDK for the PyAI API (Hear, Speak, Cue, Omni).
2
+
3
+ Zero third-party dependencies — uses the standard library (urllib). The public
4
+ contract is https://api.pyai.com/openapi.json; this client wraps it with typed
5
+ errors, automatic retries, and realtime URL helpers. Keys are opaque — never
6
+ parse them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from typing import Any, Callable, Optional
18
+
19
+ __all__ = ["PyAI", "PyAIError"]
20
+
21
+ # transport(method, url, headers, body) -> (status, headers, body_bytes)
22
+ Transport = Callable[[str, str, dict, Optional[bytes]], "tuple[int, dict, bytes]"]
23
+
24
+ _RETRYABLE = {429, 500, 502, 503, 504}
25
+ _DEFAULT_BASE_URL = "https://api.pyai.com"
26
+
27
+
28
+ class PyAIError(Exception):
29
+ """A PyAI API error. Branch on ``code`` (stable), not ``message``."""
30
+
31
+ def __init__(
32
+ self,
33
+ status: int,
34
+ message: str,
35
+ code: Optional[str] = None,
36
+ type: Optional[str] = None,
37
+ request_id: Optional[str] = None,
38
+ ) -> None:
39
+ super().__init__(f"[{status}{f'/{code}' if code else ''}] {message}")
40
+ self.status = status
41
+ self.message = message
42
+ self.code = code
43
+ self.type = type
44
+ self.request_id = request_id
45
+
46
+
47
+ def _default_transport(method: str, url: str, headers: dict, body: Optional[bytes]):
48
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
49
+ try:
50
+ with urllib.request.urlopen(req) as resp: # noqa: S310 (trusted base URL)
51
+ return resp.status, dict(resp.headers), resp.read()
52
+ except urllib.error.HTTPError as err:
53
+ return err.code, dict(err.headers), err.read()
54
+
55
+
56
+ class PyAI:
57
+ """PyAI API client.
58
+
59
+ >>> pyai = PyAI(api_key="pyai_test_...")
60
+ >>> pyai.voices.list(gender="female")
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ api_key: str,
66
+ base_url: str = _DEFAULT_BASE_URL,
67
+ max_retries: int = 2,
68
+ transport: Optional[Transport] = None,
69
+ ) -> None:
70
+ if not api_key:
71
+ raise ValueError("api_key is required")
72
+ self.api_key = api_key
73
+ self.base_url = base_url.rstrip("/")
74
+ self.max_retries = max_retries
75
+ self._transport = transport or _default_transport
76
+ # Sub-resources mirror the TS SDK ergonomics.
77
+ self.models = _Models(self)
78
+ self.voices = _Voices(self)
79
+ self.audio = _Audio(self)
80
+ self.transcription_jobs = _TranscriptionJobs(self)
81
+
82
+ # --- transport ---------------------------------------------------------
83
+
84
+ def _headers(self, extra: Optional[dict] = None) -> dict:
85
+ h = {"Authorization": f"Bearer {self.api_key}"}
86
+ if extra:
87
+ h.update(extra)
88
+ return h
89
+
90
+ def _request(self, method: str, path: str, headers: dict, body: Optional[bytes]) -> "tuple[int, dict, bytes]":
91
+ url = f"{self.base_url}{path}"
92
+ attempt = 0
93
+ while True:
94
+ status, resp_headers, resp_body = self._transport(method, url, headers, body)
95
+ if status < 400:
96
+ return status, resp_headers, resp_body
97
+ if status in _RETRYABLE and attempt < self.max_retries:
98
+ retry_after = _to_float(resp_headers.get("Retry-After") or resp_headers.get("retry-after"))
99
+ delay = retry_after if retry_after and retry_after > 0 else (2**attempt) * 0.25
100
+ time.sleep(delay)
101
+ attempt += 1
102
+ continue
103
+ raise _to_error(status, resp_headers, resp_body)
104
+
105
+ def _get_json(self, path: str) -> Any:
106
+ _, _, body = self._request("GET", path, self._headers(), None)
107
+ return json.loads(body.decode("utf-8"))
108
+
109
+ def _post_json(self, path: str, payload: dict, extra_headers: Optional[dict] = None) -> Any:
110
+ headers = self._headers({"Content-Type": "application/json", **(extra_headers or {})})
111
+ body = json.dumps(payload).encode("utf-8")
112
+ _, _, resp = self._request("POST", path, headers, body)
113
+ return json.loads(resp.decode("utf-8"))
114
+
115
+ def _post_for_bytes(self, path: str, payload: dict) -> bytes:
116
+ headers = self._headers({"Content-Type": "application/json"})
117
+ body = json.dumps(payload).encode("utf-8")
118
+ _, _, resp = self._request("POST", path, headers, body)
119
+ return resp
120
+
121
+ # --- realtime ----------------------------------------------------------
122
+
123
+ def realtime_url(self, product: str = "omni", agent_id: Optional[str] = None, query: Optional[dict] = None) -> str:
124
+ """Build the realtime WebSocket URL for ``omni`` or ``flow``."""
125
+ ws_base = self.base_url.replace("https://", "wss://").replace("http://", "ws://")
126
+ params = dict(query or {})
127
+ if product == "omni":
128
+ # Omni's native realtime surface is /v1/omni. agent_id is an opaque
129
+ # label authorized by the key's org. format/rate are load-bearing on
130
+ # the connect URL, so default to browser-grade PCM16/24kHz.
131
+ if agent_id:
132
+ params["agent_id"] = agent_id
133
+ params.setdefault("format", "pcm16")
134
+ params.setdefault("rate", "24000")
135
+ qs = urllib.parse.urlencode(params)
136
+ return f"{ws_base}/v1/omni{('?' + qs) if qs else ''}"
137
+ params["model"] = "pyai-flow-realtime"
138
+ return f"{ws_base}/v1/realtime?{urllib.parse.urlencode(params)}"
139
+
140
+ def realtime_subprotocol(self) -> str:
141
+ """The WS subprotocol that carries the key (browser-safe auth)."""
142
+ return f"pyai-key.{self.api_key}"
143
+
144
+
145
+ class _Models:
146
+ def __init__(self, client: PyAI) -> None:
147
+ self._c = client
148
+
149
+ def list(self) -> dict:
150
+ return self._c._get_json("/v1/models")
151
+
152
+
153
+ class _Voices:
154
+ def __init__(self, client: PyAI) -> None:
155
+ self._c = client
156
+
157
+ def list(self, gender: Optional[str] = None, region: Optional[str] = None) -> dict:
158
+ params = {}
159
+ if gender:
160
+ params["gender"] = gender
161
+ if region:
162
+ params["region"] = region
163
+ qs = urllib.parse.urlencode(params)
164
+ return self._c._get_json(f"/v1/voices{('?' + qs) if qs else ''}")
165
+
166
+ def get(self, voice_id: str) -> dict:
167
+ return self._c._get_json(f"/v1/voices/{urllib.parse.quote(voice_id)}")
168
+
169
+
170
+ class _Audio:
171
+ def __init__(self, client: PyAI) -> None:
172
+ self._c = client
173
+
174
+ def speech(
175
+ self,
176
+ input: str,
177
+ voice: Optional[str] = None,
178
+ model: str = "pyai-voice",
179
+ response_format: Optional[str] = None,
180
+ sample_rate: Optional[int] = None,
181
+ speed: Optional[float] = None,
182
+ ) -> bytes:
183
+ """Text-to-speech. Returns the raw audio bytes (default WAV).
184
+
185
+ ``response_format="pcm"`` returns raw, headerless 16-bit little-endian
186
+ mono samples at ``sample_rate`` (Hz, 8000-48000; defaults to the native
187
+ 24 kHz) — the shape voice-agent pipelines consume directly.
188
+ """
189
+ payload: dict = {"model": model, "input": input}
190
+ if voice:
191
+ payload["voice"] = voice
192
+ if response_format:
193
+ payload["response_format"] = response_format
194
+ if sample_rate is not None:
195
+ payload["sample_rate"] = sample_rate
196
+ if speed is not None:
197
+ payload["speed"] = speed
198
+ return self._c._post_for_bytes("/v1/audio/speech", payload)
199
+
200
+
201
+ class _TranscriptionJobs:
202
+ def __init__(self, client: PyAI) -> None:
203
+ self._c = client
204
+
205
+ def create(
206
+ self,
207
+ audio_url: str,
208
+ model: Optional[str] = None,
209
+ diarize: Optional[bool] = None,
210
+ channel: Optional[bool] = None,
211
+ output_formats: Optional[list] = None,
212
+ webhook_url: Optional[str] = None,
213
+ idempotency_key: Optional[str] = None,
214
+ ) -> dict:
215
+ payload: dict = {"audio_url": audio_url}
216
+ if model:
217
+ payload["model"] = model
218
+ if diarize is not None:
219
+ payload["diarize"] = diarize
220
+ if channel is not None:
221
+ payload["channel"] = channel
222
+ if output_formats is not None:
223
+ payload["output_formats"] = output_formats
224
+ if webhook_url:
225
+ payload["webhook_url"] = webhook_url
226
+ extra = {"Idempotency-Key": idempotency_key} if idempotency_key else None
227
+ return self._c._post_json("/v1/transcription/jobs", payload, extra)
228
+
229
+ def get(self, job_id: str) -> dict:
230
+ return self._c._get_json(f"/v1/transcription/jobs/{urllib.parse.quote(job_id)}")
231
+
232
+ def list(self, limit: Optional[int] = None, cursor: Optional[str] = None) -> dict:
233
+ params = {}
234
+ if limit is not None:
235
+ params["limit"] = str(limit)
236
+ if cursor:
237
+ params["cursor"] = cursor
238
+ qs = urllib.parse.urlencode(params)
239
+ return self._c._get_json(f"/v1/transcription/jobs{('?' + qs) if qs else ''}")
240
+
241
+
242
+ def new_idempotency_key() -> str:
243
+ """Convenience: a fresh idempotency key for safe retries."""
244
+ return str(uuid.uuid4())
245
+
246
+
247
+ def _to_float(value: Optional[str]) -> Optional[float]:
248
+ if value is None:
249
+ return None
250
+ try:
251
+ return float(value)
252
+ except (TypeError, ValueError):
253
+ return None
254
+
255
+
256
+ def _to_error(status: int, headers: dict, body: bytes) -> PyAIError:
257
+ request_id = headers.get("X-Request-Id") or headers.get("x-request-id")
258
+ try:
259
+ parsed = json.loads(body.decode("utf-8"))
260
+ except (ValueError, UnicodeDecodeError):
261
+ return PyAIError(status, f"HTTP {status}", request_id=request_id)
262
+ err = parsed.get("error") if isinstance(parsed, dict) else None
263
+ if isinstance(err, dict):
264
+ return PyAIError(status, err.get("message", f"HTTP {status}"), err.get("code"), err.get("type"), request_id)
265
+ # RFC 7807 problem: code is the last segment of `type`.
266
+ type_url = parsed.get("type") if isinstance(parsed, dict) else None
267
+ code = type_url.rsplit("/", 1)[-1] if isinstance(type_url, str) else None
268
+ detail = parsed.get("detail") or parsed.get("title") if isinstance(parsed, dict) else None
269
+ return PyAIError(status, detail or f"HTTP {status}", code, request_id=parsed.get("request_id") or request_id)
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyai-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the PyAI API (Hear, Speak, Cue, Omni).
5
+ Project-URL: Homepage, https://pyai.com
6
+ Project-URL: Documentation, https://api.pyai.com/docs
7
+ Project-URL: Repository, https://github.com/atomsai/pyai-platform-backend
8
+ License: MIT
9
+ Keywords: openai-compatible,pyai,speech,stt,tts,voice-ai
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pyai-sdk (Python SDK)
14
+
15
+ Official Python SDK for the [PyAI API](https://api.pyai.com) — Hear
16
+ (speech-to-text), Speak (text-to-speech + cloning), Cue (turn detection + KB
17
+ context), and Omni (realtime voice agents). Zero third-party dependencies
18
+ (standard library only); Python 3.9+.
19
+
20
+ The contract is `https://api.pyai.com/openapi.json`. This SDK wraps it with
21
+ typed errors, automatic retries, and realtime URL helpers.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install pyai-sdk
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ import os
33
+ from pyai import PyAI, new_idempotency_key
34
+
35
+ pyai = PyAI(api_key=os.environ["PYAI_API_KEY"])
36
+
37
+ # Text-to-speech
38
+ audio = pyai.audio.speech(input="Hello from PyAI.", voice="stock_sarah_style2")
39
+ open("hello.wav", "wb").write(audio)
40
+
41
+ # Voices
42
+ voices = pyai.voices.list(gender="female")
43
+
44
+ # Async transcription (safe retry with an idempotency key)
45
+ job = pyai.transcription_jobs.create(
46
+ audio_url="https://example.com/call.wav",
47
+ diarize=True,
48
+ idempotency_key=new_idempotency_key(),
49
+ )
50
+ done = pyai.transcription_jobs.get(job["job_id"])
51
+ ```
52
+
53
+ ## Realtime (Omni)
54
+
55
+ Keys travel as a WebSocket subprotocol. Use the helpers with your preferred WS
56
+ library (e.g. `websockets`):
57
+
58
+ ```python
59
+ url = pyai.realtime_url(product="omni", agent_id="agent_123")
60
+ subprotocol = pyai.realtime_subprotocol()
61
+
62
+ import asyncio, websockets
63
+
64
+ async def main():
65
+ async with websockets.connect(url, subprotocols=[subprotocol]) as ws:
66
+ async for frame in ws:
67
+ print(frame)
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ > Omni uses the native `wss://api.pyai.com/v1/omni` surface (the default for
73
+ > `product="omni"`); `product="flow"` uses `/v1/realtime`. The older
74
+ > `/v2/omni/chat` URL is deprecated but still works.
75
+
76
+ ## Errors
77
+
78
+ Failures raise `PyAIError` with a stable `code` (branch on it, not the message):
79
+
80
+ ```python
81
+ from pyai import PyAIError
82
+
83
+ try:
84
+ pyai.audio.speech(input="hi")
85
+ except PyAIError as err:
86
+ if err.code == "credit_exhausted":
87
+ ... # out of prepaid credit — add credit or use a sandbox key
88
+ ```
89
+
90
+ Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
91
+ `rate_limit_exceeded`, `concurrency_limit_exceeded`, `idempotency_conflict`.
92
+ `429`/`5xx` are retried automatically (honoring `Retry-After`); tune with
93
+ `PyAI(api_key, max_retries=...)`.
94
+
95
+ ## Develop
96
+
97
+ ```bash
98
+ python -m unittest discover -s tests -v # no network; transport injected
99
+ ```
@@ -0,0 +1,5 @@
1
+ pyai/__init__.py,sha256=tHlWFpRcaQrYy49ZcmFTWBZihqtqYk9TgZQ6E-ka2V8,189
2
+ pyai/client.py,sha256=nDMt8X71Dci9YY3hP0FS-f88WddQGuo8wAF-eNY6iDQ,10160
3
+ pyai_sdk-0.1.0.dist-info/METADATA,sha256=amQZAwJ21Zn8mpEZeFxdkG46cBP-fzg0cq6FtXnc2jo,2776
4
+ pyai_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ pyai_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any