cloakapi 0.5.1__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 (61) hide show
  1. LICENSE +202 -0
  2. NOTICE +18 -0
  3. THIRD-PARTY-NOTICES.md +22 -0
  4. TRADEMARKS.md +19 -0
  5. cloakapi/__init__.py +271 -0
  6. cloakapi/_disclosure/clk.txt +11 -0
  7. cloakapi/_disclosure/clk_message.txt +11 -0
  8. cloakapi/_disclosure/cloak_angle.txt +16 -0
  9. cloakapi/_disclosure/cloak_angle_message.txt +16 -0
  10. cloakapi/_http.py +443 -0
  11. cloakapi/_pretokenised.py +134 -0
  12. cloakapi/_relay.py +213 -0
  13. cloakapi/_release.py +30 -0
  14. cloakapi/_request_strings.py +1267 -0
  15. cloakapi/_ruleset/patterns.json +2238 -0
  16. cloakapi/_stream_detok.py +788 -0
  17. cloakapi/_version.py +31 -0
  18. cloakapi/async_client.py +704 -0
  19. cloakapi/attachments.py +176 -0
  20. cloakapi/cli.py +245 -0
  21. cloakapi/client.py +848 -0
  22. cloakapi/config.py +491 -0
  23. cloakapi/coverage.py +21 -0
  24. cloakapi/degraded.py +196 -0
  25. cloakapi/disclosure.py +334 -0
  26. cloakapi/engine.py +922 -0
  27. cloakapi/errors.py +304 -0
  28. cloakapi/notices.py +142 -0
  29. cloakapi/pii/__init__.py +4 -0
  30. cloakapi/pii/_checksum.py +510 -0
  31. cloakapi/pii/_ruleset.py +131 -0
  32. cloakapi/pii/patterns.py +1713 -0
  33. cloakapi/pii/rulefile.py +738 -0
  34. cloakapi/pii/tokenizer.py +537 -0
  35. cloakapi/posture.py +112 -0
  36. cloakapi/py.typed +1 -0
  37. cloakapi/receipts.py +605 -0
  38. cloakapi/resources/__init__.py +32 -0
  39. cloakapi/resources/admin.py +104 -0
  40. cloakapi/resources/buffer.py +54 -0
  41. cloakapi/resources/chat.py +293 -0
  42. cloakapi/resources/detect.py +115 -0
  43. cloakapi/resources/messages.py +260 -0
  44. cloakapi/resources/models.py +58 -0
  45. cloakapi/resources/receipts.py +252 -0
  46. cloakapi/resources/reconstruct.py +173 -0
  47. cloakapi/resources/tokens.py +47 -0
  48. cloakapi/review.py +112 -0
  49. cloakapi/streaming.py +446 -0
  50. cloakapi/surrogate_mode.py +308 -0
  51. cloakapi/types.py +284 -0
  52. cloakapi-0.5.1.dist-info/METADATA +441 -0
  53. cloakapi-0.5.1.dist-info/RECORD +61 -0
  54. cloakapi-0.5.1.dist-info/WHEEL +4 -0
  55. cloakapi-0.5.1.dist-info/entry_points.txt +2 -0
  56. cloakapi-0.5.1.dist-info/licenses/LICENSE +202 -0
  57. cloakapi-0.5.1.dist-info/licenses/NOTICE +18 -0
  58. licenses/Apache-2.0.txt +202 -0
  59. licenses/CC-BY-4.0.txt +104 -0
  60. licenses/MIT-Faker.txt +21 -0
  61. licenses/MIT.txt +21 -0
