modulex-python 0.1.0__py3-none-any.whl → 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.
- modulex/__init__.py +12 -2
- modulex/_base.py +111 -16
- modulex/_client.py +25 -13
- modulex/_exceptions.py +172 -8
- modulex/_streaming.py +90 -60
- modulex/_version.py +5 -0
- modulex/resources/api_keys.py +16 -8
- modulex/resources/assistant.py +154 -0
- modulex/resources/auth.py +24 -12
- modulex/resources/chats.py +31 -14
- modulex/resources/composer.py +160 -53
- modulex/resources/credentials.py +144 -38
- modulex/resources/dashboard.py +36 -19
- modulex/resources/deployments.py +46 -26
- modulex/resources/executions.py +132 -22
- modulex/resources/integrations.py +36 -18
- modulex/resources/knowledge.py +104 -62
- modulex/resources/notifications.py +10 -4
- modulex/resources/organizations.py +122 -28
- modulex/resources/schedules.py +63 -32
- modulex/resources/subscriptions.py +32 -14
- modulex/resources/system.py +13 -11
- modulex/resources/workflows.py +53 -27
- modulex/types/__init__.py +347 -83
- modulex/types/_models.py +79 -0
- modulex/types/api_keys.py +59 -12
- modulex/types/assistant.py +104 -0
- modulex/types/auth.py +88 -43
- modulex/types/chats.py +65 -37
- modulex/types/composer.py +143 -18
- modulex/types/credentials.py +95 -54
- modulex/types/dashboard.py +247 -46
- modulex/types/deployments.py +126 -0
- modulex/types/executions.py +132 -101
- modulex/types/integrations.py +113 -21
- modulex/types/knowledge.py +143 -50
- modulex/types/notifications.py +70 -9
- modulex/types/organizations.py +131 -27
- modulex/types/realtime.py +270 -0
- modulex/types/schedules.py +88 -35
- modulex/types/subscriptions.py +91 -36
- modulex/types/system.py +62 -0
- modulex/types/workflows.py +276 -168
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/METADATA +90 -51
- modulex_python-1.0.0.dist-info/RECORD +51 -0
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/WHEEL +1 -1
- modulex/_compat.py +0 -39
- modulex/resources/templates.py +0 -115
- modulex/types/templates.py +0 -50
- modulex_python-0.1.0.dist-info/RECORD +0 -47
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
modulex/__init__.py
CHANGED
|
@@ -4,19 +4,25 @@ from modulex._client import Modulex
|
|
|
4
4
|
from modulex._exceptions import (
|
|
5
5
|
AuthenticationError,
|
|
6
6
|
BadRequestError,
|
|
7
|
+
BillingError,
|
|
7
8
|
ConflictError,
|
|
9
|
+
CreditExhaustedError,
|
|
8
10
|
ExternalServiceError,
|
|
9
11
|
InternalError,
|
|
10
12
|
ModulexError,
|
|
11
13
|
NotFoundError,
|
|
14
|
+
PaymentRequiredError,
|
|
12
15
|
PermissionError,
|
|
16
|
+
QuotaExceededError,
|
|
13
17
|
RateLimitError,
|
|
14
18
|
ServiceUnavailableError,
|
|
15
19
|
StreamError,
|
|
16
20
|
TimeoutError,
|
|
17
21
|
ValidationError,
|
|
22
|
+
WalletError,
|
|
18
23
|
)
|
|
19
24
|
from modulex._streaming import SSEEvent
|
|
25
|
+
from modulex._version import __version__
|
|
20
26
|
|
|
21
27
|
__all__ = [
|
|
22
28
|
"Modulex",
|
|
@@ -33,7 +39,11 @@ __all__ = [
|
|
|
33
39
|
"ServiceUnavailableError",
|
|
34
40
|
"StreamError",
|
|
35
41
|
"TimeoutError",
|
|
42
|
+
"BillingError",
|
|
43
|
+
"PaymentRequiredError",
|
|
44
|
+
"QuotaExceededError",
|
|
45
|
+
"CreditExhaustedError",
|
|
46
|
+
"WalletError",
|
|
36
47
|
"SSEEvent",
|
|
48
|
+
"__version__",
|
|
37
49
|
]
|
|
38
|
-
|
|
39
|
-
__version__ = "0.1.0"
|
modulex/_base.py
CHANGED
|
@@ -14,6 +14,7 @@ from modulex._exceptions import (
|
|
|
14
14
|
raise_for_status,
|
|
15
15
|
)
|
|
16
16
|
from modulex._streaming import EventSourceStream
|
|
17
|
+
from modulex._version import __version__
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from modulex._client import Modulex
|
|
@@ -31,15 +32,29 @@ class _BaseResource:
|
|
|
31
32
|
return organization_id
|
|
32
33
|
return self._client._config.organization_id
|
|
33
34
|
|
|
34
|
-
def _build_headers(
|
|
35
|
-
|
|
35
|
+
def _build_headers(
|
|
36
|
+
self,
|
|
37
|
+
organization_id: str | None = None,
|
|
38
|
+
idempotency_key: str | None = None,
|
|
39
|
+
) -> dict[str, str]:
|
|
40
|
+
"""Build request headers with auth and optional org context.
|
|
41
|
+
|
|
42
|
+
User-supplied ``default_headers`` may override the User-Agent but never
|
|
43
|
+
the auth or content-type headers. ``idempotency_key`` (for mutating
|
|
44
|
+
requests) is sent as the ``Idempotency-Key`` header so the backend can
|
|
45
|
+
de-duplicate retried side-effectful operations.
|
|
46
|
+
"""
|
|
36
47
|
headers: dict[str, str] = {
|
|
48
|
+
"User-Agent": f"modulex-python/{__version__}",
|
|
49
|
+
**self._client._config.default_headers,
|
|
37
50
|
"Authorization": f"Bearer {self._client._config.api_key}",
|
|
38
51
|
"Content-Type": "application/json",
|
|
39
52
|
}
|
|
40
53
|
org_id = self._resolve_org_id(organization_id)
|
|
41
54
|
if org_id:
|
|
42
55
|
headers["X-Organization-ID"] = org_id
|
|
56
|
+
if idempotency_key:
|
|
57
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
43
58
|
return headers
|
|
44
59
|
|
|
45
60
|
def _should_retry(self, method: str, status_code: int, attempt: int) -> bool:
|
|
@@ -69,11 +84,12 @@ class _BaseResource:
|
|
|
69
84
|
params: dict[str, Any] | None = None,
|
|
70
85
|
json: dict[str, Any] | None = None,
|
|
71
86
|
organization_id: str | None = None,
|
|
87
|
+
idempotency_key: str | None = None,
|
|
72
88
|
**kwargs: Any,
|
|
73
89
|
) -> Any:
|
|
74
90
|
"""Execute an HTTP request with retry logic."""
|
|
75
91
|
url = f"{self._client._config.base_url}{path}"
|
|
76
|
-
headers = self._build_headers(organization_id)
|
|
92
|
+
headers = self._build_headers(organization_id, idempotency_key)
|
|
77
93
|
|
|
78
94
|
# Filter None values from params
|
|
79
95
|
if params:
|
|
@@ -191,17 +207,27 @@ class _BaseResource:
|
|
|
191
207
|
*,
|
|
192
208
|
method: str = "GET",
|
|
193
209
|
organization_id: str | None = None,
|
|
210
|
+
json: dict[str, Any] | None = None,
|
|
211
|
+
include_heartbeats: bool = False,
|
|
194
212
|
**kwargs: Any,
|
|
195
213
|
) -> EventSourceStream:
|
|
196
|
-
"""Create an SSE stream connection.
|
|
214
|
+
"""Create an SSE stream connection.
|
|
215
|
+
|
|
216
|
+
For POST streams (e.g. credentials bulk), pass ``json=`` — the JSON
|
|
217
|
+
Content-Type is kept; for GET streams it is dropped.
|
|
218
|
+
"""
|
|
197
219
|
url = f"{self._client._config.base_url}{path}"
|
|
198
220
|
headers = self._build_headers(organization_id)
|
|
199
|
-
|
|
221
|
+
if json is not None:
|
|
222
|
+
kwargs["json"] = json
|
|
223
|
+
else:
|
|
224
|
+
headers.pop("Content-Type", None)
|
|
200
225
|
return EventSourceStream(
|
|
201
226
|
self._client._http,
|
|
202
227
|
method,
|
|
203
228
|
url,
|
|
204
229
|
headers=headers,
|
|
230
|
+
include_heartbeats=include_heartbeats,
|
|
205
231
|
timeout=httpx.Timeout(self._client._config.timeout, read=None),
|
|
206
232
|
**kwargs,
|
|
207
233
|
)
|
|
@@ -237,6 +263,20 @@ class _BaseResource:
|
|
|
237
263
|
|
|
238
264
|
return response.json()
|
|
239
265
|
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _unwrap_page(result: Any, items_key: str) -> tuple[list[Any], dict[str, Any]]:
|
|
268
|
+
"""Return (items, container) handling a nested ``data.<items_key>`` envelope.
|
|
269
|
+
|
|
270
|
+
e.g. dashboard/logs returns ``{success, data: {logs, total_count, has_next}}``.
|
|
271
|
+
"""
|
|
272
|
+
if not isinstance(result, dict):
|
|
273
|
+
return [], {}
|
|
274
|
+
container = result
|
|
275
|
+
if items_key not in result and isinstance(result.get("data"), dict):
|
|
276
|
+
container = result["data"]
|
|
277
|
+
items = container.get(items_key, [])
|
|
278
|
+
return (items if isinstance(items, list) else []), container
|
|
279
|
+
|
|
240
280
|
async def _paginate(
|
|
241
281
|
self,
|
|
242
282
|
path: str,
|
|
@@ -245,37 +285,92 @@ class _BaseResource:
|
|
|
245
285
|
params: dict[str, Any] | None = None,
|
|
246
286
|
organization_id: str | None = None,
|
|
247
287
|
page_size: int = 20,
|
|
288
|
+
style: str | None = None,
|
|
289
|
+
total_key: str | None = None,
|
|
248
290
|
**kwargs: Any,
|
|
249
291
|
) -> AsyncIterator[dict[str, Any]]:
|
|
250
|
-
"""Auto-paginate
|
|
292
|
+
"""Auto-paginate a list endpoint across the backend's three styles.
|
|
293
|
+
|
|
294
|
+
Styles (auto-detected from ``params`` unless ``style`` is given):
|
|
295
|
+
- ``"page"`` — page / page_size, terminates on total_pages | has_more | short page
|
|
296
|
+
- ``"offset"`` — limit / offset, terminates on has_next | has_more | total/total_count | short page
|
|
297
|
+
- ``"cursor"`` — cursor / next_cursor (e.g. assistant & composer chat lists)
|
|
298
|
+
"""
|
|
251
299
|
params = dict(params or {})
|
|
252
300
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
301
|
+
if style is None:
|
|
302
|
+
if "cursor" in params:
|
|
303
|
+
style = "cursor"
|
|
304
|
+
elif "page" in params or "page_size" in params:
|
|
305
|
+
style = "page"
|
|
306
|
+
else:
|
|
307
|
+
style = "offset"
|
|
308
|
+
|
|
309
|
+
if style == "cursor":
|
|
310
|
+
cursor = params.pop("cursor", None)
|
|
311
|
+
while True:
|
|
312
|
+
if cursor is not None:
|
|
313
|
+
params["cursor"] = cursor
|
|
314
|
+
result = await self._get(path, params=params, organization_id=organization_id, **kwargs)
|
|
315
|
+
items, container = self._unwrap_page(result, items_key)
|
|
316
|
+
for item in items:
|
|
317
|
+
yield item
|
|
318
|
+
cursor = container.get("next_cursor")
|
|
319
|
+
if not cursor or not items:
|
|
320
|
+
break
|
|
321
|
+
|
|
322
|
+
elif style == "page":
|
|
256
323
|
page = params.pop("page", 1)
|
|
257
324
|
params["page_size"] = params.pop("page_size", page_size)
|
|
258
325
|
while True:
|
|
259
326
|
params["page"] = page
|
|
260
327
|
result = await self._get(path, params=params, organization_id=organization_id, **kwargs)
|
|
261
|
-
items =
|
|
328
|
+
items, container = self._unwrap_page(result, items_key)
|
|
262
329
|
for item in items:
|
|
263
330
|
yield item
|
|
264
|
-
total_pages =
|
|
265
|
-
|
|
331
|
+
total_pages = container.get("total_pages")
|
|
332
|
+
has_more = container.get("has_more")
|
|
333
|
+
if total_pages is not None:
|
|
334
|
+
if page >= total_pages:
|
|
335
|
+
break
|
|
336
|
+
elif has_more is not None:
|
|
337
|
+
if not has_more:
|
|
338
|
+
break
|
|
339
|
+
elif len(items) < params["page_size"] or not items:
|
|
266
340
|
break
|
|
267
341
|
page += 1
|
|
268
|
-
|
|
269
|
-
|
|
342
|
+
|
|
343
|
+
else: # offset
|
|
270
344
|
offset = params.pop("offset", 0)
|
|
271
345
|
limit = params.pop("limit", page_size)
|
|
272
346
|
params["limit"] = limit
|
|
273
347
|
while True:
|
|
274
348
|
params["offset"] = offset
|
|
275
349
|
result = await self._get(path, params=params, organization_id=organization_id, **kwargs)
|
|
276
|
-
items =
|
|
350
|
+
items, container = self._unwrap_page(result, items_key)
|
|
277
351
|
for item in items:
|
|
278
352
|
yield item
|
|
279
|
-
if not
|
|
353
|
+
if not items:
|
|
280
354
|
break
|
|
355
|
+
if container.get("has_next") is not None:
|
|
356
|
+
if not container["has_next"]:
|
|
357
|
+
break
|
|
358
|
+
elif container.get("has_more") is not None:
|
|
359
|
+
if not container["has_more"]:
|
|
360
|
+
break
|
|
361
|
+
else:
|
|
362
|
+
total = (
|
|
363
|
+
container.get(total_key)
|
|
364
|
+
if total_key
|
|
365
|
+
else (
|
|
366
|
+
container.get("total")
|
|
367
|
+
if container.get("total") is not None
|
|
368
|
+
else container.get("total_count")
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
if total is not None:
|
|
372
|
+
if offset + len(items) >= total:
|
|
373
|
+
break
|
|
374
|
+
elif len(items) < limit:
|
|
375
|
+
break
|
|
281
376
|
offset += limit
|
modulex/_client.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -10,6 +11,7 @@ from modulex._config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEO
|
|
|
10
11
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
12
13
|
from modulex.resources.api_keys import ApiKeys
|
|
14
|
+
from modulex.resources.assistant import Assistant
|
|
13
15
|
from modulex.resources.auth import Auth
|
|
14
16
|
from modulex.resources.chats import Chats
|
|
15
17
|
from modulex.resources.composer import Composer
|
|
@@ -24,13 +26,15 @@ if TYPE_CHECKING:
|
|
|
24
26
|
from modulex.resources.schedules import Schedules
|
|
25
27
|
from modulex.resources.subscriptions import Subscriptions
|
|
26
28
|
from modulex.resources.system import System
|
|
27
|
-
from modulex.resources.templates import Templates
|
|
28
29
|
from modulex.resources.workflows import Workflows
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class Modulex:
|
|
32
33
|
"""Async client for the ModuleX API.
|
|
33
34
|
|
|
35
|
+
Configuration falls back to environment variables when arguments are omitted:
|
|
36
|
+
``MODULEX_API_KEY``, ``MODULEX_BASE_URL``, ``MODULEX_ORGANIZATION_ID``.
|
|
37
|
+
|
|
34
38
|
Usage:
|
|
35
39
|
async with Modulex(api_key="mx_live_...") as client:
|
|
36
40
|
me = await client.auth.me()
|
|
@@ -38,19 +42,27 @@ class Modulex:
|
|
|
38
42
|
|
|
39
43
|
def __init__(
|
|
40
44
|
self,
|
|
41
|
-
api_key: str,
|
|
45
|
+
api_key: str | None = None,
|
|
42
46
|
*,
|
|
43
47
|
organization_id: str | None = None,
|
|
44
|
-
base_url: str =
|
|
48
|
+
base_url: str | None = None,
|
|
45
49
|
timeout: float = DEFAULT_TIMEOUT,
|
|
46
50
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
51
|
+
default_headers: dict[str, str] | None = None,
|
|
47
52
|
) -> None:
|
|
53
|
+
api_key = api_key or os.environ.get("MODULEX_API_KEY")
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise ValueError("api_key is required: pass api_key=... or set the MODULEX_API_KEY environment variable")
|
|
56
|
+
base_url = base_url or os.environ.get("MODULEX_BASE_URL") or DEFAULT_BASE_URL
|
|
57
|
+
organization_id = organization_id or os.environ.get("MODULEX_ORGANIZATION_ID")
|
|
58
|
+
|
|
48
59
|
self._config = ClientConfig(
|
|
49
60
|
api_key=api_key,
|
|
50
61
|
organization_id=organization_id,
|
|
51
62
|
base_url=base_url,
|
|
52
63
|
timeout=timeout,
|
|
53
64
|
max_retries=max_retries,
|
|
65
|
+
default_headers=default_headers or {},
|
|
54
66
|
)
|
|
55
67
|
self._http = httpx.AsyncClient()
|
|
56
68
|
|
|
@@ -64,8 +76,8 @@ class Modulex:
|
|
|
64
76
|
self._integrations: object | None = None
|
|
65
77
|
self._knowledge: object | None = None
|
|
66
78
|
self._schedules: object | None = None
|
|
67
|
-
self._templates: object | None = None
|
|
68
79
|
self._composer: object | None = None
|
|
80
|
+
self._assistant: object | None = None
|
|
69
81
|
self._dashboard: object | None = None
|
|
70
82
|
self._subscriptions: object | None = None
|
|
71
83
|
self._notifications: object | None = None
|
|
@@ -164,15 +176,6 @@ class Modulex:
|
|
|
164
176
|
self._schedules = Schedules(self)
|
|
165
177
|
return self._schedules # type: ignore[return-value]
|
|
166
178
|
|
|
167
|
-
@property
|
|
168
|
-
def templates(self) -> Templates:
|
|
169
|
-
"""Access template endpoints."""
|
|
170
|
-
if self._templates is None:
|
|
171
|
-
from modulex.resources.templates import Templates
|
|
172
|
-
|
|
173
|
-
self._templates = Templates(self)
|
|
174
|
-
return self._templates # type: ignore[return-value]
|
|
175
|
-
|
|
176
179
|
@property
|
|
177
180
|
def composer(self) -> Composer:
|
|
178
181
|
"""Access composer endpoints."""
|
|
@@ -182,6 +185,15 @@ class Modulex:
|
|
|
182
185
|
self._composer = Composer(self)
|
|
183
186
|
return self._composer # type: ignore[return-value]
|
|
184
187
|
|
|
188
|
+
@property
|
|
189
|
+
def assistant(self) -> Assistant:
|
|
190
|
+
"""Access assistant (agentic chat) endpoints."""
|
|
191
|
+
if self._assistant is None:
|
|
192
|
+
from modulex.resources.assistant import Assistant
|
|
193
|
+
|
|
194
|
+
self._assistant = Assistant(self)
|
|
195
|
+
return self._assistant # type: ignore[return-value]
|
|
196
|
+
|
|
185
197
|
@property
|
|
186
198
|
def dashboard(self) -> Dashboard:
|
|
187
199
|
"""Access dashboard endpoints."""
|
modulex/_exceptions.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
@@ -50,7 +51,11 @@ class ConflictError(ModulexError):
|
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
class RateLimitError(ModulexError):
|
|
53
|
-
"""Raised when rate limited (429).
|
|
54
|
+
"""Raised when rate limited (429).
|
|
55
|
+
|
|
56
|
+
Carries the rate-limit headers the backend sends (``X-RateLimit-*`` and
|
|
57
|
+
``Retry-After``) so callers can back off intelligently.
|
|
58
|
+
"""
|
|
54
59
|
|
|
55
60
|
def __init__(
|
|
56
61
|
self,
|
|
@@ -60,9 +65,15 @@ class RateLimitError(ModulexError):
|
|
|
60
65
|
response: httpx.Response | None = None,
|
|
61
66
|
body: Any = None,
|
|
62
67
|
retry_after: float | None = None,
|
|
68
|
+
limit: int | None = None,
|
|
69
|
+
remaining: int | None = None,
|
|
70
|
+
reset: float | None = None,
|
|
63
71
|
) -> None:
|
|
64
72
|
super().__init__(message, status_code=status_code, response=response, body=body)
|
|
65
73
|
self.retry_after = retry_after
|
|
74
|
+
self.limit = limit
|
|
75
|
+
self.remaining = remaining
|
|
76
|
+
self.reset = reset
|
|
66
77
|
|
|
67
78
|
|
|
68
79
|
class InternalError(ModulexError):
|
|
@@ -85,9 +96,61 @@ class TimeoutError(ModulexError):
|
|
|
85
96
|
"""Raised when a request times out."""
|
|
86
97
|
|
|
87
98
|
|
|
99
|
+
class BillingError(ModulexError):
|
|
100
|
+
"""Base class for billing / quota / credit / wallet denials.
|
|
101
|
+
|
|
102
|
+
The ModuleX backend returns a *structured denial envelope* (top-level, NOT
|
|
103
|
+
under ``detail``) for usage gating: ``{code, layer, key, current, limit, reason}``.
|
|
104
|
+
The ``layer`` determines the HTTP status (quota -> 403, rate -> 429,
|
|
105
|
+
credit/wallet -> 402). This class surfaces those fields structurally instead
|
|
106
|
+
of collapsing them into an opaque message.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
message: str,
|
|
112
|
+
*,
|
|
113
|
+
status_code: int | None = None,
|
|
114
|
+
response: httpx.Response | None = None,
|
|
115
|
+
body: Any = None,
|
|
116
|
+
code: str | None = None,
|
|
117
|
+
layer: str | None = None,
|
|
118
|
+
key: str | None = None,
|
|
119
|
+
current: float | int | None = None,
|
|
120
|
+
limit: float | int | None = None,
|
|
121
|
+
reason: str | None = None,
|
|
122
|
+
retry_after: float | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__(message, status_code=status_code, response=response, body=body)
|
|
125
|
+
self.code = code
|
|
126
|
+
self.layer = layer
|
|
127
|
+
self.key = key
|
|
128
|
+
self.current = current
|
|
129
|
+
self.limit = limit
|
|
130
|
+
self.reason = reason
|
|
131
|
+
self.retry_after = retry_after
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PaymentRequiredError(BillingError):
|
|
135
|
+
"""Raised for payment-required denials (402) without a structured envelope."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class QuotaExceededError(BillingError):
|
|
139
|
+
"""Raised when a usage quota is exceeded (layer="quota", HTTP 403)."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class CreditExhaustedError(BillingError):
|
|
143
|
+
"""Raised when the credit plan is exhausted (layer="credit", HTTP 402)."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class WalletError(BillingError):
|
|
147
|
+
"""Raised for wallet overage denials (layer="wallet", HTTP 402)."""
|
|
148
|
+
|
|
149
|
+
|
|
88
150
|
_STATUS_CODE_MAP: dict[int, type[ModulexError]] = {
|
|
89
151
|
400: BadRequestError,
|
|
90
152
|
401: AuthenticationError,
|
|
153
|
+
402: PaymentRequiredError,
|
|
91
154
|
403: PermissionError,
|
|
92
155
|
404: NotFoundError,
|
|
93
156
|
409: ConflictError,
|
|
@@ -98,8 +161,78 @@ _STATUS_CODE_MAP: dict[int, type[ModulexError]] = {
|
|
|
98
161
|
503: ServiceUnavailableError,
|
|
99
162
|
}
|
|
100
163
|
|
|
164
|
+
# Map the backend denial ``layer`` to a specific BillingError subclass.
|
|
165
|
+
_BILLING_LAYER_MAP: dict[str, type[BillingError]] = {
|
|
166
|
+
"quota": QuotaExceededError,
|
|
167
|
+
"credit": CreditExhaustedError,
|
|
168
|
+
"wallet": WalletError,
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503}
|
|
102
172
|
|
|
173
|
+
# Statuses that may carry a structured billing/usage denial envelope.
|
|
174
|
+
_DENIAL_STATUSES = {402, 403, 429}
|
|
175
|
+
_DENIAL_KEYS = ("code", "layer", "reason")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
179
|
+
"""Parse a ``Retry-After`` header (delay-seconds OR an HTTP-date)."""
|
|
180
|
+
if not value:
|
|
181
|
+
return None
|
|
182
|
+
value = value.strip()
|
|
183
|
+
try:
|
|
184
|
+
return float(value)
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
from datetime import datetime, timezone
|
|
189
|
+
|
|
190
|
+
dt = parsedate_to_datetime(value)
|
|
191
|
+
if dt is None:
|
|
192
|
+
return None
|
|
193
|
+
delta = (dt - datetime.now(timezone.utc)).total_seconds()
|
|
194
|
+
return max(delta, 0.0)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_rate_headers(response: httpx.Response) -> tuple[int | None, int | None, float | None]:
|
|
200
|
+
"""Read ``X-RateLimit-Limit/Remaining/Reset`` headers (best-effort)."""
|
|
201
|
+
|
|
202
|
+
def _as_int(name: str) -> int | None:
|
|
203
|
+
raw = response.headers.get(name)
|
|
204
|
+
try:
|
|
205
|
+
return int(raw) if raw is not None else None
|
|
206
|
+
except ValueError:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
reset_raw = response.headers.get("X-RateLimit-Reset")
|
|
210
|
+
try:
|
|
211
|
+
reset = float(reset_raw) if reset_raw is not None else None
|
|
212
|
+
except ValueError:
|
|
213
|
+
reset = None
|
|
214
|
+
return _as_int("X-RateLimit-Limit"), _as_int("X-RateLimit-Remaining"), reset
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _extract_denial_envelope(body: Any) -> dict[str, Any] | None:
|
|
218
|
+
"""Return the structured denial envelope if present.
|
|
219
|
+
|
|
220
|
+
Tolerates three shapes (mirrors the UI's ``billing-error.ts:extractEnvelope``):
|
|
221
|
+
- top-level ``{code, layer, ...}``
|
|
222
|
+
- ``{"detail": {code, layer, ...}}`` (FastAPI-wrapped dict detail)
|
|
223
|
+
- a bare ``{"reason": ...}`` top-level dict
|
|
224
|
+
"""
|
|
225
|
+
candidates = []
|
|
226
|
+
if isinstance(body, dict):
|
|
227
|
+
candidates.append(body)
|
|
228
|
+
detail = body.get("detail")
|
|
229
|
+
if isinstance(detail, dict):
|
|
230
|
+
candidates.append(detail)
|
|
231
|
+
for candidate in candidates:
|
|
232
|
+
if any(k in candidate for k in _DENIAL_KEYS):
|
|
233
|
+
return candidate
|
|
234
|
+
return None
|
|
235
|
+
|
|
103
236
|
|
|
104
237
|
def raise_for_status(response: httpx.Response) -> None:
|
|
105
238
|
"""Raise an appropriate exception for error HTTP status codes."""
|
|
@@ -111,21 +244,52 @@ def raise_for_status(response: httpx.Response) -> None:
|
|
|
111
244
|
except Exception:
|
|
112
245
|
body = {"detail": response.text}
|
|
113
246
|
|
|
247
|
+
status = response.status_code
|
|
248
|
+
|
|
249
|
+
# 1) Structured billing/usage denial envelope (402/403/429) — surface fields.
|
|
250
|
+
if status in _DENIAL_STATUSES:
|
|
251
|
+
envelope = _extract_denial_envelope(body)
|
|
252
|
+
if envelope is not None:
|
|
253
|
+
layer = envelope.get("layer")
|
|
254
|
+
reason = envelope.get("reason")
|
|
255
|
+
code = envelope.get("code")
|
|
256
|
+
message = reason or code or "Request denied by usage gate"
|
|
257
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
258
|
+
exc_cls = _BILLING_LAYER_MAP.get(layer or "", BillingError)
|
|
259
|
+
raise exc_cls(
|
|
260
|
+
str(message),
|
|
261
|
+
status_code=status,
|
|
262
|
+
response=response,
|
|
263
|
+
body=body,
|
|
264
|
+
code=code,
|
|
265
|
+
layer=layer,
|
|
266
|
+
key=envelope.get("key"),
|
|
267
|
+
current=envelope.get("current"),
|
|
268
|
+
limit=envelope.get("limit"),
|
|
269
|
+
reason=reason,
|
|
270
|
+
retry_after=retry_after,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 2) Standard error mapping.
|
|
114
274
|
detail = body.get("detail", response.text) if isinstance(body, dict) else str(body)
|
|
115
|
-
if isinstance(detail, list):
|
|
116
|
-
detail = "; ".join(item.get("msg", str(item)) for item in detail)
|
|
275
|
+
if isinstance(detail, list): # FastAPI 422 validation errors
|
|
276
|
+
detail = "; ".join(item.get("msg", str(item)) if isinstance(item, dict) else str(item) for item in detail)
|
|
277
|
+
elif isinstance(detail, dict): # org-member 429 dict-detail (non-envelope)
|
|
278
|
+
detail = detail.get("reason") or detail.get("message") or detail.get("code") or str(detail)
|
|
117
279
|
|
|
118
|
-
exc_class = _STATUS_CODE_MAP.get(
|
|
280
|
+
exc_class = _STATUS_CODE_MAP.get(status, ModulexError)
|
|
119
281
|
|
|
120
282
|
kwargs: dict[str, Any] = {
|
|
121
|
-
"status_code":
|
|
283
|
+
"status_code": status,
|
|
122
284
|
"response": response,
|
|
123
285
|
"body": body,
|
|
124
286
|
}
|
|
125
287
|
|
|
126
288
|
if exc_class is RateLimitError:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
kwargs["
|
|
289
|
+
kwargs["retry_after"] = parse_retry_after(response.headers.get("Retry-After"))
|
|
290
|
+
limit, remaining, reset = _parse_rate_headers(response)
|
|
291
|
+
kwargs["limit"] = limit
|
|
292
|
+
kwargs["remaining"] = remaining
|
|
293
|
+
kwargs["reset"] = reset
|
|
130
294
|
|
|
131
295
|
raise exc_class(str(detail), **kwargs)
|