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.
- LICENSE +202 -0
- NOTICE +18 -0
- THIRD-PARTY-NOTICES.md +22 -0
- TRADEMARKS.md +19 -0
- cloakapi/__init__.py +271 -0
- cloakapi/_disclosure/clk.txt +11 -0
- cloakapi/_disclosure/clk_message.txt +11 -0
- cloakapi/_disclosure/cloak_angle.txt +16 -0
- cloakapi/_disclosure/cloak_angle_message.txt +16 -0
- cloakapi/_http.py +443 -0
- cloakapi/_pretokenised.py +134 -0
- cloakapi/_relay.py +213 -0
- cloakapi/_release.py +30 -0
- cloakapi/_request_strings.py +1267 -0
- cloakapi/_ruleset/patterns.json +2238 -0
- cloakapi/_stream_detok.py +788 -0
- cloakapi/_version.py +31 -0
- cloakapi/async_client.py +704 -0
- cloakapi/attachments.py +176 -0
- cloakapi/cli.py +245 -0
- cloakapi/client.py +848 -0
- cloakapi/config.py +491 -0
- cloakapi/coverage.py +21 -0
- cloakapi/degraded.py +196 -0
- cloakapi/disclosure.py +334 -0
- cloakapi/engine.py +922 -0
- cloakapi/errors.py +304 -0
- cloakapi/notices.py +142 -0
- cloakapi/pii/__init__.py +4 -0
- cloakapi/pii/_checksum.py +510 -0
- cloakapi/pii/_ruleset.py +131 -0
- cloakapi/pii/patterns.py +1713 -0
- cloakapi/pii/rulefile.py +738 -0
- cloakapi/pii/tokenizer.py +537 -0
- cloakapi/posture.py +112 -0
- cloakapi/py.typed +1 -0
- cloakapi/receipts.py +605 -0
- cloakapi/resources/__init__.py +32 -0
- cloakapi/resources/admin.py +104 -0
- cloakapi/resources/buffer.py +54 -0
- cloakapi/resources/chat.py +293 -0
- cloakapi/resources/detect.py +115 -0
- cloakapi/resources/messages.py +260 -0
- cloakapi/resources/models.py +58 -0
- cloakapi/resources/receipts.py +252 -0
- cloakapi/resources/reconstruct.py +173 -0
- cloakapi/resources/tokens.py +47 -0
- cloakapi/review.py +112 -0
- cloakapi/streaming.py +446 -0
- cloakapi/surrogate_mode.py +308 -0
- cloakapi/types.py +284 -0
- cloakapi-0.5.1.dist-info/METADATA +441 -0
- cloakapi-0.5.1.dist-info/RECORD +61 -0
- cloakapi-0.5.1.dist-info/WHEEL +4 -0
- cloakapi-0.5.1.dist-info/entry_points.txt +2 -0
- cloakapi-0.5.1.dist-info/licenses/LICENSE +202 -0
- cloakapi-0.5.1.dist-info/licenses/NOTICE +18 -0
- licenses/Apache-2.0.txt +202 -0
- licenses/CC-BY-4.0.txt +104 -0
- licenses/MIT-Faker.txt +21 -0
- 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)
|