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.
@@ -0,0 +1,21 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ *.tsbuildinfo
5
+ npm-debug.log*
6
+
7
+ # Go
8
+ /gateway/bin/
9
+ *.out
10
+
11
+ # Env / secrets — NEVER commit
12
+ .env
13
+ .env.*
14
+ *.pem
15
+ *.key
16
+ secrets/
17
+
18
+ # OS / editor
19
+ .DS_Store
20
+ .idea/
21
+ .vscode/
@@ -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,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,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"
@@ -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()