morphiq-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.
@@ -0,0 +1,89 @@
1
+ """Typed sync/async clients for the MorphIQ Labs API."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .client import AsyncMorphIQ, MorphIQ
6
+ from .errors import (
7
+ APIError,
8
+ MorphIQError,
9
+ RateLimitError,
10
+ RequestTimeout,
11
+ ResponseError,
12
+ TransportError,
13
+ )
14
+ from .market_data import PriceAdjustment, PriceBar, PriceHistory, PriceInterval, PriceProvider
15
+ from .models import (
16
+ AnalyticsRequest,
17
+ AnalyticsResponse,
18
+ AnalyticsResult,
19
+ BatchAnalyticsRequest,
20
+ BatchAnalyticsResponse,
21
+ BatchFailure,
22
+ BatchGreeksRequest,
23
+ BatchGreeksResponse,
24
+ BatchImpliedVolatilityRequest,
25
+ BatchImpliedVolatilityResponse,
26
+ BatchPriceRequest,
27
+ BatchPriceResponse,
28
+ BatchSuccess,
29
+ DividendEvent,
30
+ Greeks,
31
+ GreeksRequest,
32
+ GreeksResponse,
33
+ GreeksResult,
34
+ ImpliedVolatilityRequest,
35
+ ImpliedVolatilityResponse,
36
+ ImpliedVolatilityResult,
37
+ PriceRequest,
38
+ PriceResponse,
39
+ RegimeRequest,
40
+ RegimeResponse,
41
+ )
42
+ from .workflows import AsyncBatchWorkflow, BatchWorkflow, ChunkAttempt
43
+
44
+ __version__ = version("morphiq-sdk")
45
+
46
+ __all__ = [
47
+ "AsyncBatchWorkflow",
48
+ "BatchWorkflow",
49
+ "ChunkAttempt",
50
+ "BatchPriceRequest",
51
+ "BatchPriceResponse",
52
+ "BatchGreeksRequest",
53
+ "BatchGreeksResponse",
54
+ "BatchImpliedVolatilityRequest",
55
+ "BatchImpliedVolatilityResponse",
56
+ "BatchAnalyticsRequest",
57
+ "BatchAnalyticsResponse",
58
+ "BatchSuccess",
59
+ "BatchFailure",
60
+ "RateLimitError",
61
+ "APIError",
62
+ "AnalyticsRequest",
63
+ "AnalyticsResponse",
64
+ "AnalyticsResult",
65
+ "AsyncMorphIQ",
66
+ "DividendEvent",
67
+ "Greeks",
68
+ "GreeksRequest",
69
+ "GreeksResponse",
70
+ "GreeksResult",
71
+ "ImpliedVolatilityRequest",
72
+ "ImpliedVolatilityResponse",
73
+ "ImpliedVolatilityResult",
74
+ "MorphIQ",
75
+ "MorphIQError",
76
+ "PriceAdjustment",
77
+ "PriceBar",
78
+ "PriceHistory",
79
+ "PriceInterval",
80
+ "PriceProvider",
81
+ "PriceRequest",
82
+ "PriceResponse",
83
+ "RegimeRequest",
84
+ "RegimeResponse",
85
+ "RequestTimeout",
86
+ "ResponseError",
87
+ "TransportError",
88
+ "__version__",
89
+ ]
morphiq_sdk/client.py ADDED
@@ -0,0 +1,469 @@
1
+ """Shared JSON API behavior for synchronous and asynchronous HTTPX clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Sequence
8
+ from types import TracebackType
9
+ from typing import Any, TypeVar
10
+
11
+ import httpx
12
+ from pydantic import ValidationError
13
+
14
+ from .errors import APIError, RateLimitError, RequestTimeout, ResponseError, TransportError
15
+ from .models import (
16
+ AnalyticsRequest,
17
+ AnalyticsResponse,
18
+ BatchAnalyticsRequest,
19
+ BatchAnalyticsResponse,
20
+ BatchGreeksRequest,
21
+ BatchGreeksResponse,
22
+ BatchImpliedVolatilityRequest,
23
+ BatchImpliedVolatilityResponse,
24
+ BatchPriceRequest,
25
+ BatchPriceResponse,
26
+ ErrorEnvelope,
27
+ GreeksRequest,
28
+ GreeksResponse,
29
+ ImpliedVolatilityRequest,
30
+ ImpliedVolatilityResponse,
31
+ PriceRequest,
32
+ PriceResponse,
33
+ RegimeRequest,
34
+ RegimeResponse,
35
+ ResponseMetadata,
36
+ _BatchResponse,
37
+ )
38
+ from .workflows import AsyncBatchWorkflow, BatchWorkflow, _prepare_chunks
39
+
40
+ _ResponseType = TypeVar(
41
+ "_ResponseType",
42
+ BatchPriceResponse,
43
+ BatchGreeksResponse,
44
+ BatchImpliedVolatilityResponse,
45
+ BatchAnalyticsResponse,
46
+ PriceResponse,
47
+ RegimeResponse,
48
+ GreeksResponse,
49
+ ImpliedVolatilityResponse,
50
+ AnalyticsResponse,
51
+ )
52
+
53
+
54
+ _BatchResponseType = TypeVar("_BatchResponseType", bound=_BatchResponse[Any])
55
+
56
+
57
+ def _check_batch(response: _BatchResponseType, expected: int) -> _BatchResponseType:
58
+ # Never silently sort, drop or misassociate outcomes with submitted options.
59
+ if [item.index for item in response.results] != list(range(expected)):
60
+ raise ResponseError(
61
+ request_id=response.metadata.request_id,
62
+ body=response.raw_body,
63
+ headers=response.headers,
64
+ metadata=response.metadata,
65
+ )
66
+ return response
67
+
68
+
69
+ def _redact_body(body: bytes, key: str) -> bytes:
70
+ """Handle both literal and JSON-escaped reflections without rewriting clean bodies."""
71
+ try:
72
+ payload = json.loads(body)
73
+ except (ValueError, UnicodeDecodeError):
74
+ return body.replace(key.encode(), b"[REDACTED]")
75
+ changed = False
76
+
77
+ def visit(value: Any) -> Any:
78
+ nonlocal changed
79
+ if isinstance(value, str):
80
+ if key in value:
81
+ changed = True
82
+ return value.replace(key, "[REDACTED]")
83
+ elif isinstance(value, list):
84
+ return [visit(item) for item in value]
85
+ elif isinstance(value, dict):
86
+ return {visit(name): visit(item) for name, item in value.items()}
87
+ return value
88
+
89
+ sanitized = visit(payload)
90
+ return json.dumps(sanitized).encode() if changed else body
91
+
92
+
93
+ class _Configuration:
94
+ def __init__(
95
+ self, *, base_url: str | None, api_key: str | None, allow_unauthenticated: bool
96
+ ) -> None:
97
+ endpoint = (
98
+ base_url
99
+ if base_url is not None
100
+ else os.environ.get("MORPHIQ_API_BASE_URL") or "https://api.morphiqlabs.com"
101
+ )
102
+ try:
103
+ url = httpx.URL(endpoint)
104
+ except httpx.InvalidURL:
105
+ raise ValueError("base_url must be an absolute HTTP(S) URL.") from None
106
+ if (
107
+ url.scheme not in ("http", "https")
108
+ or not url.host
109
+ or url.userinfo
110
+ or url.query
111
+ or url.fragment
112
+ ):
113
+ raise ValueError(
114
+ "base_url must be an HTTP(S) URL without credentials, query or fragment."
115
+ )
116
+ self.url = str(url).rstrip("/") + "/"
117
+ key = api_key if api_key is not None else os.environ.get("MORPHIQ_API_KEY")
118
+ if key is not None and (not key or not key.isascii() or any(c.isspace() for c in key)):
119
+ raise ValueError("API key must be a non-empty ASCII token without whitespace.")
120
+ if not key and not allow_unauthenticated:
121
+ raise ValueError(
122
+ "Pass api_key or set MORPHIQ_API_KEY. Only a controlled open development "
123
+ "service should use allow_unauthenticated=True."
124
+ )
125
+ self._key = key
126
+
127
+ def headers(self) -> dict[str, str]:
128
+ result = {"Accept": "application/json", "User-Agent": "morphiq-sdk-python"}
129
+ if self._key:
130
+ result["Authorization"] = f"Bearer {self._key}"
131
+ return result
132
+
133
+ def decode(self, response: httpx.Response, model: type[_ResponseType]) -> _ResponseType:
134
+ # Retain response evidence, never the request or its Authorization header.
135
+ body = response.content
136
+ headers = dict(response.headers)
137
+ if self._key:
138
+ body = _redact_body(body, self._key)
139
+ headers = {
140
+ key: value.replace(self._key, "[REDACTED]") for key, value in headers.items()
141
+ }
142
+ request_id = headers.get("x-request-id")
143
+ if response.status_code != 200:
144
+ try:
145
+ envelope = ErrorEnvelope.model_validate_json(body)
146
+ except ValidationError:
147
+ envelope = None
148
+ error_type = RateLimitError if response.status_code == 429 else APIError
149
+ raise error_type(
150
+ status_code=response.status_code,
151
+ envelope=envelope,
152
+ request_id=request_id,
153
+ body=body,
154
+ headers=headers,
155
+ )
156
+ try:
157
+ result = model.model_validate_json(body)
158
+ except ValidationError:
159
+ # A malformed numerical result does not erase valid billing evidence.
160
+ metadata = None
161
+ try:
162
+ payload = json.loads(body)
163
+ if isinstance(payload, dict):
164
+ metadata = ResponseMetadata.model_validate(payload.get("metadata"))
165
+ except (ValueError, UnicodeDecodeError):
166
+ pass
167
+ raise ResponseError(
168
+ request_id=request_id, body=body, headers=headers, metadata=metadata
169
+ ) from None
170
+ result._raw_body = body
171
+ result._headers = headers
172
+ return result
173
+
174
+
175
+ class MorphIQ:
176
+ """Synchronous API client. No retries or redirects are performed.
177
+
178
+ The API URL defaults to https://api.morphiqlabs.com; override it with
179
+ ``base_url`` or MORPHIQ_API_BASE_URL. ``api_key`` falls back to MORPHIQ_API_KEY.
180
+ A numeric timeout applies to connect/read/write/pool waits;
181
+ pass ``httpx.Timeout`` to tune them separately. Use a context manager or close().
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ *,
187
+ base_url: str | None = None,
188
+ api_key: str | None = None,
189
+ timeout: float | httpx.Timeout = 30.0,
190
+ allow_unauthenticated: bool = False,
191
+ transport: httpx.BaseTransport | None = None,
192
+ ) -> None:
193
+ self._config = _Configuration(
194
+ base_url=base_url, api_key=api_key, allow_unauthenticated=allow_unauthenticated
195
+ )
196
+ self._http = httpx.Client(
197
+ base_url=self._config.url,
198
+ headers=self._config.headers(),
199
+ timeout=timeout,
200
+ transport=transport,
201
+ follow_redirects=False,
202
+ trust_env=False,
203
+ )
204
+
205
+ def price(self, request: PriceRequest) -> PriceResponse:
206
+ """Price one option; preserve server model selection and charge evidence."""
207
+ return self._post("v1/price", request.to_wire(), PriceResponse)
208
+
209
+ def regime(self, request: RegimeRequest) -> RegimeResponse:
210
+ """Identify regime heads; preserve scores, applicability and provenance."""
211
+ return self._post("v1/regime", request.to_wire(), RegimeResponse)
212
+
213
+ def greeks(self, request: GreeksRequest) -> GreeksResponse:
214
+ """Return the option price and all ten Greeks, preserving the API's units."""
215
+ return self._post("v1/greeks", request.to_wire(), GreeksResponse)
216
+
217
+ def implied_volatility(self, request: ImpliedVolatilityRequest) -> ImpliedVolatilityResponse:
218
+ """Solve IV from an option premium; preserve residual basis and charge evidence."""
219
+ return self._post("v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse)
220
+
221
+ def analytics(self, request: AnalyticsRequest) -> AnalyticsResponse:
222
+ """Return combined analytics and one server-reported commercial charge."""
223
+ return self._post("v1/analytics", request.to_wire(), AnalyticsResponse)
224
+
225
+ def price_batch(self, request: BatchPriceRequest) -> BatchPriceResponse:
226
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
227
+ payload = request.to_wire()
228
+ response = self._post("v1/price:batch", payload, BatchPriceResponse)
229
+ return _check_batch(response, len(payload["requests"]))
230
+
231
+ def greeks_batch(self, request: BatchGreeksRequest) -> BatchGreeksResponse:
232
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
233
+ payload = request.to_wire()
234
+ response = self._post("v1/greeks:batch", payload, BatchGreeksResponse)
235
+ return _check_batch(response, len(payload["requests"]))
236
+
237
+ def implied_volatility_batch(
238
+ self, request: BatchImpliedVolatilityRequest
239
+ ) -> BatchImpliedVolatilityResponse:
240
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
241
+ payload = request.to_wire()
242
+ response = self._post(
243
+ "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse
244
+ )
245
+ return _check_batch(response, len(payload["requests"]))
246
+
247
+ def analytics_batch(self, request: BatchAnalyticsRequest) -> BatchAnalyticsResponse:
248
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
249
+ payload = request.to_wire()
250
+ response = self._post("v1/analytics:batch", payload, BatchAnalyticsResponse)
251
+ return _check_batch(response, len(payload["requests"]))
252
+
253
+ def price_workflow(
254
+ self, requests: Sequence[PriceRequest], *, chunk_size: int
255
+ ) -> BatchWorkflow[BatchPriceRequest, BatchPriceResponse]:
256
+ """Prepare explicit sequential chunks without sending requests; call run() once."""
257
+ return BatchWorkflow(
258
+ _prepare_chunks(requests, BatchPriceRequest, chunk_size), self.price_batch
259
+ )
260
+
261
+ def greeks_workflow(
262
+ self, requests: Sequence[GreeksRequest], *, chunk_size: int
263
+ ) -> BatchWorkflow[BatchGreeksRequest, BatchGreeksResponse]:
264
+ """Prepare explicit sequential chunks without sending requests; call run() once."""
265
+ return BatchWorkflow(
266
+ _prepare_chunks(requests, BatchGreeksRequest, chunk_size), self.greeks_batch
267
+ )
268
+
269
+ def implied_volatility_workflow(
270
+ self, requests: Sequence[ImpliedVolatilityRequest], *, chunk_size: int
271
+ ) -> BatchWorkflow[BatchImpliedVolatilityRequest, BatchImpliedVolatilityResponse]:
272
+ """Prepare explicit sequential chunks without sending requests; call run() once."""
273
+ return BatchWorkflow(
274
+ _prepare_chunks(requests, BatchImpliedVolatilityRequest, chunk_size),
275
+ self.implied_volatility_batch,
276
+ )
277
+
278
+ def analytics_workflow(
279
+ self, requests: Sequence[AnalyticsRequest], *, chunk_size: int
280
+ ) -> BatchWorkflow[BatchAnalyticsRequest, BatchAnalyticsResponse]:
281
+ """Prepare explicit sequential chunks without sending requests; call run() once."""
282
+ return BatchWorkflow(
283
+ _prepare_chunks(requests, BatchAnalyticsRequest, chunk_size), self.analytics_batch
284
+ )
285
+
286
+ def _post(
287
+ self, path: str, payload: dict[str, Any], model: type[_ResponseType]
288
+ ) -> _ResponseType:
289
+ try:
290
+ response = self._http.post(path, json=payload)
291
+ except httpx.TimeoutException:
292
+ raise RequestTimeout(
293
+ "The request timed out; execution and CU charges may be unknown. "
294
+ "No retry was attempted."
295
+ ) from None
296
+ except httpx.RequestError:
297
+ raise TransportError(
298
+ "The request failed before a complete response was received. "
299
+ "Execution and CU charges may be unknown; no retry was attempted."
300
+ ) from None
301
+ return self._config.decode(response, model)
302
+
303
+ def close(self) -> None:
304
+ self._http.close()
305
+
306
+ def __enter__(self) -> MorphIQ:
307
+ self._http.__enter__()
308
+ return self
309
+
310
+ def __exit__(
311
+ self,
312
+ exc_type: type[BaseException] | None,
313
+ exc_value: BaseException | None,
314
+ traceback: TracebackType | None,
315
+ ) -> None:
316
+ self.close()
317
+
318
+
319
+ class AsyncMorphIQ:
320
+ """Async equivalent of MorphIQ; use async with and await client.price(...)."""
321
+
322
+ def __init__(
323
+ self,
324
+ *,
325
+ base_url: str | None = None,
326
+ api_key: str | None = None,
327
+ timeout: float | httpx.Timeout = 30.0,
328
+ allow_unauthenticated: bool = False,
329
+ transport: httpx.AsyncBaseTransport | None = None,
330
+ ) -> None:
331
+ self._config = _Configuration(
332
+ base_url=base_url, api_key=api_key, allow_unauthenticated=allow_unauthenticated
333
+ )
334
+ self._http = httpx.AsyncClient(
335
+ base_url=self._config.url,
336
+ headers=self._config.headers(),
337
+ timeout=timeout,
338
+ transport=transport,
339
+ follow_redirects=False,
340
+ trust_env=False,
341
+ )
342
+
343
+ async def price(self, request: PriceRequest) -> PriceResponse:
344
+ """Price one option; preserve server model selection and charge evidence."""
345
+ return await self._post("v1/price", request.to_wire(), PriceResponse)
346
+
347
+ async def regime(self, request: RegimeRequest) -> RegimeResponse:
348
+ """Identify regime heads; preserve scores, applicability and provenance."""
349
+ return await self._post("v1/regime", request.to_wire(), RegimeResponse)
350
+
351
+ async def greeks(self, request: GreeksRequest) -> GreeksResponse:
352
+ """Return the option price and all ten Greeks, preserving the API's units."""
353
+ return await self._post("v1/greeks", request.to_wire(), GreeksResponse)
354
+
355
+ async def implied_volatility(
356
+ self, request: ImpliedVolatilityRequest
357
+ ) -> ImpliedVolatilityResponse:
358
+ """Solve IV from an option premium; preserve residual basis and charge evidence."""
359
+ return await self._post(
360
+ "v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse
361
+ )
362
+
363
+ async def analytics(self, request: AnalyticsRequest) -> AnalyticsResponse:
364
+ """Return combined analytics and one server-reported commercial charge."""
365
+ return await self._post("v1/analytics", request.to_wire(), AnalyticsResponse)
366
+
367
+ async def price_batch(self, request: BatchPriceRequest) -> BatchPriceResponse:
368
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
369
+ payload = request.to_wire()
370
+ response = await self._post("v1/price:batch", payload, BatchPriceResponse)
371
+ return _check_batch(response, len(payload["requests"]))
372
+
373
+ async def greeks_batch(self, request: BatchGreeksRequest) -> BatchGreeksResponse:
374
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
375
+ payload = request.to_wire()
376
+ response = await self._post("v1/greeks:batch", payload, BatchGreeksResponse)
377
+ return _check_batch(response, len(payload["requests"]))
378
+
379
+ async def implied_volatility_batch(
380
+ self, request: BatchImpliedVolatilityRequest
381
+ ) -> BatchImpliedVolatilityResponse:
382
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
383
+ payload = request.to_wire()
384
+ response = await self._post(
385
+ "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse
386
+ )
387
+ return _check_batch(response, len(payload["requests"]))
388
+
389
+ async def analytics_batch(self, request: BatchAnalyticsRequest) -> BatchAnalyticsResponse:
390
+ """Submit one batch; preserve ordered outcomes and count its charge once."""
391
+ payload = request.to_wire()
392
+ response = await self._post("v1/analytics:batch", payload, BatchAnalyticsResponse)
393
+ return _check_batch(response, len(payload["requests"]))
394
+
395
+ def price_workflow(
396
+ self, requests: Sequence[PriceRequest], *, chunk_size: int, max_concurrency: int = 1
397
+ ) -> AsyncBatchWorkflow[BatchPriceRequest, BatchPriceResponse]:
398
+ """Prepare chunks without HTTP; max_concurrency explicitly bounds async execution."""
399
+ return AsyncBatchWorkflow(
400
+ _prepare_chunks(requests, BatchPriceRequest, chunk_size),
401
+ self.price_batch,
402
+ max_concurrency=max_concurrency,
403
+ )
404
+
405
+ def greeks_workflow(
406
+ self, requests: Sequence[GreeksRequest], *, chunk_size: int, max_concurrency: int = 1
407
+ ) -> AsyncBatchWorkflow[BatchGreeksRequest, BatchGreeksResponse]:
408
+ """Prepare chunks without HTTP; max_concurrency explicitly bounds async execution."""
409
+ return AsyncBatchWorkflow(
410
+ _prepare_chunks(requests, BatchGreeksRequest, chunk_size),
411
+ self.greeks_batch,
412
+ max_concurrency=max_concurrency,
413
+ )
414
+
415
+ def implied_volatility_workflow(
416
+ self,
417
+ requests: Sequence[ImpliedVolatilityRequest],
418
+ *,
419
+ chunk_size: int,
420
+ max_concurrency: int = 1,
421
+ ) -> AsyncBatchWorkflow[BatchImpliedVolatilityRequest, BatchImpliedVolatilityResponse]:
422
+ """Prepare chunks without HTTP; max_concurrency explicitly bounds async execution."""
423
+ return AsyncBatchWorkflow(
424
+ _prepare_chunks(requests, BatchImpliedVolatilityRequest, chunk_size),
425
+ self.implied_volatility_batch,
426
+ max_concurrency=max_concurrency,
427
+ )
428
+
429
+ def analytics_workflow(
430
+ self, requests: Sequence[AnalyticsRequest], *, chunk_size: int, max_concurrency: int = 1
431
+ ) -> AsyncBatchWorkflow[BatchAnalyticsRequest, BatchAnalyticsResponse]:
432
+ """Prepare chunks without HTTP; max_concurrency explicitly bounds async execution."""
433
+ return AsyncBatchWorkflow(
434
+ _prepare_chunks(requests, BatchAnalyticsRequest, chunk_size),
435
+ self.analytics_batch,
436
+ max_concurrency=max_concurrency,
437
+ )
438
+
439
+ async def _post(
440
+ self, path: str, payload: dict[str, Any], model: type[_ResponseType]
441
+ ) -> _ResponseType:
442
+ try:
443
+ response = await self._http.post(path, json=payload)
444
+ except httpx.TimeoutException:
445
+ raise RequestTimeout(
446
+ "The request timed out; execution and CU charges may be unknown. "
447
+ "No retry was attempted."
448
+ ) from None
449
+ except httpx.RequestError:
450
+ raise TransportError(
451
+ "The request failed before a complete response was received. "
452
+ "Execution and CU charges may be unknown; no retry was attempted."
453
+ ) from None
454
+ return self._config.decode(response, model)
455
+
456
+ async def aclose(self) -> None:
457
+ await self._http.aclose()
458
+
459
+ async def __aenter__(self) -> AsyncMorphIQ:
460
+ await self._http.__aenter__()
461
+ return self
462
+
463
+ async def __aexit__(
464
+ self,
465
+ exc_type: type[BaseException] | None,
466
+ exc_value: BaseException | None,
467
+ traceback: TracebackType | None,
468
+ ) -> None:
469
+ await self.aclose()
morphiq_sdk/errors.py ADDED
@@ -0,0 +1,102 @@
1
+ """Errors without retained HTTP requests or credential-bearing trace messages."""
2
+
3
+ from datetime import UTC, datetime
4
+ from email.utils import parsedate_to_datetime
5
+
6
+ from .models import ErrorEnvelope, ResponseMetadata, ServiceErrorMetadata
7
+
8
+
9
+ class MorphIQError(Exception):
10
+ """Base class for request/response failures from the SDK."""
11
+
12
+
13
+ class TransportError(MorphIQError):
14
+ """No complete response was received. Execution and charges may be unknown."""
15
+
16
+
17
+ class RequestTimeout(TransportError):
18
+ """A client timeout does not establish that the server did no work."""
19
+
20
+
21
+ class ResponseError(MorphIQError):
22
+ """A successful HTTP status carried an invalid API response.
23
+
24
+ Independently valid pricing metadata remains available even when the result
25
+ is malformed. Missing metadata does not establish a zero charge.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ request_id: str | None,
32
+ body: bytes,
33
+ headers: dict[str, str],
34
+ metadata: ResponseMetadata | None = None,
35
+ ) -> None:
36
+ self.metadata = metadata
37
+ self.request_id = request_id or (metadata.request_id if metadata else None)
38
+ self.body = body
39
+ self.headers = headers
40
+ super().__init__("The API returned an invalid API response; no retry was attempted.")
41
+
42
+
43
+ class APIError(MorphIQError):
44
+ """Non-success HTTP response, including gateway and router failures.
45
+
46
+ Inspect ``code``, ``request_id``, ``metadata``, ``headers`` and ``body``.
47
+ The known API key is redacted if a remote server reflects it in a response.
48
+ Missing charge metadata is never interpreted as a zero charge.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ status_code: int,
55
+ envelope: ErrorEnvelope | None,
56
+ request_id: str | None,
57
+ body: bytes,
58
+ headers: dict[str, str],
59
+ ) -> None:
60
+ self.status_code = status_code
61
+ self.code = envelope.error.code if envelope else None
62
+ self.message = (
63
+ envelope.error.message if envelope else "Non-standard API or gateway response"
64
+ )
65
+ self.field = envelope.error.field if envelope else None
66
+ self.metadata: ResponseMetadata | ServiceErrorMetadata | None = (
67
+ envelope.metadata if envelope else None
68
+ )
69
+ self.request_id = self.metadata.request_id if self.metadata else request_id
70
+ self.body = body
71
+ self.headers = headers
72
+ self.retry_after = headers.get("retry-after")
73
+ detail = f" ({self.code})" if self.code else ""
74
+ support = f"; request_id={self.request_id}" if self.request_id else ""
75
+ super().__init__(f"HTTP {status_code}{detail}: {self.message}{support}")
76
+
77
+
78
+ class RateLimitError(APIError):
79
+ """HTTP 429, including gateway responses. No automatic wait, split or retry.
80
+
81
+ ``retry_after`` retains the raw server header; ``retry_delay`` interprets
82
+ delta-seconds or an HTTP date for callers choosing when to try again.
83
+ Absence or an invalid value returns None, never an invented zero delay.
84
+ """
85
+
86
+ def retry_delay(self, *, now: datetime | None = None) -> float | None:
87
+ value = self.retry_after
88
+ if value is None:
89
+ return None
90
+ value = value.strip()
91
+ try:
92
+ if value.isascii() and value.isdigit():
93
+ return float(int(value))
94
+ retry_at = parsedate_to_datetime(value)
95
+ if retry_at.tzinfo is None:
96
+ retry_at = retry_at.replace(tzinfo=UTC)
97
+ current = now if now is not None else datetime.now(UTC)
98
+ if current.tzinfo is None:
99
+ raise ValueError("now must include a timezone")
100
+ return max(0.0, (retry_at - current).total_seconds())
101
+ except (ValueError, TypeError, OverflowError):
102
+ return None