cloakapi/_http.py ADDED
@@ -0,0 +1,443 @@
1
+ """
2
+ Internal HTTP layer -- shared by both sync and async clients.
3
+
4
+ Handles:
5
+ - Authorization header injection
6
+ - Retry with exponential backoff + jitter
7
+ - Error mapping from HTTP responses
8
+ - Debug logging (scrubbed of API key; never logs message content)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import os
17
+ import random
18
+ import time
19
+ from typing import Any, TypeVar
20
+ from urllib.parse import urlsplit
21
+
22
+ import httpx
23
+
24
+ from ._version import SDK_VERSION
25
+ from .config import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
26
+ from .errors import (
27
+ CloakAPIError,
28
+ NetworkError,
29
+ RequestBodyRejectedError,
30
+ _extract_error,
31
+ _map_status,
32
+ _request_id,
33
+ )
34
+
35
+ logger = logging.getLogger("cloakapi")
36
+
37
+ _ModelT = TypeVar("_ModelT")
38
+
39
+ # Keep the JSON response ceiling in step with the Go SDK's readLimitedBody.
40
+ # httpx has already buffered response.content at this point, but refusing before
41
+ # JSON parsing prevents a gateway response from expanding into an unbounded
42
+ # Python object graph.
43
+ MAX_RESPONSE_BODY_BYTES = 32 * 1024 * 1024
44
+
45
+ _LOG_REQUESTS = os.environ.get("CLOAKAPI_LOG_REQUESTS", "") == "1"
46
+
47
+ # Status codes that are safe to retry
48
+ _RETRY_STATUSES = {429, 502, 503, 504}
49
+ _RETRY_EXCEPTIONS = (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout)
50
+ _RELAY_MAC_HEADER = "X-Cloak-Client-MAC"
51
+
52
+
53
+ def serialise_body(body: Any) -> bytes:
54
+ """Canonical JSON serialisation of a relay body.
55
+
56
+ This is the SINGLE source of truth for how a request body is turned into
57
+ bytes. The exact bytes produced here are BOTH what egresses to the gateway
58
+ AND what is signed for the pre-tokenised passthrough MAC (POST
59
+ /api/v1/pretokenised/mac). They must be byte-identical for the gateway's
60
+ constant-time MAC check to pass, so both paths call this one function with
61
+ the same ``dict`` (Python's ``json.dumps`` preserves key insertion order and
62
+ uses fixed default separators, so the output is deterministic).
63
+ """
64
+ try:
65
+ # allow_nan is on by default and emits the tokens NaN / Infinity, which
66
+ # are not JSON. A non-finite leaf must not leave with the rest of the body.
67
+ return json.dumps(body, allow_nan=False).encode()
68
+ except ValueError:
69
+ raise RequestBodyRejectedError() from None
70
+
71
+
72
+ def response_json_object(response: httpx.Response, *, endpoint: str) -> dict[str, Any]:
73
+ """Return a JSON object response, rejecting other JSON shapes explicitly."""
74
+ payload = response_json(response)
75
+ if not isinstance(payload, dict):
76
+ raise ValueError(f"expected a JSON object from {endpoint}")
77
+ return payload
78
+
79
+
80
+ def ensure_response_size(response: httpx.Response) -> None:
81
+ """Reject an oversized buffered gateway response before JSON parsing."""
82
+ if len(response.content) > MAX_RESPONSE_BODY_BYTES:
83
+ raise CloakAPIError("Gateway response exceeds the SDK limit")
84
+
85
+
86
+ def _read_limited_response(response: httpx.Response) -> httpx.Response:
87
+ """Buffer at most the SDK response limit, then close the transport stream."""
88
+ body = bytearray()
89
+ try:
90
+ for chunk in response.iter_bytes():
91
+ if len(body) + len(chunk) > MAX_RESPONSE_BODY_BYTES:
92
+ raise CloakAPIError("Gateway response exceeds the SDK limit")
93
+ body.extend(chunk)
94
+ finally:
95
+ response.close()
96
+
97
+ headers = response.headers.copy()
98
+ for key in ("content-encoding", "content-length", "transfer-encoding"):
99
+ headers.pop(key, None)
100
+ return httpx.Response(
101
+ response.status_code,
102
+ headers=headers,
103
+ content=bytes(body),
104
+ request=response.request,
105
+ )
106
+
107
+
108
+ async def _aread_limited_response(response: httpx.Response) -> httpx.Response:
109
+ """Async variant of :func:`_read_limited_response`."""
110
+ body = bytearray()
111
+ try:
112
+ async for chunk in response.aiter_bytes():
113
+ if len(body) + len(chunk) > MAX_RESPONSE_BODY_BYTES:
114
+ raise CloakAPIError("Gateway response exceeds the SDK limit")
115
+ body.extend(chunk)
116
+ finally:
117
+ await response.aclose()
118
+
119
+ headers = response.headers.copy()
120
+ for key in ("content-encoding", "content-length", "transfer-encoding"):
121
+ headers.pop(key, None)
122
+ return httpx.Response(
123
+ response.status_code,
124
+ headers=headers,
125
+ content=bytes(body),
126
+ request=response.request,
127
+ )
128
+
129
+
130
+ def response_json(response: httpx.Response) -> Any:
131
+ """Decode a gateway response without retaining malformed body data in errors."""
132
+ ensure_response_size(response)
133
+ invalid_json = False
134
+ try:
135
+ payload = response.json()
136
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
137
+ # Keep only a boolean beyond the except block: JSONDecodeError.doc and
138
+ # its traceback/context can contain the entire upstream response.
139
+ invalid_json = True
140
+ payload = None
141
+ if invalid_json:
142
+ raise CloakAPIError("Gateway returned invalid JSON")
143
+ return payload
144
+
145
+
146
+ def validate_response(data: Any, model: type[_ModelT], *, endpoint: str) -> _ModelT:
147
+ """Validate upstream JSON without exposing response fields in validation errors."""
148
+ from .errors import CloakAPIError
149
+
150
+ invalid = False
151
+ result: _ModelT | None = None
152
+ try:
153
+ result = model.model_validate(data) # type: ignore[attr-defined]
154
+ except Exception:
155
+ invalid = True
156
+ if invalid or result is None:
157
+ raise CloakAPIError(f"Gateway returned an invalid response from {endpoint}")
158
+ return result
159
+
160
+
161
+ def _base_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]:
162
+ headers = {
163
+ "Authorization": f"Bearer {api_key}",
164
+ "Content-Type": "application/json",
165
+ "Accept": "application/json",
166
+ "User-Agent": f"cloakapi-python/{SDK_VERSION}",
167
+ "X-CloakAPI-SDK": f"python/{SDK_VERSION}",
168
+ }
169
+ if extra:
170
+ headers.update(extra)
171
+ return headers
172
+
173
+
174
+ def _backoff(attempt: int, retry_after: float | None = None) -> float:
175
+ """Exponential backoff with full jitter, respecting Retry-After."""
176
+ if retry_after is not None:
177
+ return retry_after
178
+ base = min(0.5 * (2**attempt), 32.0)
179
+ return float(base * random.random())
180
+
181
+
182
+ def _require_retry_budget(max_retries: object, timeout: object) -> None:
183
+ """Reject a wrong-typed retry budget before any byte is sent."""
184
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
185
+ raise ValueError("max_retries must be a non-negative integer")
186
+ if (
187
+ isinstance(timeout, bool)
188
+ or not isinstance(timeout, (int, float))
189
+ or timeout < 0
190
+ or timeout != timeout
191
+ or timeout == float("inf")
192
+ ):
193
+ raise ValueError("timeout must be a non-negative finite number")
194
+
195
+
196
+ def safe_path_segment(value: object, *, what: str) -> str:
197
+ """One URL path segment. Rejects traversal, queries, and control bytes.
198
+
199
+ The error text is fixed: ``value`` is caller-supplied and may be a secret
200
+ or a document name.
201
+ """
202
+ if not isinstance(value, str) or not _is_safe_path_segment(value):
203
+ raise ValueError(f"{what} is invalid")
204
+ return value
205
+
206
+
207
+ def _is_safe_path_segment(value: str) -> bool:
208
+ if not value or len(value) > 200 or ".." in value:
209
+ return False
210
+ for ch in value:
211
+ if ("0" <= ch <= "9") or ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ch in "._:-":
212
+ continue
213
+ return False
214
+ return True
215
+
216
+
217
+ def absolutt_url(base_url: str | None, path: str) -> str:
218
+ """Join the GATEWAY-LOCK-validated base to a request path.
219
+
220
+ ⛔ KVIFOR (2026-08-07, målt)
221
+
222
+ ``base_url`` used to be applied ONLY to the ``httpx.Client`` this SDK builds
223
+ for itself. A caller who passed ``http_client=`` got a client with no base,
224
+ so every path stayed relative — and, worse, a caller whose own client had a
225
+ base of ``https://api.openai.com`` egressed there while ``client.base_url``
226
+ still reported ``https://api.cloakapi.io``. ``validate_gateway_base``
227
+ documents itself as "the HARD billing invariant … there is NO
228
+ direct-to-provider mode", and it validated a value the request path then
229
+ never used.
230
+
231
+ The fix is the .NET port's shape (``Client.cs:607``), which never sets
232
+ ``BaseAddress`` and builds ``_baseUrl + path`` at every call site — the one
233
+ port of the nine that already had this right.
234
+
235
+ An already-absolute ``path`` is returned untouched, so a caller that passes
236
+ a full URL (the receipts resource builds some) is unaffected.
237
+ """
238
+ if not isinstance(path, str) or "\r" in path or "\n" in path:
239
+ raise ValueError("request path is invalid")
240
+ if path.startswith("//") or "@" in path.split("?", 1)[0]:
241
+ raise ValueError("request path must stay on the configured origin")
242
+ path_only = path.split("?", 1)[0].split("#", 1)[0]
243
+ if any(segment in {".", ".."} for segment in path_only.split("/")):
244
+ raise ValueError("request path must stay on the configured origin")
245
+ if not base_url:
246
+ if path.startswith(("http://", "https://")):
247
+ return path
248
+ return path
249
+ base = urlsplit(base_url)
250
+ candidate = urlsplit(path)
251
+ if candidate.scheme or candidate.netloc:
252
+ def origin(parts: Any) -> tuple[str, str | None, int | None]:
253
+ scheme = parts.scheme.lower()
254
+ port = parts.port or (443 if scheme == "https" else 80 if scheme == "http" else None)
255
+ return scheme, parts.hostname.lower() if parts.hostname else None, port
256
+
257
+ if origin(candidate) != origin(base):
258
+ raise ValueError("request URL must stay on the configured origin")
259
+ return path
260
+ return base_url.rstrip("/") + "/" + path.lstrip("/")
261
+
262
+
263
+ def sync_request(
264
+ client: httpx.Client,
265
+ method: str,
266
+ path: str,
267
+ api_key: str,
268
+ *,
269
+ base_url: str | None = None,
270
+ body: dict[str, Any] | None = None,
271
+ content: bytes | None = None,
272
+ extra_headers: dict[str, str] | None = None,
273
+ stream: bool = False,
274
+ max_retries: int = DEFAULT_MAX_RETRIES,
275
+ timeout: float = DEFAULT_TIMEOUT,
276
+ ) -> httpx.Response:
277
+ """
278
+ Execute a sync HTTP request with retry + error mapping.
279
+
280
+ Returns the raw httpx.Response (caller reads .json() or iterates stream).
281
+ Raises CloakAPIError subclasses on unrecoverable failure.
282
+
283
+ ``content`` (pre-serialised bytes) takes precedence over ``body`` so a
284
+ caller that has already serialised (e.g. to sign the pre-tokenised MAC over
285
+ the exact egressing bytes) can relay those same bytes verbatim.
286
+ """
287
+ _require_retry_budget(max_retries, timeout)
288
+ headers = _base_headers(api_key, extra_headers)
289
+ # A pre-tokenised relay POST is approved by its exact-body MAC. Retrying it
290
+ # after a lost response could replay an already accepted request.
291
+ single_attempt = method.upper() == "POST" and _RELAY_MAC_HEADER in headers
292
+ if content is None:
293
+ content = serialise_body(body) if body is not None else None
294
+ path = absolutt_url(base_url, path)
295
+ attempt = 0
296
+
297
+ while True:
298
+ failure: NetworkError | None = None
299
+ try:
300
+ if _LOG_REQUESTS:
301
+ logger.debug("REQUEST %s", method)
302
+
303
+ request = client.build_request(
304
+ method,
305
+ path,
306
+ content=content,
307
+ headers=headers,
308
+ timeout=timeout,
309
+ )
310
+ response = client.send(request, stream=True, follow_redirects=False)
311
+ response = _read_limited_response(response)
312
+
313
+ except _RETRY_EXCEPTIONS:
314
+ attempt += 1
315
+ if single_attempt or attempt > max_retries:
316
+ failure = NetworkError(f"Connection failed after {max_retries} retries")
317
+ else:
318
+ wait = _backoff(attempt)
319
+ logger.debug(
320
+ "Network error (attempt %d/%d), retrying in %.2fs",
321
+ attempt,
322
+ max_retries,
323
+ wait,
324
+ )
325
+ time.sleep(wait)
326
+ continue
327
+
328
+ except httpx.HTTPError:
329
+ failure = NetworkError("HTTP request failed")
330
+
331
+ if failure is not None:
332
+ raise failure
333
+
334
+ ensure_response_size(response)
335
+
336
+ if response.is_success:
337
+ return response
338
+
339
+ if not single_attempt and response.status_code in _RETRY_STATUSES and attempt < max_retries:
340
+ message, code, retry_after = _extract_error(response)
341
+ wait = _backoff(attempt, retry_after)
342
+ attempt += 1
343
+ logger.debug(
344
+ "Status %d (attempt %d/%d), retrying in %.2fs",
345
+ response.status_code,
346
+ attempt,
347
+ max_retries,
348
+ wait,
349
+ )
350
+ time.sleep(wait)
351
+ continue
352
+
353
+ message, code, retry_after = _extract_error(response)
354
+ rid = _request_id(response)
355
+ raise _map_status(
356
+ response.status_code, message, request_id=rid, code=code, retry_after=retry_after
357
+ )
358
+
359
+
360
+ async def async_request(
361
+ client: httpx.AsyncClient,
362
+ method: str,
363
+ path: str,
364
+ api_key: str,
365
+ *,
366
+ base_url: str | None = None,
367
+ body: dict[str, Any] | None = None,
368
+ content: bytes | None = None,
369
+ extra_headers: dict[str, str] | None = None,
370
+ stream: bool = False,
371
+ max_retries: int = DEFAULT_MAX_RETRIES,
372
+ timeout: float = DEFAULT_TIMEOUT,
373
+ ) -> httpx.Response:
374
+ """Async variant of sync_request (``content`` overrides ``body``)."""
375
+ _require_retry_budget(max_retries, timeout)
376
+ headers = _base_headers(api_key, extra_headers)
377
+ single_attempt = method.upper() == "POST" and _RELAY_MAC_HEADER in headers
378
+ if content is None:
379
+ content = serialise_body(body) if body is not None else None
380
+ path = absolutt_url(base_url, path)
381
+ attempt = 0
382
+
383
+ while True:
384
+ failure: NetworkError | None = None
385
+ try:
386
+ if _LOG_REQUESTS:
387
+ logger.debug("ASYNC REQUEST %s", method)
388
+
389
+ request = client.build_request(
390
+ method,
391
+ path,
392
+ content=content,
393
+ headers=headers,
394
+ timeout=timeout,
395
+ )
396
+ response = await client.send(request, stream=True, follow_redirects=False)
397
+ response = await _aread_limited_response(response)
398
+
399
+ except _RETRY_EXCEPTIONS:
400
+ attempt += 1
401
+ if single_attempt or attempt > max_retries:
402
+ failure = NetworkError(f"Connection failed after {max_retries} retries")
403
+ else:
404
+ wait = _backoff(attempt)
405
+ logger.debug(
406
+ "Async network error (attempt %d/%d), retrying in %.2fs",
407
+ attempt,
408
+ max_retries,
409
+ wait,
410
+ )
411
+ await asyncio.sleep(wait)
412
+ continue
413
+
414
+ except httpx.HTTPError:
415
+ failure = NetworkError("HTTP request failed")
416
+
417
+ if failure is not None:
418
+ raise failure
419
+
420
+ ensure_response_size(response)
421
+
422
+ if response.is_success:
423
+ return response
424
+
425
+ if not single_attempt and response.status_code in _RETRY_STATUSES and attempt < max_retries:
426
+ message, code, retry_after = _extract_error(response)
427
+ wait = _backoff(attempt, retry_after)
428
+ attempt += 1
429
+ logger.debug(
430
+ "Async status %d (attempt %d/%d), retrying in %.2fs",
431
+ response.status_code,
432
+ attempt,
433
+ max_retries,
434
+ wait,
435
+ )
436
+ await asyncio.sleep(wait)
437
+ continue
438
+
439
+ message, code, retry_after = _extract_error(response)
440
+ rid = _request_id(response)
441
+ raise _map_status(
442
+ response.status_code, message, request_id=rid, code=code, retry_after=retry_after
443
+ )
@@ -0,0 +1,134 @@
1
+ """
2
+ Client-side pre-tokenised passthrough (Level-1 relay half).
3
+
4
+ The SDK tokenises PII on-device (``cloakapi.pii.tokenizer`` / the shared FFI
5
+ engine) BEFORE anything egresses, then relays the already-tokenised body to the
6
+ CloakAPI gateway. Under the Level-1-only fail-closed gateway the relay is
7
+ accepted ONLY when it PROVES the body was tokenised client-side, via:
8
+
9
+ X-Cloak-Pretokenised: 1
10
+ X-Cloak-Client-MAC: <HMAC-SHA256 of the exact tokenised body bytes>
11
+ X-CloakAPI-Routing: client
12
+
13
+ on top of the engine-identity headers the SDK already sends
14
+ (``X-Cloak-Engine-Version`` / ``X-Cloak-Engine-Hash``). The per-body MAC is
15
+ minted server-side from the tenant key by ``POST /api/v1/pretokenised/mac`` — so
16
+ the client never holds the raw tenant key — and is computed over the EXACT bytes
17
+ that are then relayed to ``/messages`` / ``/chat/completions`` (both derived from
18
+ the one :func:`cloakapi._http.serialise_body` call), so the gateway's
19
+ constant-time check (``PrivacyTokenisationMiddleware``) passes.
20
+
21
+ This mirrors, in Python, the cloakup server relay
22
+ (``cloakup/src/lib/server/cloak/pretokenised.ts``) and the CloakAPI starter-kit
23
+ route — same endpoint, same header names, same "MAC over the exact egressing
24
+ bytes" contract.
25
+
26
+ Fail-loud: if the MAC cannot be obtained the request is NOT relayed. An HTTP
27
+ error from ``/mac`` surfaces as the usual typed :class:`~cloakapi.errors.APIError`
28
+ (e.g. 401 when unauthenticated); a 2xx response with no usable ``mac`` raises
29
+ :class:`~cloakapi.errors.PretokenisedMacError`. There is deliberately NO
30
+ silent "relay without the MAC" fallback.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ import httpx
38
+
39
+ from ._http import async_request, sync_request
40
+ from .config import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
41
+ from .errors import PretokenisedMacError
42
+
43
+ # Endpoint that mints the per-body passthrough MAC for the authenticated tenant.
44
+ MAC_PATH = "/api/v1/pretokenised/mac"
45
+
46
+ # Passthrough proof headers (EXACT names the gateway's
47
+ # PrivacyTokenisationMiddleware reads; mirror cloakup/starter-kit/TS SDK).
48
+ HEADER_PRETOKENISED = "X-Cloak-Pretokenised"
49
+ HEADER_CLIENT_MAC = "X-Cloak-Client-MAC"
50
+ HEADER_ROUTING = "X-CloakAPI-Routing"
51
+
52
+
53
+ def proof_headers(mac: str) -> dict[str, str]:
54
+ """The pre-tokenised passthrough proof headers for a minted per-body MAC."""
55
+ return {
56
+ HEADER_PRETOKENISED: "1",
57
+ HEADER_CLIENT_MAC: mac,
58
+ HEADER_ROUTING: "client",
59
+ }
60
+
61
+
62
+ def _extract_mac(payload: Any) -> str:
63
+ """Pull the ``mac`` string out of the /mac response (``{mac}`` or
64
+ ``{data:{mac}}``, matching the cloakup/TS clients). Raises if absent."""
65
+ mac: Any = None
66
+ if isinstance(payload, dict):
67
+ mac = payload.get("mac")
68
+ if not mac and isinstance(payload.get("data"), dict):
69
+ mac = payload["data"].get("mac")
70
+ if isinstance(mac, str) and mac:
71
+ return mac
72
+ raise PretokenisedMacError(
73
+ "pre-tokenised MAC endpoint returned no usable 'mac' — refusing to relay "
74
+ "without the client MAC proof"
75
+ )
76
+
77
+
78
+ def fetch_mac_sync(
79
+ client: httpx.Client,
80
+ api_key: str,
81
+ body_bytes: bytes,
82
+ *,
83
+ base_url: str | None = None,
84
+ max_retries: int = DEFAULT_MAX_RETRIES,
85
+ timeout: float = DEFAULT_TIMEOUT,
86
+ ) -> str:
87
+ """Mint the per-body passthrough MAC for ``body_bytes`` (the EXACT bytes that
88
+ will be relayed). Fail-loud: never returns without a valid MAC."""
89
+ resp = sync_request(
90
+ client,
91
+ "POST",
92
+ MAC_PATH,
93
+ api_key,
94
+ base_url=base_url,
95
+ content=body_bytes,
96
+ max_retries=max_retries,
97
+ timeout=timeout,
98
+ )
99
+ try:
100
+ payload = resp.json()
101
+ except Exception:
102
+ payload = None
103
+ if payload is None:
104
+ raise PretokenisedMacError("pre-tokenised MAC response was not valid JSON")
105
+ return _extract_mac(payload)
106
+
107
+
108
+ async def fetch_mac_async(
109
+ client: httpx.AsyncClient,
110
+ api_key: str,
111
+ body_bytes: bytes,
112
+ *,
113
+ base_url: str | None = None,
114
+ max_retries: int = DEFAULT_MAX_RETRIES,
115
+ timeout: float = DEFAULT_TIMEOUT,
116
+ ) -> str:
117
+ """Async variant of :func:`fetch_mac_sync`."""
118
+ resp = await async_request(
119
+ client,
120
+ "POST",
121
+ MAC_PATH,
122
+ api_key,
123
+ base_url=base_url,
124
+ content=body_bytes,
125
+ max_retries=max_retries,
126
+ timeout=timeout,
127
+ )
128
+ try:
129
+ payload = resp.json()
130
+ except Exception:
131
+ payload = None
132
+ if payload is None:
133
+ raise PretokenisedMacError("pre-tokenised MAC response was not valid JSON")
134
+ return _extract_mac(payload)