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.
Files changed (51) hide show
  1. modulex/__init__.py +12 -2
  2. modulex/_base.py +111 -16
  3. modulex/_client.py +25 -13
  4. modulex/_exceptions.py +172 -8
  5. modulex/_streaming.py +90 -60
  6. modulex/_version.py +5 -0
  7. modulex/resources/api_keys.py +16 -8
  8. modulex/resources/assistant.py +154 -0
  9. modulex/resources/auth.py +24 -12
  10. modulex/resources/chats.py +31 -14
  11. modulex/resources/composer.py +160 -53
  12. modulex/resources/credentials.py +144 -38
  13. modulex/resources/dashboard.py +36 -19
  14. modulex/resources/deployments.py +46 -26
  15. modulex/resources/executions.py +132 -22
  16. modulex/resources/integrations.py +36 -18
  17. modulex/resources/knowledge.py +104 -62
  18. modulex/resources/notifications.py +10 -4
  19. modulex/resources/organizations.py +122 -28
  20. modulex/resources/schedules.py +63 -32
  21. modulex/resources/subscriptions.py +32 -14
  22. modulex/resources/system.py +13 -11
  23. modulex/resources/workflows.py +53 -27
  24. modulex/types/__init__.py +347 -83
  25. modulex/types/_models.py +79 -0
  26. modulex/types/api_keys.py +59 -12
  27. modulex/types/assistant.py +104 -0
  28. modulex/types/auth.py +88 -43
  29. modulex/types/chats.py +65 -37
  30. modulex/types/composer.py +143 -18
  31. modulex/types/credentials.py +95 -54
  32. modulex/types/dashboard.py +247 -46
  33. modulex/types/deployments.py +126 -0
  34. modulex/types/executions.py +132 -101
  35. modulex/types/integrations.py +113 -21
  36. modulex/types/knowledge.py +143 -50
  37. modulex/types/notifications.py +70 -9
  38. modulex/types/organizations.py +131 -27
  39. modulex/types/realtime.py +270 -0
  40. modulex/types/schedules.py +88 -35
  41. modulex/types/subscriptions.py +91 -36
  42. modulex/types/system.py +62 -0
  43. modulex/types/workflows.py +276 -168
  44. {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/METADATA +90 -51
  45. modulex_python-1.0.0.dist-info/RECORD +51 -0
  46. {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/WHEEL +1 -1
  47. modulex/_compat.py +0 -39
  48. modulex/resources/templates.py +0 -115
  49. modulex/types/templates.py +0 -50
  50. modulex_python-0.1.0.dist-info/RECORD +0 -47
  51. {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(self, organization_id: str | None = None) -> dict[str, str]:
35
- """Build request headers with auth and optional org context."""
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
- headers.pop("Content-Type", None)
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 through a list endpoint."""
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
- # Detect pagination style
254
- if "page" in params or "page_size" in params:
255
- # Page-based pagination
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 = result.get(items_key, [])
328
+ items, container = self._unwrap_page(result, items_key)
262
329
  for item in items:
263
330
  yield item
264
- total_pages = result.get("total_pages", 1)
265
- if page >= total_pages:
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
- else:
269
- # Limit/offset pagination
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 = result.get(items_key, [])
350
+ items, container = self._unwrap_page(result, items_key)
277
351
  for item in items:
278
352
  yield item
279
- if not result.get("has_next", False) or len(items) < limit:
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 = DEFAULT_BASE_URL,
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(response.status_code, ModulexError)
280
+ exc_class = _STATUS_CODE_MAP.get(status, ModulexError)
119
281
 
120
282
  kwargs: dict[str, Any] = {
121
- "status_code": response.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
- retry_after_header = response.headers.get("Retry-After")
128
- retry_after = float(retry_after_header) if retry_after_header else None
129
- kwargs["retry_after"] = retry_after
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)