hakim 1.0.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.
hakim/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Hakim — official Python SDK for the Hakim voice AI API.
2
+
3
+ Quickstart (async):
4
+
5
+ from hakim import AsyncHakim
6
+
7
+ async with AsyncHakim(api_key=...) as hakim:
8
+ audio = await hakim.tts_create(
9
+ model="hakim-fast-v1",
10
+ input="مرحبا بالعالم",
11
+ voice="omar",
12
+ )
13
+
14
+ Quickstart (sync):
15
+
16
+ from hakim import Hakim
17
+
18
+ with Hakim(api_key=...) as hakim:
19
+ audio = hakim.tts_create(
20
+ model="hakim-fast-v1", input="مرحبا بالعالم", voice="omar",
21
+ )
22
+
23
+ Every namespace from the Node SDK (`audio.speech`, `audio.transcriptions`,
24
+ `voices`, `webhooks`, `jobs`, `usage`) is available on both clients.
25
+ """
26
+
27
+ from ._version import SDK_NAME, SDK_VERSION
28
+ from .client import AsyncHakim, Hakim
29
+ from .errors import (
30
+ APIError,
31
+ AuthenticationError,
32
+ ConnectionError,
33
+ HakimError,
34
+ IdempotencyConflictError,
35
+ InvalidRequestError,
36
+ NotFoundError,
37
+ PermissionError,
38
+ QuotaExceededError,
39
+ RateLimitError,
40
+ ServiceUnavailableError,
41
+ )
42
+ from .webhooks import VerifyResult, verify_webhook_signature
43
+
44
+ __all__ = [
45
+ "SDK_NAME",
46
+ "SDK_VERSION",
47
+ "APIError",
48
+ "AsyncHakim",
49
+ "AuthenticationError",
50
+ "ConnectionError",
51
+ "Hakim",
52
+ "HakimError",
53
+ "IdempotencyConflictError",
54
+ "InvalidRequestError",
55
+ "NotFoundError",
56
+ "PermissionError",
57
+ "QuotaExceededError",
58
+ "RateLimitError",
59
+ "ServiceUnavailableError",
60
+ "VerifyResult",
61
+ "verify_webhook_signature",
62
+ ]
63
+
64
+ __version__ = SDK_VERSION
hakim/_transport.py ADDED
@@ -0,0 +1,345 @@
1
+ """Async HTTP transport built on :mod:`httpx`.
2
+
3
+ Contract:
4
+
5
+ * Uses ``httpx.AsyncClient`` — no per-request connection churn.
6
+ * Every call attaches ``Authorization`` + ``User-Agent`` + ``X-Request-Id``.
7
+ * Mutating calls with a JSON body auto-generate an ``Idempotency-Key``
8
+ unless the caller supplied one.
9
+ * Retries on transient failures: 5xx (except 501/505), 429, 408, 425,
10
+ and low-level connection errors. Back-off is exponential with ±25%
11
+ jitter, capped at 10 s; honours ``Retry-After`` when present.
12
+ * 4xx (except 408 / 425 / 429) is never retried — caller error.
13
+ * JSON / multipart / raw / streaming bodies supported.
14
+ * Non-2xx raises a :class:`HakimError` subclass built from the uniform
15
+ ``ApiError`` body. Network-level failures raise :class:`ConnectionError`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import os
23
+ import random as _random
24
+ import sys
25
+ import time
26
+ import uuid
27
+ from collections.abc import Awaitable, Callable, Mapping
28
+ from email.utils import parsedate_to_datetime
29
+ from typing import Any, cast
30
+
31
+ import httpx
32
+
33
+ from ._version import SDK_NAME, SDK_VERSION
34
+ from .errors import (
35
+ ConnectionError,
36
+ HakimApiErrorPayload,
37
+ HakimError,
38
+ error_from_payload,
39
+ )
40
+
41
+ _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
42
+
43
+ # Retry-eligible statuses (408 RequestTimeout, 425 TooEarly, 429 TooMany,
44
+ # 5xx minus 501/505).
45
+ _RETRY_STATUSES = frozenset({408, 425, 429})
46
+
47
+
48
+ class AsyncTransport:
49
+ """Low-level async HTTP helper. Shared by every namespace."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ api_key: str,
55
+ base_url: str,
56
+ timeout_s: float,
57
+ max_retries: int,
58
+ user_agent_suffix: str | None = None,
59
+ client: httpx.AsyncClient | None = None,
60
+ sleep: Callable[[float], Awaitable[None]] | None = None,
61
+ random: Callable[[], float] | None = None,
62
+ generate_idempotency_key: Callable[[], str] | None = None,
63
+ ) -> None:
64
+ if not api_key:
65
+ raise ValueError("hakim SDK: api_key is required")
66
+ self.api_key = api_key
67
+ self.base_url = base_url.rstrip("/")
68
+ self._timeout_s = timeout_s
69
+ self._max_retries = max_retries
70
+ self._user_agent = _build_user_agent(user_agent_suffix)
71
+ self._client = client or httpx.AsyncClient(timeout=timeout_s)
72
+ self._owns_client = client is None
73
+ self._sleep: Callable[[float], Awaitable[None]] = sleep or asyncio.sleep
74
+ self._random: Callable[[], float] = random or _random.random
75
+ self._generate_idempotency_key: Callable[[], str] = (
76
+ generate_idempotency_key or _default_idempotency_key
77
+ )
78
+
79
+ async def aclose(self) -> None:
80
+ if self._owns_client:
81
+ await self._client.aclose()
82
+
83
+ async def __aenter__(self) -> AsyncTransport:
84
+ return self
85
+
86
+ async def __aexit__(self, *_exc: object) -> None:
87
+ await self.aclose()
88
+
89
+ async def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ *,
94
+ json: Any | None = None,
95
+ files: list[tuple[str, Any]] | None = None,
96
+ data: Mapping[str, Any] | None = None,
97
+ params: Mapping[str, Any] | None = None,
98
+ headers: Mapping[str, str] | None = None,
99
+ idempotency_key: str | None = None,
100
+ timeout_s: float | None = None,
101
+ stream: bool = False,
102
+ accept: str | None = None,
103
+ ) -> httpx.Response:
104
+ """Execute ``method path`` with retries + idempotency + UA.
105
+
106
+ The caller is responsible for closing a streamed response via
107
+ ``async with resp`` or ``await resp.aclose()``; for non-stream
108
+ calls the transport reads and returns the buffered response.
109
+ """
110
+
111
+ url = f"{self.base_url}/{path.lstrip('/')}"
112
+ final_headers = self._build_headers(
113
+ method=method,
114
+ extra=dict(headers or {}),
115
+ has_json=json is not None,
116
+ has_multipart=files is not None or data is not None,
117
+ idempotency_key=idempotency_key,
118
+ accept=accept,
119
+ )
120
+ actual_timeout = timeout_s if timeout_s is not None else self._timeout_s
121
+
122
+ attempt = 0
123
+ last_exc: BaseException | None = None
124
+ while attempt <= self._max_retries:
125
+ try:
126
+ build_kwargs: dict[str, Any] = {
127
+ "method": method,
128
+ "url": url,
129
+ "headers": final_headers,
130
+ "params": _clean_params(params),
131
+ "timeout": actual_timeout,
132
+ }
133
+ if json is not None:
134
+ build_kwargs["json"] = json
135
+ if files is not None:
136
+ build_kwargs["files"] = files
137
+ if data is not None:
138
+ build_kwargs["data"] = dict(data)
139
+ request = self._client.build_request(**build_kwargs)
140
+ response = await self._client.send(request, stream=stream)
141
+ except httpx.TimeoutException as err:
142
+ last_exc = err
143
+ code = "request_timeout"
144
+ if attempt < self._max_retries:
145
+ await self._sleep(self._backoff_seconds(attempt))
146
+ attempt += 1
147
+ continue
148
+ raise ConnectionError(
149
+ f"hakim SDK: request timed out after {actual_timeout}s",
150
+ code=code,
151
+ request_id=final_headers.get("x-request-id"),
152
+ cause=err,
153
+ ) from err
154
+ except httpx.HTTPError as err:
155
+ last_exc = err
156
+ if attempt < self._max_retries:
157
+ await self._sleep(self._backoff_seconds(attempt))
158
+ attempt += 1
159
+ continue
160
+ raise ConnectionError(
161
+ f"hakim SDK: network error: {err}",
162
+ code="network_error",
163
+ request_id=final_headers.get("x-request-id"),
164
+ cause=err,
165
+ ) from err
166
+
167
+ if response.is_success:
168
+ return response
169
+
170
+ if _is_retryable_status(response.status_code) and attempt < self._max_retries:
171
+ retry_after = _read_retry_after_seconds(response.headers)
172
+ wait_s = (
173
+ retry_after
174
+ if retry_after is not None
175
+ else self._backoff_seconds(attempt)
176
+ )
177
+ if stream:
178
+ await response.aclose()
179
+ else:
180
+ await response.aread()
181
+ await response.aclose()
182
+ await self._sleep(wait_s)
183
+ attempt += 1
184
+ continue
185
+
186
+ # Non-retryable (or retries exhausted) — raise as HakimError.
187
+ raise await _parse_error_response(response)
188
+
189
+ # If we fall out of the loop, retries were exhausted on transient.
190
+ raise ConnectionError(
191
+ f"hakim SDK: request failed after {self._max_retries} retries",
192
+ request_id=final_headers.get("x-request-id"),
193
+ cause=last_exc,
194
+ )
195
+
196
+ def _build_headers(
197
+ self,
198
+ *,
199
+ method: str,
200
+ extra: dict[str, str],
201
+ has_json: bool,
202
+ has_multipart: bool,
203
+ idempotency_key: str | None,
204
+ accept: str | None,
205
+ ) -> dict[str, str]:
206
+ del has_multipart # for symmetry with Node SDK (multipart uses httpx defaults)
207
+ headers: dict[str, str] = {
208
+ "authorization": f"Bearer {self.api_key}",
209
+ "user-agent": self._user_agent,
210
+ }
211
+ for k, v in extra.items():
212
+ headers[k.lower()] = v
213
+ headers.setdefault("x-request-id", f"sdk-{_default_idempotency_key()}")
214
+ headers.setdefault("accept", accept or "application/json")
215
+
216
+ if method in _MUTATING_METHODS and has_json:
217
+ headers["idempotency-key"] = (
218
+ idempotency_key or self._generate_idempotency_key()
219
+ )
220
+ elif idempotency_key is not None:
221
+ headers["idempotency-key"] = idempotency_key
222
+ return headers
223
+
224
+ def _backoff_seconds(self, attempt: int) -> float:
225
+ """200 ms, 600 ms, 1800 ms, ..., capped at 10 s; ±25% jitter."""
226
+ base_ms: float = float(min(200 * (3**attempt), 10_000))
227
+ jitter: float = base_ms * (self._random() - 0.5) * 0.5
228
+ total_ms: float = max(0.0, base_ms + jitter)
229
+ return total_ms / 1000.0
230
+
231
+
232
+ def _build_user_agent(suffix: str | None) -> str:
233
+ py = f"python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
234
+ plat = sys.platform
235
+ base = f"{SDK_NAME}/{SDK_VERSION} ({py}; {plat})"
236
+ return f"{base} {suffix}" if suffix else base
237
+
238
+
239
+ def _default_idempotency_key() -> str:
240
+ return str(uuid.uuid4())
241
+
242
+
243
+ def _is_retryable_status(status: int) -> bool:
244
+ if status in _RETRY_STATUSES:
245
+ return True
246
+ if status in (501, 505):
247
+ return False
248
+ return 500 <= status <= 599
249
+
250
+
251
+ def _read_retry_after_seconds(headers: httpx.Headers) -> float | None:
252
+ raw = headers.get("retry-after")
253
+ if not raw:
254
+ return None
255
+ try:
256
+ n = float(raw)
257
+ if n >= 0:
258
+ return n
259
+ except ValueError:
260
+ pass
261
+ try:
262
+ dt = parsedate_to_datetime(raw)
263
+ if dt is None:
264
+ return None
265
+ delta = dt.timestamp() - time.time()
266
+ return max(0.0, delta)
267
+ except (TypeError, ValueError):
268
+ return None
269
+
270
+
271
+ async def _parse_error_response(response: httpx.Response) -> HakimError:
272
+ request_id = response.headers.get("x-request-id")
273
+ retry_after = _read_retry_after_seconds(response.headers)
274
+ retry_after_ms = int(retry_after * 1000) if retry_after is not None else None
275
+
276
+ content_type = response.headers.get("content-type", "")
277
+ body_bytes: bytes
278
+ try:
279
+ if response.is_stream_consumed:
280
+ body_bytes = response.content
281
+ else:
282
+ body_bytes = await response.aread()
283
+ except Exception:
284
+ body_bytes = b""
285
+ finally:
286
+ with contextlib.suppress(Exception):
287
+ await response.aclose()
288
+
289
+ payload: HakimApiErrorPayload | None = None
290
+ try:
291
+ text_body = body_bytes.decode("utf-8", errors="replace")
292
+ except Exception:
293
+ text_body = ""
294
+ if "application/json" in content_type and text_body:
295
+ try:
296
+ import json as _json
297
+
298
+ parsed = _json.loads(text_body)
299
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
300
+ payload = cast(HakimApiErrorPayload, parsed["error"])
301
+ except Exception:
302
+ payload = None
303
+
304
+ if payload is None:
305
+ payload = cast(
306
+ HakimApiErrorPayload,
307
+ {
308
+ "type": "api_error"
309
+ if response.status_code >= 500
310
+ else "invalid_request_error",
311
+ "code": f"http_{response.status_code}",
312
+ "message": text_body or response.reason_phrase
313
+ or f"HTTP {response.status_code}",
314
+ },
315
+ )
316
+
317
+ return error_from_payload(
318
+ payload,
319
+ status=response.status_code,
320
+ request_id=request_id,
321
+ retry_after_ms=retry_after_ms,
322
+ )
323
+
324
+
325
+ def _clean_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
326
+ if not params:
327
+ return None
328
+ out: dict[str, Any] = {}
329
+ for key, value in params.items():
330
+ if value is None:
331
+ continue
332
+ if isinstance(value, bool):
333
+ out[key] = "true" if value else "false"
334
+ else:
335
+ out[key] = value
336
+ return out
337
+
338
+
339
+ # Re-export so tests can import a stable public surface. Not part of
340
+ # the caller-facing API; prefixed with `_` in __all__ to hide.
341
+ __all__ = ["AsyncTransport"]
342
+
343
+ # Pull env helper out for the client module.
344
+ def env(name: str) -> str | None:
345
+ return os.environ.get(name)