ligaxai 0.1.0.dev13__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.
ligaxai/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """LigaX AI Python SDK.
2
+
3
+ from ligaxai import LigaX
4
+
5
+ client = LigaX() # reads LIGAX_API_KEY
6
+ run = client.runs.wait(profile_id="prf_...", input="Hello")
7
+ print(run.output.text)
8
+ """
9
+
10
+ from . import types
11
+ from ._client import (
12
+ API_KEY_ENV_VAR,
13
+ BASE_URL_ENV_VAR,
14
+ DEFAULT_BASE_URL,
15
+ AsyncLigaX,
16
+ LigaX,
17
+ MissingAPIKeyError,
18
+ )
19
+ from ._exceptions import (
20
+ APIConnectionError,
21
+ APIError,
22
+ APIStatusError,
23
+ APITimeoutError,
24
+ AuthenticationError,
25
+ BadRequestError,
26
+ ConflictError,
27
+ InternalServerError,
28
+ NotFoundError,
29
+ PaymentRequiredError,
30
+ PermissionDeniedError,
31
+ RateLimitError,
32
+ UnprocessableEntityError,
33
+ WebhookVerificationError,
34
+ )
35
+ from ._generated.models import Run
36
+ from ._models import BaseModel
37
+ from ._pagination import AsyncPage, SyncPage
38
+ from ._run_stream import (
39
+ AsyncRunStream,
40
+ AsyncRunStreamManager,
41
+ RunEvent,
42
+ RunEventList,
43
+ RunStream,
44
+ TextAssembler,
45
+ )
46
+ from ._streaming import AsyncStream, EventRegistry, ServerSentEvent, Stream
47
+ from ._types import NOT_GIVEN, NotGiven, RequestOptions, Timeout
48
+ from ._version import __version__
49
+
50
+ __all__ = [
51
+ "API_KEY_ENV_VAR",
52
+ "BASE_URL_ENV_VAR",
53
+ "DEFAULT_BASE_URL",
54
+ "NOT_GIVEN",
55
+ "APIConnectionError",
56
+ "APIError",
57
+ "APIStatusError",
58
+ "APITimeoutError",
59
+ "AsyncLigaX",
60
+ "AsyncPage",
61
+ "AsyncRunStream",
62
+ "AsyncRunStreamManager",
63
+ "AsyncStream",
64
+ "AuthenticationError",
65
+ "BadRequestError",
66
+ "BaseModel",
67
+ "ConflictError",
68
+ "EventRegistry",
69
+ "InternalServerError",
70
+ "LigaX",
71
+ "MissingAPIKeyError",
72
+ "NotFoundError",
73
+ "NotGiven",
74
+ "PaymentRequiredError",
75
+ "PermissionDeniedError",
76
+ "RateLimitError",
77
+ "RequestOptions",
78
+ "Run",
79
+ "RunEvent",
80
+ "RunEventList",
81
+ "RunStream",
82
+ "ServerSentEvent",
83
+ "Stream",
84
+ "SyncPage",
85
+ "TextAssembler",
86
+ "Timeout",
87
+ "UnprocessableEntityError",
88
+ "WebhookVerificationError",
89
+ "__version__",
90
+ "types",
91
+ ]
@@ -0,0 +1,437 @@
1
+ """The shared request pipeline behind ``LigaX`` and ``AsyncLigaX``.
2
+
3
+ Layering: ``_client`` (public constructors, env, resource mounting) → ``resources/*``
4
+ (one method per endpoint, sync + async) → this module (headers, retries, error mapping,
5
+ parsing). Resources never touch ``httpx2`` directly.
6
+
7
+ Retry policy, in the order it is evaluated:
8
+
9
+ 1. ``x-should-retry: true|false`` on the response is an explicit server override.
10
+ 2. Transport errors (connect, read, write, timeout) are retried — a POST carries an
11
+ ``Idempotency-Key`` that is minted once and reused across attempts, so a replayed
12
+ write is safe.
13
+ 3. 408, 429 and every 5xx are retried; a 409 only when the server marks it retryable
14
+ (``x-should-retry: true``, e.g. ``idempotency_in_progress``). 402, 403 and 422 never
15
+ are: payment,
16
+ permission and validation failures (a spend limit, a scope, ``idempotency_key_reused``)
17
+ do not fix themselves on a second attempt — and the server marks none of them retryable.
18
+ 4. Nothing is retried once a stream has started — the consumer resumes with
19
+ ``last_event_id`` instead.
20
+
21
+ Delay: ``retry-after-ms``, then ``retry-after`` (integer seconds or an HTTP-date), honoured
22
+ when ``0 < value <= 120`` s; otherwise exponential ``0.5 s → 8 s`` with 25 % jitter.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import email.utils
28
+ import json
29
+ import platform
30
+ import random
31
+ import sys
32
+ import time
33
+ import uuid
34
+ from datetime import UTC, datetime
35
+ from typing import Any, TypeAlias, TypeVar
36
+
37
+ import anyio
38
+ import httpx2
39
+
40
+ from ._exceptions import (
41
+ APIConnectionError,
42
+ APIStatusError,
43
+ APITimeoutError,
44
+ AuthenticationError,
45
+ BadRequestError,
46
+ ConflictError,
47
+ InternalServerError,
48
+ NotFoundError,
49
+ PaymentRequiredError,
50
+ PermissionDeniedError,
51
+ RateLimitError,
52
+ UnprocessableEntityError,
53
+ extract_error,
54
+ )
55
+ from ._logging import logger
56
+ from ._models import BaseModel
57
+ from ._pagination import AsyncPage, PageRequest, SyncPage
58
+ from ._streaming import AsyncStream, EventRegistry, Stream
59
+ from ._types import RequestOptions
60
+ from ._version import __version__
61
+
62
+ __all__ = [
63
+ "DEFAULT_MAX_RETRIES",
64
+ "DEFAULT_TIMEOUT",
65
+ "AsyncBaseClient",
66
+ "BaseClient",
67
+ "MultipartFiles",
68
+ "SyncBaseClient",
69
+ ]
70
+
71
+ DEFAULT_TIMEOUT = httpx2.Timeout(600.0, connect=5.0)
72
+ DEFAULT_MAX_RETRIES = 2
73
+ INITIAL_RETRY_DELAY = 0.5
74
+ MAX_RETRY_DELAY = 8.0
75
+ MAX_RETRY_AFTER = 120.0
76
+ RETRYABLE_STATUS = frozenset({408, 429})
77
+ NEVER_RETRY_STATUS = frozenset({402, 403, 422})
78
+ IDEMPOTENT_METHODS = frozenset({"POST"})
79
+
80
+ USER_AGENT_TEMPLATE = "ligaxai-python/{version} (python/{py}; {os}/{arch})"
81
+
82
+ ModelT = TypeVar("ModelT", bound=BaseModel)
83
+
84
+ #: Multipart parts as ``{field: (filename, content, content_type)}``. ``content`` is bytes,
85
+ #: never a stream: the request is rebuilt on every retry, and a stream would be empty the
86
+ #: second time.
87
+ MultipartFiles: TypeAlias = dict[str, tuple[str, bytes, str]]
88
+ SyncPageT = TypeVar("SyncPageT", bound="SyncPage[Any]")
89
+ AsyncPageT = TypeVar("AsyncPageT", bound="AsyncPage[Any]")
90
+
91
+ # Module-level so tests can monkeypatch them and run the retry loop without sleeping.
92
+ _sleep = time.sleep
93
+ _async_sleep = anyio.sleep
94
+
95
+
96
+ def _user_agent() -> str:
97
+ return USER_AGENT_TEMPLATE.format(
98
+ version=__version__,
99
+ py=f"{sys.version_info.major}.{sys.version_info.minor}",
100
+ os=sys.platform,
101
+ arch=platform.machine() or "unknown",
102
+ )
103
+
104
+
105
+ class BaseClient:
106
+ """Configuration and the pure (I/O-free) parts of the pipeline."""
107
+
108
+ api_key: str
109
+ base_url: httpx2.URL
110
+ timeout: httpx2.Timeout | float
111
+ max_retries: int
112
+
113
+ def __init__(
114
+ self,
115
+ *,
116
+ api_key: str,
117
+ base_url: str,
118
+ timeout: httpx2.Timeout | float | None,
119
+ max_retries: int,
120
+ default_headers: dict[str, str] | None,
121
+ ) -> None:
122
+ self.api_key = api_key
123
+ self.base_url = httpx2.URL(base_url if base_url.endswith("/") else base_url + "/")
124
+ self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
125
+ self.max_retries = max_retries
126
+ self._default_headers: dict[str, str] = dict(default_headers or {})
127
+
128
+ # -- request construction -------------------------------------------------------
129
+
130
+ @property
131
+ def user_agent(self) -> str:
132
+ return _user_agent()
133
+
134
+ def default_headers(self) -> dict[str, str]:
135
+ return {
136
+ "Accept": "application/json",
137
+ "User-Agent": self.user_agent,
138
+ "Authorization": f"Bearer {self.api_key}",
139
+ **self._default_headers,
140
+ }
141
+
142
+ def _build_headers(self, method: str, options: RequestOptions, stream: bool) -> dict[str, str]:
143
+ # Merged case-insensitively: a caller's `idempotency-key` or `authorization` replaces
144
+ # ours instead of travelling alongside it as a second header.
145
+ merged: dict[str, tuple[str, str]] = {}
146
+ for source in (self.default_headers(), options.headers):
147
+ for name, value in source.items():
148
+ merged[name.lower()] = (name, value)
149
+ if stream:
150
+ merged["accept"] = ("Accept", "text/event-stream")
151
+ if method.upper() in IDEMPOTENT_METHODS and "idempotency-key" not in merged:
152
+ merged["idempotency-key"] = (
153
+ "Idempotency-Key",
154
+ options.idempotency_key or f"sdk-{uuid.uuid4()}",
155
+ )
156
+ return dict(merged.values())
157
+
158
+ def _build_url(self, path: str) -> httpx2.URL:
159
+ return self.base_url.join(path.lstrip("/"))
160
+
161
+ # -- retry decisions -------------------------------------------------------------
162
+
163
+ def _should_retry(self, response: httpx2.Response) -> bool:
164
+ override = response.headers.get("x-should-retry", "").strip().lower()
165
+ if override == "true":
166
+ return True
167
+ if override == "false":
168
+ return False
169
+ status = response.status_code
170
+ if status in NEVER_RETRY_STATUS:
171
+ return False
172
+ return status in RETRYABLE_STATUS or status >= 500
173
+
174
+ def _retry_delay(self, retries_taken: int, response: httpx2.Response | None) -> float:
175
+ if response is not None:
176
+ hinted = _parse_retry_after(response.headers)
177
+ if hinted is not None and 0 < hinted <= MAX_RETRY_AFTER:
178
+ return hinted
179
+ base = min(INITIAL_RETRY_DELAY * (2**retries_taken), MAX_RETRY_DELAY)
180
+ jitter: float = 1 - 0.25 * random.random()
181
+ return float(base * jitter)
182
+
183
+ # -- response handling -----------------------------------------------------------
184
+
185
+ def _make_status_error(self, response: httpx2.Response) -> APIStatusError:
186
+ body = _safe_json(response)
187
+ raw_message = extract_error(body).get("message")
188
+ message: str | None = raw_message if isinstance(raw_message, str) else None
189
+ if message is None:
190
+ message = response.text.strip() or f"Error code: {response.status_code}"
191
+ cls = _status_error_class(response.status_code)
192
+ return cls(message, response=response, body=body)
193
+
194
+ def _parse(self, response: httpx2.Response, cast_to: type[ModelT]) -> ModelT:
195
+ value = cast_to.model_validate(_safe_json(response))
196
+ value.attach_request_id(response.headers.get("x-request-id"))
197
+ return value
198
+
199
+
200
+ class SyncBaseClient(BaseClient):
201
+ _http: httpx2.Client
202
+
203
+ def request(
204
+ self,
205
+ method: str,
206
+ path: str,
207
+ *,
208
+ cast_to: type[ModelT],
209
+ json: Any = None,
210
+ files: MultipartFiles | None = None,
211
+ data: dict[str, str] | None = None,
212
+ options: RequestOptions | None = None,
213
+ ) -> ModelT:
214
+ response = self._send_with_retries(
215
+ method, path, json=json, files=files, data=data, options=options, stream=False
216
+ )
217
+ return self._parse(response, cast_to)
218
+
219
+ def request_stream(
220
+ self,
221
+ method: str,
222
+ path: str,
223
+ *,
224
+ json: Any = None,
225
+ options: RequestOptions | None = None,
226
+ registry: EventRegistry[ModelT] | None = None,
227
+ ) -> Stream[ModelT]:
228
+ response = self._send_with_retries(method, path, json=json, options=options, stream=True)
229
+ return Stream(response=response, registry=registry)
230
+
231
+ def request_page(self, page_cls: type[SyncPageT], req: PageRequest) -> SyncPageT:
232
+ response = self._send_with_retries(req.method, req.path, json=req.json, options=req.options)
233
+ page = self._parse(response, page_cls)
234
+ page.bind(self, req)
235
+ return page
236
+
237
+ def _send_with_retries(
238
+ self,
239
+ method: str,
240
+ path: str,
241
+ *,
242
+ json: Any,
243
+ options: RequestOptions | None,
244
+ stream: bool = False,
245
+ files: MultipartFiles | None = None,
246
+ data: dict[str, str] | None = None,
247
+ ) -> httpx2.Response:
248
+ opts = options or RequestOptions()
249
+ max_retries = self.max_retries if opts.max_retries is None else opts.max_retries
250
+ headers = self._build_headers(method, opts, stream)
251
+ timeout = self.timeout if opts.timeout is None else opts.timeout
252
+ retries_taken = 0
253
+ while True:
254
+ request = self._http.build_request(
255
+ method,
256
+ self._build_url(path),
257
+ json=json,
258
+ files=files,
259
+ data=data,
260
+ params=opts.params or None,
261
+ headers=headers,
262
+ timeout=timeout,
263
+ )
264
+ logger.debug("Request %s %s (attempt %d)", method, request.url, retries_taken + 1)
265
+ try:
266
+ response = self._http.send(request, stream=stream)
267
+ except httpx2.TimeoutException as exc:
268
+ if retries_taken >= max_retries:
269
+ raise APITimeoutError(request) from exc
270
+ _sleep(self._retry_delay(retries_taken, None))
271
+ retries_taken += 1
272
+ continue
273
+ except httpx2.TransportError as exc:
274
+ if retries_taken >= max_retries:
275
+ raise APIConnectionError(request=request) from exc
276
+ _sleep(self._retry_delay(retries_taken, None))
277
+ retries_taken += 1
278
+ continue
279
+
280
+ if response.is_success:
281
+ return response
282
+
283
+ if retries_taken < max_retries and self._should_retry(response):
284
+ response.close()
285
+ _sleep(self._retry_delay(retries_taken, response))
286
+ retries_taken += 1
287
+ continue
288
+
289
+ response.read()
290
+ raise self._make_status_error(response)
291
+
292
+
293
+ class AsyncBaseClient(BaseClient):
294
+ _http: httpx2.AsyncClient
295
+
296
+ async def request(
297
+ self,
298
+ method: str,
299
+ path: str,
300
+ *,
301
+ cast_to: type[ModelT],
302
+ json: Any = None,
303
+ files: MultipartFiles | None = None,
304
+ data: dict[str, str] | None = None,
305
+ options: RequestOptions | None = None,
306
+ ) -> ModelT:
307
+ response = await self._send_with_retries(
308
+ method, path, json=json, files=files, data=data, options=options, stream=False
309
+ )
310
+ return self._parse(response, cast_to)
311
+
312
+ async def request_stream(
313
+ self,
314
+ method: str,
315
+ path: str,
316
+ *,
317
+ json: Any = None,
318
+ options: RequestOptions | None = None,
319
+ registry: EventRegistry[ModelT] | None = None,
320
+ ) -> AsyncStream[ModelT]:
321
+ response = await self._send_with_retries(
322
+ method, path, json=json, options=options, stream=True
323
+ )
324
+ return AsyncStream(response=response, registry=registry)
325
+
326
+ async def request_page(self, page_cls: type[AsyncPageT], req: PageRequest) -> AsyncPageT:
327
+ response = await self._send_with_retries(
328
+ req.method, req.path, json=req.json, options=req.options
329
+ )
330
+ page = self._parse(response, page_cls)
331
+ page.bind(self, req)
332
+ return page
333
+
334
+ async def _send_with_retries(
335
+ self,
336
+ method: str,
337
+ path: str,
338
+ *,
339
+ json: Any,
340
+ options: RequestOptions | None,
341
+ stream: bool = False,
342
+ files: MultipartFiles | None = None,
343
+ data: dict[str, str] | None = None,
344
+ ) -> httpx2.Response:
345
+ opts = options or RequestOptions()
346
+ max_retries = self.max_retries if opts.max_retries is None else opts.max_retries
347
+ headers = self._build_headers(method, opts, stream)
348
+ timeout = self.timeout if opts.timeout is None else opts.timeout
349
+ retries_taken = 0
350
+ while True:
351
+ request = self._http.build_request(
352
+ method,
353
+ self._build_url(path),
354
+ json=json,
355
+ files=files,
356
+ data=data,
357
+ params=opts.params or None,
358
+ headers=headers,
359
+ timeout=timeout,
360
+ )
361
+ logger.debug("Request %s %s (attempt %d)", method, request.url, retries_taken + 1)
362
+ try:
363
+ response = await self._http.send(request, stream=stream)
364
+ except httpx2.TimeoutException as exc:
365
+ if retries_taken >= max_retries:
366
+ raise APITimeoutError(request) from exc
367
+ await _async_sleep(self._retry_delay(retries_taken, None))
368
+ retries_taken += 1
369
+ continue
370
+ except httpx2.TransportError as exc:
371
+ if retries_taken >= max_retries:
372
+ raise APIConnectionError(request=request) from exc
373
+ await _async_sleep(self._retry_delay(retries_taken, None))
374
+ retries_taken += 1
375
+ continue
376
+
377
+ if response.is_success:
378
+ return response
379
+
380
+ if retries_taken < max_retries and self._should_retry(response):
381
+ await response.aclose()
382
+ await _async_sleep(self._retry_delay(retries_taken, response))
383
+ retries_taken += 1
384
+ continue
385
+
386
+ await response.aread()
387
+ raise self._make_status_error(response)
388
+
389
+
390
+ # -- helpers ---------------------------------------------------------------------------
391
+
392
+
393
+ def _status_error_class(status: int) -> type[APIStatusError]:
394
+ mapping: dict[int, type[APIStatusError]] = {
395
+ 400: BadRequestError,
396
+ 401: AuthenticationError,
397
+ 402: PaymentRequiredError,
398
+ 403: PermissionDeniedError,
399
+ 404: NotFoundError,
400
+ 409: ConflictError,
401
+ 422: UnprocessableEntityError,
402
+ 429: RateLimitError,
403
+ }
404
+ if status >= 500:
405
+ return InternalServerError
406
+ return mapping.get(status, APIStatusError)
407
+
408
+
409
+ def _safe_json(response: httpx2.Response) -> Any:
410
+ try:
411
+ return response.json()
412
+ except (json.JSONDecodeError, UnicodeDecodeError):
413
+ return None
414
+
415
+
416
+ def _parse_retry_after(headers: httpx2.Headers) -> float | None:
417
+ """``retry-after-ms`` first, then ``retry-after`` as seconds or an HTTP-date."""
418
+ ms = headers.get("retry-after-ms")
419
+ if ms is not None:
420
+ try:
421
+ return float(ms) / 1000
422
+ except ValueError:
423
+ pass
424
+ raw = headers.get("retry-after")
425
+ if raw is None:
426
+ return None
427
+ try:
428
+ return float(raw)
429
+ except ValueError:
430
+ pass
431
+ try:
432
+ when = email.utils.parsedate_to_datetime(raw)
433
+ except (TypeError, ValueError):
434
+ return None
435
+ if when.tzinfo is None:
436
+ when = when.replace(tzinfo=UTC)
437
+ return (when - datetime.now(UTC)).total_seconds()