tazo-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.
- tazo/__init__.py +45 -0
- tazo/_audio.py +87 -0
- tazo/_http.py +327 -0
- tazo/_version.py +1 -0
- tazo/client.py +99 -0
- tazo/errors.py +84 -0
- tazo/py.typed +0 -0
- tazo/resources.py +266 -0
- tazo/streaming.py +459 -0
- tazo/types.py +262 -0
- tazo_sdk-0.1.0.dist-info/METADATA +239 -0
- tazo_sdk-0.1.0.dist-info/RECORD +14 -0
- tazo_sdk-0.1.0.dist-info/WHEEL +4 -0
- tazo_sdk-0.1.0.dist-info/licenses/LICENSE +177 -0
tazo/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tazo's typed SDK for uploaded and streaming audio decisions."""
|
|
2
|
+
|
|
3
|
+
from ._audio import wav_chunks
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .client import AsyncTazo, Tazo
|
|
6
|
+
from .errors import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConnectionError,
|
|
9
|
+
DecisionPendingError,
|
|
10
|
+
IdempotencyConflictError,
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
ProtocolError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
RequestTooLargeError,
|
|
17
|
+
ServiceError,
|
|
18
|
+
StreamingError,
|
|
19
|
+
TazoError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
)
|
|
22
|
+
from .types import ChoiceQuestion, NoulQuestion
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AsyncTazo",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"ChoiceQuestion",
|
|
28
|
+
"ConnectionError",
|
|
29
|
+
"DecisionPendingError",
|
|
30
|
+
"IdempotencyConflictError",
|
|
31
|
+
"InvalidRequestError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"NoulQuestion",
|
|
34
|
+
"PermissionDeniedError",
|
|
35
|
+
"ProtocolError",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"RequestTooLargeError",
|
|
38
|
+
"ServiceError",
|
|
39
|
+
"StreamingError",
|
|
40
|
+
"Tazo",
|
|
41
|
+
"TazoError",
|
|
42
|
+
"TimeoutError",
|
|
43
|
+
"__version__",
|
|
44
|
+
"wav_chunks",
|
|
45
|
+
]
|
tazo/_audio.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Bounded, replayable uploads and standard-library WAV helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import wave
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import BinaryIO
|
|
12
|
+
|
|
13
|
+
from .errors import InvalidRequestError, RequestTooLargeError
|
|
14
|
+
|
|
15
|
+
AudioInput = str | os.PathLike[str] | bytes | BinaryIO
|
|
16
|
+
MAX_UPLOAD_BYTES = 16 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, repr=False)
|
|
20
|
+
class Upload:
|
|
21
|
+
"""One immutable snapshot, reused unchanged for every HTTP attempt."""
|
|
22
|
+
|
|
23
|
+
data: bytes
|
|
24
|
+
filename: str
|
|
25
|
+
content_type: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def snapshot_audio(audio: AudioInput, filename: str | None, content_type: str | None) -> Upload:
|
|
29
|
+
name = "audio"
|
|
30
|
+
if isinstance(audio, bytes):
|
|
31
|
+
data = audio
|
|
32
|
+
elif isinstance(audio, (str, os.PathLike)):
|
|
33
|
+
path = Path(audio)
|
|
34
|
+
name = path.name
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
data = handle.read(MAX_UPLOAD_BYTES + 1)
|
|
37
|
+
else:
|
|
38
|
+
candidate = getattr(audio, "name", None)
|
|
39
|
+
if isinstance(candidate, str):
|
|
40
|
+
name = Path(candidate).name
|
|
41
|
+
position = audio.tell() if audio.seekable() else None
|
|
42
|
+
try:
|
|
43
|
+
data = audio.read(MAX_UPLOAD_BYTES + 1)
|
|
44
|
+
finally:
|
|
45
|
+
if position is not None:
|
|
46
|
+
audio.seek(position)
|
|
47
|
+
if not isinstance(data, bytes):
|
|
48
|
+
raise InvalidRequestError("Audio must be a binary file, bytes, or a filesystem path.")
|
|
49
|
+
if not data:
|
|
50
|
+
raise InvalidRequestError("Audio must not be empty.")
|
|
51
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
52
|
+
raise RequestTooLargeError("Audio uploads are limited to 16 MiB.")
|
|
53
|
+
name = Path(filename).name if filename else name
|
|
54
|
+
media_type = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
55
|
+
return Upload(data, name, media_type)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def wav_chunks(path: str | os.PathLike[str], *, chunk_ms: int = 100) -> Iterator[bytes]:
|
|
59
|
+
"""Yield raw mono 16 kHz PCM16 from a WAV, without headers or real-time pacing.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
path: PCM16 WAV recording. Compressed WAV and other formats are rejected.
|
|
63
|
+
chunk_ms: Chunk size from 1 to 200 milliseconds.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
Binary audio frames suitable for ``session.send_audio``.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(chunk_ms, bool) or not isinstance(chunk_ms, int) or not 1 <= chunk_ms <= 200:
|
|
69
|
+
raise InvalidRequestError("chunk_ms must be an integer between 1 and 200.")
|
|
70
|
+
with wave.open(str(path), "rb") as handle:
|
|
71
|
+
if (
|
|
72
|
+
handle.getnchannels(),
|
|
73
|
+
handle.getsampwidth(),
|
|
74
|
+
handle.getframerate(),
|
|
75
|
+
handle.getcomptype(),
|
|
76
|
+
) != (1, 2, 16000, "NONE"):
|
|
77
|
+
raise InvalidRequestError("Streaming requires a mono, 16 kHz, PCM16 WAV.")
|
|
78
|
+
if handle.getnframes() == 0:
|
|
79
|
+
raise InvalidRequestError("Audio must not be empty.")
|
|
80
|
+
remaining = handle.getnframes()
|
|
81
|
+
while remaining:
|
|
82
|
+
frames = min(chunk_ms * 16, remaining)
|
|
83
|
+
data = handle.readframes(frames)
|
|
84
|
+
if len(data) != frames * 2:
|
|
85
|
+
raise InvalidRequestError("The WAV file is truncated.")
|
|
86
|
+
remaining -= frames
|
|
87
|
+
yield data
|
tazo/_http.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Shared request policy with native sync and async HTTP adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import builtins
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import TypeVar
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from pydantic import BaseModel, ValidationError
|
|
18
|
+
|
|
19
|
+
from ._audio import Upload
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
ConnectionError,
|
|
24
|
+
DecisionPendingError,
|
|
25
|
+
IdempotencyConflictError,
|
|
26
|
+
InvalidRequestError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
PermissionDeniedError,
|
|
29
|
+
ProtocolError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
RequestTooLargeError,
|
|
32
|
+
ServiceError,
|
|
33
|
+
TazoError,
|
|
34
|
+
TimeoutError,
|
|
35
|
+
)
|
|
36
|
+
from .types import DecisionResponse
|
|
37
|
+
|
|
38
|
+
ResponseT = TypeVar("ResponseT", bound=BaseModel)
|
|
39
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=5, read=65, write=30, pool=5)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def retry_after(value: str | None) -> float | None:
|
|
43
|
+
if not value:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
delay = float(value)
|
|
47
|
+
except ValueError:
|
|
48
|
+
try:
|
|
49
|
+
instant = parsedate_to_datetime(value)
|
|
50
|
+
if instant.tzinfo is None:
|
|
51
|
+
instant = instant.replace(tzinfo=UTC)
|
|
52
|
+
delay = (instant - datetime.now(UTC)).total_seconds()
|
|
53
|
+
except (TypeError, ValueError, OverflowError):
|
|
54
|
+
return None
|
|
55
|
+
return max(0, delay) if math.isfinite(delay) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def response_error(response: httpx.Response, key: str | None) -> TazoError:
|
|
59
|
+
code = None
|
|
60
|
+
message = f"Tazo returned HTTP {response.status_code}."
|
|
61
|
+
retryable = response.status_code in {408, 429, 500, 502, 503, 504}
|
|
62
|
+
request_id = response.headers.get("X-Tazo-Request-Id")
|
|
63
|
+
try:
|
|
64
|
+
body = response.json()
|
|
65
|
+
except ValueError:
|
|
66
|
+
body = None
|
|
67
|
+
if isinstance(body, dict):
|
|
68
|
+
request_id = request_id or (
|
|
69
|
+
body.get("request_id") if isinstance(body.get("request_id"), str) else None
|
|
70
|
+
)
|
|
71
|
+
error = body.get("error")
|
|
72
|
+
if isinstance(error, dict):
|
|
73
|
+
code = error.get("code") if isinstance(error.get("code"), str) else None
|
|
74
|
+
if isinstance(error.get("message"), str):
|
|
75
|
+
message = error["message"]
|
|
76
|
+
if isinstance(error.get("retryable"), bool):
|
|
77
|
+
retryable = error["retryable"]
|
|
78
|
+
cls: type[TazoError] = {
|
|
79
|
+
400: InvalidRequestError,
|
|
80
|
+
401: AuthenticationError,
|
|
81
|
+
403: PermissionDeniedError,
|
|
82
|
+
404: NotFoundError,
|
|
83
|
+
409: IdempotencyConflictError,
|
|
84
|
+
413: RequestTooLargeError,
|
|
85
|
+
422: InvalidRequestError,
|
|
86
|
+
429: RateLimitError,
|
|
87
|
+
}.get(response.status_code, ServiceError)
|
|
88
|
+
# Authentication and invalid input must never cause retry loops, even if a
|
|
89
|
+
# malformed server response sets retryable=true.
|
|
90
|
+
if response.status_code in {400, 401, 403, 404, 409, 413, 422}:
|
|
91
|
+
retryable = False
|
|
92
|
+
if code == "decision_pending":
|
|
93
|
+
cls = DecisionPendingError
|
|
94
|
+
return cls(
|
|
95
|
+
message,
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
code=code,
|
|
98
|
+
request_id=request_id,
|
|
99
|
+
idempotency_key=key,
|
|
100
|
+
retryable=retryable,
|
|
101
|
+
retry_after=retry_after(response.headers.get("Retry-After")),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HTTPPolicy:
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
api_key: str | None,
|
|
110
|
+
base_url: str,
|
|
111
|
+
timeout: float | httpx.Timeout,
|
|
112
|
+
max_retries: int,
|
|
113
|
+
operation_timeout: float,
|
|
114
|
+
) -> None:
|
|
115
|
+
parsed = urlsplit(base_url)
|
|
116
|
+
if (
|
|
117
|
+
parsed.scheme not in {"http", "https"}
|
|
118
|
+
or not parsed.netloc
|
|
119
|
+
or parsed.username
|
|
120
|
+
or parsed.password
|
|
121
|
+
or parsed.query
|
|
122
|
+
or parsed.fragment
|
|
123
|
+
):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"base_url must be an HTTP(S) URL without credentials, query, or fragment."
|
|
126
|
+
)
|
|
127
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
128
|
+
raise ValueError("max_retries must be a nonnegative integer.")
|
|
129
|
+
if not math.isfinite(operation_timeout) or operation_timeout <= 0:
|
|
130
|
+
raise ValueError("operation_timeout must be positive and finite.")
|
|
131
|
+
self.base_url = parsed.geturl().rstrip("/")
|
|
132
|
+
self._api_key = api_key if api_key is not None else os.getenv("TAZO_API_KEY")
|
|
133
|
+
if self._api_key is not None and (
|
|
134
|
+
not self._api_key.strip()
|
|
135
|
+
or not self._api_key.isascii()
|
|
136
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in self._api_key)
|
|
137
|
+
):
|
|
138
|
+
raise ValueError("api_key must contain printable ASCII characters.")
|
|
139
|
+
self.timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
140
|
+
self.max_retries = max_retries
|
|
141
|
+
self.operation_timeout = operation_timeout
|
|
142
|
+
self.closed = False
|
|
143
|
+
|
|
144
|
+
def credential(self) -> str:
|
|
145
|
+
if not self._api_key:
|
|
146
|
+
raise AuthenticationError("Set TAZO_API_KEY or pass api_key to the client.")
|
|
147
|
+
return self._api_key
|
|
148
|
+
|
|
149
|
+
def headers(self, *, authenticated: bool, key: str | None) -> dict[str, str]:
|
|
150
|
+
if self.closed:
|
|
151
|
+
raise TazoError("This client is closed.")
|
|
152
|
+
headers = {"User-Agent": f"tazo-python/{__version__}", "Accept": "application/json"}
|
|
153
|
+
if authenticated:
|
|
154
|
+
headers["Authorization"] = f"Bearer {self.credential()}"
|
|
155
|
+
if key is not None:
|
|
156
|
+
headers["Idempotency-Key"] = key
|
|
157
|
+
return headers
|
|
158
|
+
|
|
159
|
+
def attempt_timeout(self, remaining: float) -> httpx.Timeout:
|
|
160
|
+
def cap(value: float | None) -> float:
|
|
161
|
+
return remaining if value is None else min(value, remaining)
|
|
162
|
+
|
|
163
|
+
return httpx.Timeout(
|
|
164
|
+
**{
|
|
165
|
+
name: cap(getattr(self.timeout, name))
|
|
166
|
+
for name in ("connect", "read", "write", "pool")
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def delay(self, error: TazoError, attempt: int) -> float:
|
|
171
|
+
if error.retry_after is not None:
|
|
172
|
+
return error.retry_after
|
|
173
|
+
return random.uniform(0, min(8, 0.5 * 2 ** min(attempt, 8)))
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def decode(response: httpx.Response, model: type[ResponseT], key: str | None) -> ResponseT:
|
|
177
|
+
try:
|
|
178
|
+
result = model.model_validate_json(response.content)
|
|
179
|
+
except ValidationError:
|
|
180
|
+
raise ProtocolError(
|
|
181
|
+
"Tazo returned an invalid response.",
|
|
182
|
+
status_code=response.status_code,
|
|
183
|
+
request_id=response.headers.get("X-Tazo-Request-Id"),
|
|
184
|
+
idempotency_key=key,
|
|
185
|
+
) from None
|
|
186
|
+
if isinstance(result, DecisionResponse):
|
|
187
|
+
result.request_id = response.headers.get("X-Tazo-Request-Id")
|
|
188
|
+
result.idempotency_key = key
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def deadline_error(key: str | None, last: TazoError | None) -> TazoError:
|
|
193
|
+
if last is not None:
|
|
194
|
+
return last
|
|
195
|
+
return TimeoutError(
|
|
196
|
+
"The operation deadline was reached; accepted decision work may continue.",
|
|
197
|
+
idempotency_key=key,
|
|
198
|
+
retryable=True,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class HTTPClient(HTTPPolicy):
|
|
203
|
+
def __init__(self, *, http_client: httpx.Client | None = None, **kwargs: object) -> None:
|
|
204
|
+
# Public constructors provide the typed policy arguments explicitly.
|
|
205
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
206
|
+
self._client = http_client or httpx.Client()
|
|
207
|
+
self._owns_client = http_client is None
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
if not self.closed and self._owns_client:
|
|
211
|
+
self._client.close()
|
|
212
|
+
self.closed = True
|
|
213
|
+
|
|
214
|
+
def request(
|
|
215
|
+
self,
|
|
216
|
+
method: str,
|
|
217
|
+
path: str,
|
|
218
|
+
model: type[ResponseT],
|
|
219
|
+
*,
|
|
220
|
+
authenticated: bool = True,
|
|
221
|
+
key: str | None = None,
|
|
222
|
+
request_json: str | None = None,
|
|
223
|
+
upload: Upload | None = None,
|
|
224
|
+
health: bool = False,
|
|
225
|
+
) -> ResponseT:
|
|
226
|
+
headers = self.headers(authenticated=authenticated, key=key)
|
|
227
|
+
deadline = time.monotonic() + self.operation_timeout
|
|
228
|
+
last: TazoError | None = None
|
|
229
|
+
for attempt in range(self.max_retries + 1):
|
|
230
|
+
remaining = deadline - time.monotonic()
|
|
231
|
+
if remaining <= 0:
|
|
232
|
+
raise self.deadline_error(key, last)
|
|
233
|
+
try:
|
|
234
|
+
response = self._client.request(
|
|
235
|
+
method,
|
|
236
|
+
f"{self.base_url}{path}",
|
|
237
|
+
headers=headers,
|
|
238
|
+
timeout=self.attempt_timeout(remaining),
|
|
239
|
+
follow_redirects=False,
|
|
240
|
+
files=None
|
|
241
|
+
if upload is None
|
|
242
|
+
else {
|
|
243
|
+
"request": (None, request_json or "", "application/json"),
|
|
244
|
+
"audio": (upload.filename, upload.data, upload.content_type),
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
except httpx.TimeoutException:
|
|
248
|
+
last = TimeoutError("Tazo request timed out.", idempotency_key=key, retryable=True)
|
|
249
|
+
except httpx.TransportError:
|
|
250
|
+
last = ConnectionError(
|
|
251
|
+
"Could not connect to Tazo.", idempotency_key=key, retryable=True
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
if response.status_code == 200 or (health and response.status_code == 503):
|
|
255
|
+
return self.decode(response, model, key)
|
|
256
|
+
last = response_error(response, key)
|
|
257
|
+
if not last.retryable or attempt == self.max_retries:
|
|
258
|
+
raise last
|
|
259
|
+
delay = self.delay(last, attempt)
|
|
260
|
+
if delay >= deadline - time.monotonic():
|
|
261
|
+
raise last
|
|
262
|
+
time.sleep(delay)
|
|
263
|
+
raise AssertionError("Unreachable retry state")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class AsyncHTTPClient(HTTPPolicy):
|
|
267
|
+
def __init__(self, *, http_client: httpx.AsyncClient | None = None, **kwargs: object) -> None:
|
|
268
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
269
|
+
self._client = http_client or httpx.AsyncClient()
|
|
270
|
+
self._owns_client = http_client is None
|
|
271
|
+
|
|
272
|
+
async def close(self) -> None:
|
|
273
|
+
if not self.closed and self._owns_client:
|
|
274
|
+
await self._client.aclose()
|
|
275
|
+
self.closed = True
|
|
276
|
+
|
|
277
|
+
async def request(
|
|
278
|
+
self,
|
|
279
|
+
method: str,
|
|
280
|
+
path: str,
|
|
281
|
+
model: type[ResponseT],
|
|
282
|
+
*,
|
|
283
|
+
authenticated: bool = True,
|
|
284
|
+
key: str | None = None,
|
|
285
|
+
request_json: str | None = None,
|
|
286
|
+
upload: Upload | None = None,
|
|
287
|
+
health: bool = False,
|
|
288
|
+
) -> ResponseT:
|
|
289
|
+
headers = self.headers(authenticated=authenticated, key=key)
|
|
290
|
+
deadline = time.monotonic() + self.operation_timeout
|
|
291
|
+
last: TazoError | None = None
|
|
292
|
+
for attempt in range(self.max_retries + 1):
|
|
293
|
+
remaining = deadline - time.monotonic()
|
|
294
|
+
if remaining <= 0:
|
|
295
|
+
raise self.deadline_error(key, last)
|
|
296
|
+
try:
|
|
297
|
+
async with asyncio.timeout(remaining):
|
|
298
|
+
response = await self._client.request(
|
|
299
|
+
method,
|
|
300
|
+
f"{self.base_url}{path}",
|
|
301
|
+
headers=headers,
|
|
302
|
+
timeout=self.attempt_timeout(remaining),
|
|
303
|
+
follow_redirects=False,
|
|
304
|
+
files=None
|
|
305
|
+
if upload is None
|
|
306
|
+
else {
|
|
307
|
+
"request": (None, request_json or "", "application/json"),
|
|
308
|
+
"audio": (upload.filename, upload.data, upload.content_type),
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
except (httpx.TimeoutException, builtins.TimeoutError):
|
|
312
|
+
last = TimeoutError("Tazo request timed out.", idempotency_key=key, retryable=True)
|
|
313
|
+
except httpx.TransportError:
|
|
314
|
+
last = ConnectionError(
|
|
315
|
+
"Could not connect to Tazo.", idempotency_key=key, retryable=True
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
if response.status_code == 200 or (health and response.status_code == 503):
|
|
319
|
+
return self.decode(response, model, key)
|
|
320
|
+
last = response_error(response, key)
|
|
321
|
+
if not last.retryable or attempt == self.max_retries:
|
|
322
|
+
raise last
|
|
323
|
+
delay = self.delay(last, attempt)
|
|
324
|
+
if delay >= deadline - time.monotonic():
|
|
325
|
+
raise last
|
|
326
|
+
await asyncio.sleep(delay)
|
|
327
|
+
raise AssertionError("Unreachable retry state")
|
tazo/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tazo/client.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Public client configuration and connection ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._http import DEFAULT_TIMEOUT, AsyncHTTPClient, HTTPClient
|
|
8
|
+
from .resources import AsyncDecisions, AsyncHealth, AsyncModels, Decisions, Health, Models
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Tazo:
|
|
12
|
+
"""Synchronous client for the four public Tazo operations.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
api_key: Bearer credential, or read TAZO_API_KEY. May be omitted for health.
|
|
16
|
+
base_url: API origin, optionally with a proxy path prefix; no /v1 suffix.
|
|
17
|
+
timeout: Per-network-phase timeout in seconds, or an httpx.Timeout.
|
|
18
|
+
max_retries: Additional HTTP attempts after the first (default two).
|
|
19
|
+
operation_timeout: HTTP retry budget in seconds (default 180). Each network
|
|
20
|
+
phase is capped by the remaining budget. A synchronous in-flight request
|
|
21
|
+
can outlast this budget across multiple network phases/body chunks.
|
|
22
|
+
http_client: Optional caller-owned HTTPX client; the SDK will not close it.
|
|
23
|
+
|
|
24
|
+
Use as a context manager, or call close. Streaming sockets have their own
|
|
25
|
+
context managers and must be closed before closing this client.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
base_url: str = "https://api.tazolabs.com",
|
|
33
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
34
|
+
max_retries: int = 2,
|
|
35
|
+
operation_timeout: float = 180,
|
|
36
|
+
http_client: httpx.Client | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._http = HTTPClient(
|
|
39
|
+
api_key=api_key,
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
operation_timeout=operation_timeout,
|
|
44
|
+
http_client=http_client,
|
|
45
|
+
)
|
|
46
|
+
self.decisions = Decisions(self._http)
|
|
47
|
+
self.models = Models(self._http)
|
|
48
|
+
self.health = Health(self._http)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Release the SDK-owned HTTP pool. Safe to call more than once."""
|
|
52
|
+
self._http.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> Tazo:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args: object) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncTazo:
|
|
62
|
+
"""Native async client with the same configuration as Tazo.
|
|
63
|
+
|
|
64
|
+
Await create, list, and check. Use async with for streaming sessions.
|
|
65
|
+
operation_timeout also enforces an async deadline during each HTTP attempt.
|
|
66
|
+
Supplied HTTPX clients remain caller-owned.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
api_key: str | None = None,
|
|
73
|
+
base_url: str = "https://api.tazolabs.com",
|
|
74
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
75
|
+
max_retries: int = 2,
|
|
76
|
+
operation_timeout: float = 180,
|
|
77
|
+
http_client: httpx.AsyncClient | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._http = AsyncHTTPClient(
|
|
80
|
+
api_key=api_key,
|
|
81
|
+
base_url=base_url,
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
max_retries=max_retries,
|
|
84
|
+
operation_timeout=operation_timeout,
|
|
85
|
+
http_client=http_client,
|
|
86
|
+
)
|
|
87
|
+
self.decisions = AsyncDecisions(self._http)
|
|
88
|
+
self.models = AsyncModels(self._http)
|
|
89
|
+
self.health = AsyncHealth(self._http)
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Release the SDK-owned HTTP pool."""
|
|
93
|
+
await self._http.close()
|
|
94
|
+
|
|
95
|
+
async def __aenter__(self) -> AsyncTazo:
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, *args: object) -> None:
|
|
99
|
+
await self.close()
|
tazo/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Exceptions shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TazoError(Exception):
|
|
7
|
+
"""Base SDK error with recovery metadata and no stored credentials or audio."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
code: str | None = None,
|
|
15
|
+
request_id: str | None = None,
|
|
16
|
+
idempotency_key: str | None = None,
|
|
17
|
+
retryable: bool = False,
|
|
18
|
+
retry_after: float | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.code = code
|
|
23
|
+
self.request_id = request_id
|
|
24
|
+
self.idempotency_key = idempotency_key
|
|
25
|
+
self.retryable = retryable
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(TazoError):
|
|
30
|
+
"""Missing or rejected bearer credential."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PermissionDeniedError(TazoError):
|
|
34
|
+
"""The authenticated caller lacks access."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidRequestError(TazoError):
|
|
38
|
+
"""Invalid local input or an HTTP 400/422 rejection."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFoundError(TazoError):
|
|
42
|
+
"""The requested model was not found."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RequestTooLargeError(InvalidRequestError):
|
|
46
|
+
"""Audio or request exceeds the service's size limit."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RateLimitError(TazoError):
|
|
50
|
+
"""Account admission limit reached; inspect retry_after."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IdempotencyConflictError(TazoError):
|
|
54
|
+
"""An operation key was reused with different input."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DecisionPendingError(TazoError):
|
|
58
|
+
"""Accepted work continues. Retain the key and retry the same input later."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConnectionError(TazoError):
|
|
62
|
+
"""Transport connection failed; acceptance may be uncertain for writes."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TimeoutError(TazoError):
|
|
66
|
+
"""A network timeout or the overall operation deadline was reached."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProtocolError(TazoError):
|
|
70
|
+
"""The server response doesn't match the public contract."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ServiceError(TazoError):
|
|
74
|
+
"""The service returned an unsuccessful response."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StreamingError(TazoError):
|
|
78
|
+
"""A fatal streaming failure or unexpected disconnect; audio is never replayed."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self, message: str, *, code: str | None = None, close_code: int | None = None
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(message, code=code)
|
|
84
|
+
self.close_code = close_code
|
tazo/py.typed
ADDED
|
File without changes
|