pyai-sdk 0.1.0__tar.gz
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_sdk-0.1.0/.gitignore +21 -0
- pyai_sdk-0.1.0/PKG-INFO +99 -0
- pyai_sdk-0.1.0/README.md +87 -0
- pyai_sdk-0.1.0/pyai/__init__.py +6 -0
- pyai_sdk-0.1.0/pyai/client.py +269 -0
- pyai_sdk-0.1.0/pyproject.toml +21 -0
- pyai_sdk-0.1.0/tests/test_client.py +98 -0
pyai_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
pyai_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# pyai-sdk (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [PyAI API](https://api.pyai.com) — Hear
|
|
4
|
+
(speech-to-text), Speak (text-to-speech + cloning), Cue (turn detection + KB
|
|
5
|
+
context), and Omni (realtime voice agents). Zero third-party dependencies
|
|
6
|
+
(standard library only); Python 3.9+.
|
|
7
|
+
|
|
8
|
+
The contract is `https://api.pyai.com/openapi.json`. This SDK wraps it with
|
|
9
|
+
typed errors, automatic retries, and realtime URL helpers.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install pyai-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from pyai import PyAI, new_idempotency_key
|
|
22
|
+
|
|
23
|
+
pyai = PyAI(api_key=os.environ["PYAI_API_KEY"])
|
|
24
|
+
|
|
25
|
+
# Text-to-speech
|
|
26
|
+
audio = pyai.audio.speech(input="Hello from PyAI.", voice="stock_sarah_style2")
|
|
27
|
+
open("hello.wav", "wb").write(audio)
|
|
28
|
+
|
|
29
|
+
# Voices
|
|
30
|
+
voices = pyai.voices.list(gender="female")
|
|
31
|
+
|
|
32
|
+
# Async transcription (safe retry with an idempotency key)
|
|
33
|
+
job = pyai.transcription_jobs.create(
|
|
34
|
+
audio_url="https://example.com/call.wav",
|
|
35
|
+
diarize=True,
|
|
36
|
+
idempotency_key=new_idempotency_key(),
|
|
37
|
+
)
|
|
38
|
+
done = pyai.transcription_jobs.get(job["job_id"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Realtime (Omni)
|
|
42
|
+
|
|
43
|
+
Keys travel as a WebSocket subprotocol. Use the helpers with your preferred WS
|
|
44
|
+
library (e.g. `websockets`):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
url = pyai.realtime_url(product="omni", agent_id="agent_123")
|
|
48
|
+
subprotocol = pyai.realtime_subprotocol()
|
|
49
|
+
|
|
50
|
+
import asyncio, websockets
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
async with websockets.connect(url, subprotocols=[subprotocol]) as ws:
|
|
54
|
+
async for frame in ws:
|
|
55
|
+
print(frame)
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
> Omni uses the native `wss://api.pyai.com/v1/omni` surface (the default for
|
|
61
|
+
> `product="omni"`); `product="flow"` uses `/v1/realtime`. The older
|
|
62
|
+
> `/v2/omni/chat` URL is deprecated but still works.
|
|
63
|
+
|
|
64
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
Failures raise `PyAIError` with a stable `code` (branch on it, not the message):
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pyai import PyAIError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
pyai.audio.speech(input="hi")
|
|
73
|
+
except PyAIError as err:
|
|
74
|
+
if err.code == "credit_exhausted":
|
|
75
|
+
... # out of prepaid credit — add credit or use a sandbox key
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
|
|
79
|
+
`rate_limit_exceeded`, `concurrency_limit_exceeded`, `idempotency_conflict`.
|
|
80
|
+
`429`/`5xx` are retried automatically (honoring `Retry-After`); tune with
|
|
81
|
+
`PyAI(api_key, max_retries=...)`.
|
|
82
|
+
|
|
83
|
+
## Develop
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python -m unittest discover -s tests -v # no network; transport injected
|
|
87
|
+
```
|
|
@@ -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,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyai-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the PyAI API (Hear, Speak, Cue, Omni)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["pyai", "voice-ai", "tts", "stt", "speech", "openai-compatible"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://pyai.com"
|
|
17
|
+
Documentation = "https://api.pyai.com/docs"
|
|
18
|
+
Repository = "https://github.com/atomsai/pyai-platform-backend"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["pyai"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for the PyAI Python SDK. No network — a fake transport is injected."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
9
|
+
|
|
10
|
+
from pyai import PyAI, PyAIError # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_transport(handler):
|
|
14
|
+
"""handler(method, url, headers, body) -> (status, headers, body_bytes)."""
|
|
15
|
+
calls = []
|
|
16
|
+
|
|
17
|
+
def transport(method, url, headers, body):
|
|
18
|
+
calls.append({"method": method, "url": url, "headers": headers, "body": body})
|
|
19
|
+
return handler(method, url, headers, body)
|
|
20
|
+
|
|
21
|
+
transport.calls = calls
|
|
22
|
+
return transport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def json_body(obj):
|
|
26
|
+
return json.dumps(obj).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestClient(unittest.TestCase):
|
|
30
|
+
def test_bearer_auth_and_models_list(self):
|
|
31
|
+
t = make_transport(lambda *a: (200, {}, json_body({"object": "list", "data": [{"id": "pyai-voice"}]})))
|
|
32
|
+
pyai = PyAI(api_key="pyai_test_k", transport=t)
|
|
33
|
+
res = pyai.models.list()
|
|
34
|
+
self.assertEqual(res["data"][0]["id"], "pyai-voice")
|
|
35
|
+
self.assertEqual(t.calls[0]["headers"]["Authorization"], "Bearer pyai_test_k")
|
|
36
|
+
|
|
37
|
+
def test_voices_query(self):
|
|
38
|
+
t = make_transport(lambda *a: (200, {}, json_body({"object": "list", "data": []})))
|
|
39
|
+
pyai = PyAI(api_key="k", transport=t)
|
|
40
|
+
pyai.voices.list(gender="female", region="en_us")
|
|
41
|
+
self.assertIn("gender=female", t.calls[0]["url"])
|
|
42
|
+
self.assertIn("region=en_us", t.calls[0]["url"])
|
|
43
|
+
|
|
44
|
+
def test_speech_returns_bytes(self):
|
|
45
|
+
t = make_transport(lambda *a: (200, {}, b"\x01\x02\x03"))
|
|
46
|
+
pyai = PyAI(api_key="k", transport=t)
|
|
47
|
+
audio = pyai.audio.speech(input="hi", voice="v")
|
|
48
|
+
self.assertEqual(audio, b"\x01\x02\x03")
|
|
49
|
+
|
|
50
|
+
def test_idempotency_header_forwarded(self):
|
|
51
|
+
t = make_transport(lambda *a: (202, {}, json_body({"job_id": "job_1", "status": "queued"})))
|
|
52
|
+
pyai = PyAI(api_key="k", transport=t)
|
|
53
|
+
job = pyai.transcription_jobs.create(audio_url="https://x/a.wav", idempotency_key="abc")
|
|
54
|
+
self.assertEqual(job["job_id"], "job_1")
|
|
55
|
+
self.assertEqual(t.calls[0]["headers"]["Idempotency-Key"], "abc")
|
|
56
|
+
|
|
57
|
+
def test_openai_error_mapping(self):
|
|
58
|
+
t = make_transport(lambda *a: (402, {}, json_body({"error": {"message": "no credit", "code": "credit_exhausted"}})))
|
|
59
|
+
pyai = PyAI(api_key="k", max_retries=0, transport=t)
|
|
60
|
+
with self.assertRaises(PyAIError) as ctx:
|
|
61
|
+
pyai.models.list()
|
|
62
|
+
self.assertEqual(ctx.exception.status, 402)
|
|
63
|
+
self.assertEqual(ctx.exception.code, "credit_exhausted")
|
|
64
|
+
|
|
65
|
+
def test_problem_json_error_mapping(self):
|
|
66
|
+
body = {"type": "https://api.pyai.com/problems/idempotency_conflict", "title": "Conflict", "status": 409}
|
|
67
|
+
t = make_transport(lambda *a: (409, {}, json_body(body)))
|
|
68
|
+
pyai = PyAI(api_key="k", max_retries=0, transport=t)
|
|
69
|
+
with self.assertRaises(PyAIError) as ctx:
|
|
70
|
+
pyai.transcription_jobs.get("job_1")
|
|
71
|
+
self.assertEqual(ctx.exception.code, "idempotency_conflict")
|
|
72
|
+
|
|
73
|
+
def test_retry_on_429(self):
|
|
74
|
+
state = {"n": 0}
|
|
75
|
+
|
|
76
|
+
def handler(method, url, headers, body):
|
|
77
|
+
state["n"] += 1
|
|
78
|
+
if state["n"] == 1:
|
|
79
|
+
return 429, {"Retry-After": "0"}, json_body({})
|
|
80
|
+
return 200, {}, json_body({"object": "list", "data": []})
|
|
81
|
+
|
|
82
|
+
t = make_transport(handler)
|
|
83
|
+
pyai = PyAI(api_key="k", max_retries=2, transport=t)
|
|
84
|
+
pyai.models.list()
|
|
85
|
+
self.assertEqual(len(t.calls), 2)
|
|
86
|
+
|
|
87
|
+
def test_realtime_url_and_subprotocol(self):
|
|
88
|
+
pyai = PyAI(api_key="pyai_live_z")
|
|
89
|
+
self.assertEqual(
|
|
90
|
+
pyai.realtime_url(product="omni", agent_id="agent_1"),
|
|
91
|
+
"wss://api.pyai.com/v1/omni?agent_id=agent_1&format=pcm16&rate=24000",
|
|
92
|
+
)
|
|
93
|
+
self.assertTrue(pyai.realtime_url(product="flow").endswith("/v1/realtime?model=pyai-flow-realtime"))
|
|
94
|
+
self.assertEqual(pyai.realtime_subprotocol(), "pyai-key.pyai_live_z")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
unittest.main()
|