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
@@ -0,0 +1,704 @@
1
+ """
2
+ CloakAPI asynchronous client.
3
+
4
+ Usage::
5
+
6
+ import asyncio
7
+ from cloakapi import AsyncCloakAPI
8
+
9
+ async def main():
10
+ async with AsyncCloakAPI(api_key="cloak_abc123") as client:
11
+ msg = await client.messages.create(
12
+ model="claude-sonnet-4-6",
13
+ messages=[{"role": "user", "content": "Hello!"}],
14
+ )
15
+ print(msg.text())
16
+
17
+ asyncio.run(main())
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import copy
24
+ import inspect
25
+ import logging
26
+ import threading
27
+ import warnings
28
+ from collections.abc import Callable
29
+ from typing import Any, TypeVar
30
+
31
+ import httpx
32
+
33
+ from ._http import async_request, response_json, validate_response
34
+ from ._pretokenised import fetch_mac_async, proof_headers
35
+ from ._relay import BuiltRelay, build_relay, refuse_plaintext_tier
36
+ from ._request_strings import (
37
+ bind_tokeniser,
38
+ restore_arguments,
39
+ restore_reply_tool_calls,
40
+ tokenise_body_strings,
41
+ tokenise_message,
42
+ )
43
+ from .config import (
44
+ DEFAULT_MAX_RETRIES,
45
+ DEFAULT_TIMEOUT,
46
+ Tier,
47
+ end_user_header_value,
48
+ normalise_tier,
49
+ resolve_api_key,
50
+ resolve_app_id,
51
+ resolve_base_url,
52
+ )
53
+ from .coverage import Coverage
54
+ from .engine import engine_available, engine_identity, require_pinned_engine_identity
55
+ from .errors import PlaintextRelayNotSupportedError, ReviewRequiredError
56
+ from .notices import ReviewInfo, SendWarning
57
+ from .pii.tokenizer import TokenMap, detokenize
58
+ from .receipts import receipt_from_headers
59
+ from .resources.admin import AsyncAdminResource
60
+ from .resources.buffer import AsyncBufferResource
61
+ from .resources.chat import AsyncChatResource
62
+ from .resources.detect import AsyncDetectResource
63
+ from .resources.messages import AsyncMessagesResource
64
+ from .resources.models import AsyncModelsResource
65
+ from .resources.receipts import AsyncReceiptsResource
66
+ from .resources.reconstruct import AsyncReconstructResource
67
+ from .resources.tokens import AsyncTokensResource
68
+ from .review import Approved, Prepared, RelayReview, check_review, run_review
69
+ from .surrogate_mode import (
70
+ SurrogateMode,
71
+ SurrogateModeReport,
72
+ coerce_surrogate_mode,
73
+ resolve_surrogate_mode,
74
+ )
75
+
76
+ logger = logging.getLogger("cloakapi")
77
+
78
+ _ReplyT = TypeVar("_ReplyT")
79
+
80
+
81
+ class AsyncCloakAPI:
82
+ """
83
+ CloakAPI asynchronous client (uses ``httpx.AsyncClient``).
84
+
85
+ Same surface area as :class:`~cloakapi.CloakAPI` but all methods are
86
+ ``async`` and streaming returns ``async for`` iterators.
87
+
88
+ Args:
89
+ api_key: Your CloakAPI key (``cloak_...``). Falls back to
90
+ ``CLOAKAPI_API_KEY`` env var or ``~/.cloakapi/config.json``.
91
+ base_url: Gateway base URL. Defaults to ``https://api.cloakapi.io``.
92
+ timeout: Request timeout in seconds (default 30).
93
+ max_retries: Maximum retry attempts on transient errors (default 3).
94
+ http_client: Optional pre-configured ``httpx.AsyncClient`` to use.
95
+ tokenize: Client-level PII tokenization toggle (overridable per-call).
96
+ Defaults to ``True`` — privacy-by-default, and the only posture the
97
+ Level-1-only fail-closed gateway accepts.
98
+ review: Called before every message/chat request with ``(text, info)``:
99
+ ``text`` is the exact body that will be sent, ``info`` a
100
+ :class:`~cloakapi.notices.ReviewInfo` (``state``, ``coverage``,
101
+ ``warnings``). Return ``True`` to send. Anything else, or no review
102
+ at all, raises :class:`~cloakapi.errors.ReviewRequiredError` before
103
+ any network call. ``lambda text, info: True`` is the one-liner for
104
+ server-side use. For your own review screen use the step form:
105
+ ``prepare`` -> ``approve`` -> ``send``.
106
+ review_callback: Deprecated 0.4.x form of ``review``; receives a
107
+ :class:`~cloakapi.review.RelayReview` (method, URL, body bytes,
108
+ headers) and must return ``True``. Removed in 0.6.
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ api_key: str | None = None,
114
+ *,
115
+ base_url: str | None = None,
116
+ timeout: float = DEFAULT_TIMEOUT,
117
+ max_retries: int = DEFAULT_MAX_RETRIES,
118
+ http_client: httpx.AsyncClient | None = None,
119
+ tokenize: bool = True,
120
+ tier: Tier = "balanced",
121
+ seed_hex: str | None = None,
122
+ app_id: str | None = None,
123
+ end_user_secret: str | None = None,
124
+ surrogate_mode: SurrogateMode | None = None,
125
+ review_callback: Callable[[RelayReview], Any] | None = None,
126
+ review: Callable[[str, ReviewInfo], Any] | None = None,
127
+ ) -> None:
128
+ self.api_key = resolve_api_key(api_key)
129
+ self._base_url = resolve_base_url(base_url)
130
+ # ADR-0015 ecosystem attribution — opaque app/site slug → X-Cloak-App-Id
131
+ # on every relay call. Defaults from CLOAK_APP_ID env; None → omitted.
132
+ self.app_id = resolve_app_id(app_id)
133
+ # HMAC secret for client-side end-user tokenisation. None uses the
134
+ # API key, so rotating the key rotates the pseudonym; set this to pin one
135
+ # end user to one token across rotations. Must match the other SDKs'
136
+ # end_user_secret for a cross-language join to line up.
137
+ self._end_user_secret = end_user_secret or None
138
+ self.timeout = timeout
139
+ self.max_retries = max_retries
140
+ self.tokenize = tokenize
141
+ self.tier: Tier = normalise_tier(tier)
142
+ self.seed_hex = seed_hex
143
+ # `review(text, info)` is the form every CloakAPI SDK shares. The
144
+ # 0.4.x `review_callback(request)` keeps working for one minor version.
145
+ self.review = review
146
+ self.review_callback = review_callback
147
+ if review_callback is not None and review is None:
148
+ warnings.warn(
149
+ "review_callback is deprecated; pass review=(text, info) -> bool instead",
150
+ DeprecationWarning,
151
+ stacklevel=2,
152
+ )
153
+ # SURROGATE MODE — see CloakAPI.__init__ in client.py for the full
154
+ # rationale. Explicit argument RESOLVES (bad literal raises); otherwise
155
+ # the legacy `tier` is COERCED onto a protected mode.
156
+ self.surrogate_mode: SurrogateMode = (
157
+ resolve_surrogate_mode(surrogate_mode)
158
+ if surrogate_mode is not None
159
+ else coerce_surrogate_mode(self.tier)
160
+ )
161
+ self._token_map: TokenMap = {}
162
+ # THE CONVERSATION LEDGER — its presence puts every tokenise call in
163
+ # CONVERSATION scope. RE-IDENTIFICATION MATERIAL: client-side only,
164
+ # never egressed/logged/receipted.
165
+ self._bindings: dict[str, Any] = {}
166
+ # Serialises "read the ledger, detect, write the ledger" per string, so
167
+ # threads that share this client cannot lose each other's stand-ins.
168
+ self._tokenise_lock = threading.RLock()
169
+ self._bindings_revision = 0
170
+ self._last_surrogate_mode: SurrogateModeReport | None = None
171
+ self._last_coverage: Coverage | None = None
172
+ self._last_warnings: tuple[SendWarning, ...] = ()
173
+ self._last_value_not_replaced = False
174
+ self._last_receipt: dict[str, Any] | None = None
175
+ self._pending: dict[int, dict[str, Any]] = {}
176
+ self._owns_client = http_client is None
177
+ self._http_client: httpx.AsyncClient = http_client or httpx.AsyncClient(
178
+ base_url=self._base_url,
179
+ timeout=timeout,
180
+ # The only permitted HTTP base is loopback. Do not let an
181
+ # environment proxy receive its bearer Authorization header.
182
+ mounts={"http://": httpx.AsyncHTTPTransport(trust_env=False)},
183
+ )
184
+ # Resource namespaces
185
+ self.messages = AsyncMessagesResource(self)
186
+ self.chat = AsyncChatResource(self)
187
+ self.buffer = AsyncBufferResource(self)
188
+ self.admin = AsyncAdminResource(self)
189
+ self.models = AsyncModelsResource(self)
190
+ self.tokens = AsyncTokensResource(self)
191
+ self.receipts = AsyncReceiptsResource(self)
192
+ self.reconstruct = AsyncReconstructResource(self)
193
+ self.detect = AsyncDetectResource(self)
194
+
195
+ # ── HTTP helpers ─────────────────────────────────────────────────────────
196
+
197
+ @property
198
+ def base_url(self) -> str:
199
+ """The gateway origin fixed when this client was constructed."""
200
+ return self._base_url
201
+
202
+ def _require_egress_engine_identity(self) -> None:
203
+ """Defer identity attestation until tokenisation coverage is known."""
204
+ return None
205
+
206
+ async def _unsafe_raw_async_request(
207
+ self,
208
+ method: str,
209
+ path: str,
210
+ *,
211
+ body: dict[str, Any] | None = None,
212
+ content: bytes | None = None,
213
+ extra_headers: dict[str, str] | None = None,
214
+ ) -> httpx.Response:
215
+ """Send an unreviewed HTTP request. Internal plumbing only; unsafe for caller data."""
216
+ return await async_request(
217
+ self._http_client,
218
+ method,
219
+ path,
220
+ self.api_key,
221
+ base_url=self._base_url,
222
+ body=body,
223
+ content=content,
224
+ extra_headers=extra_headers,
225
+ max_retries=self.max_retries,
226
+ timeout=self.timeout,
227
+ )
228
+
229
+ async def _prepare_relay(
230
+ self,
231
+ body: dict[str, Any],
232
+ extra_headers: dict[str, str],
233
+ should_tokenize: bool,
234
+ *,
235
+ method: str = "POST",
236
+ path: str = "/api/v1/messages",
237
+ token_state: dict[str, Any] | None = None,
238
+ call_state: dict[str, Any] | None = None,
239
+ ) -> tuple[bytes, dict[str, str]]:
240
+ """Finalise one relay request: body bytes, review, proof.
241
+
242
+ Serialises the assembled body ONCE (the exact bytes that will leave),
243
+ shows them to the caller's review, then mints the per-body MAC over
244
+ those bytes from ``POST /api/v1/pretokenised/mac`` and attaches the
245
+ proof headers the gateway checks::
246
+
247
+ X-Cloak-Pretokenised: 1
248
+ X-Cloak-Client-MAC: <HMAC over these exact bytes>
249
+ X-CloakAPI-Routing: client
250
+
251
+ Returns ``(content, headers)``; the caller MUST send ``content``
252
+ verbatim.
253
+
254
+ * Reduced coverage never stops the request. It is stated in
255
+ ``X-Cloak-Coverage`` and ``X-CloakAPI-Tokenized``, shown to the
256
+ reviewer, and the request claims no engine identity.
257
+ * The MAC is fetched on every lane, with the caller's own HTTP client.
258
+ It proves which bytes this key holder prepared; it says nothing about
259
+ which engine ran. If it cannot be obtained the request is not sent
260
+ and the gateway or network error is raised.
261
+ * Plaintext relay does not exist (``tier='off'`` is refused).
262
+ """
263
+ bindings_revision = self._bindings_revision
264
+ try:
265
+ own_state = call_state if call_state is not None else token_state
266
+ built = self._build_relay(
267
+ body, extra_headers, should_tokenize, method=method, path=path, state=own_state
268
+ )
269
+ if own_state is not None:
270
+ own_state["built"] = built # the reply is annotated from this, not from "latest"
271
+ answer = run_review(self, built)
272
+ if inspect.isawaitable(answer):
273
+ try:
274
+ answer = await answer
275
+ except Exception:
276
+ raise ReviewRequiredError() from None
277
+ check_review(answer)
278
+ await self._attach_proof(built)
279
+ return built.content, built.headers
280
+ except BaseException:
281
+ if token_state is not None and self._bindings_revision == bindings_revision:
282
+ self._bindings.clear()
283
+ self._bindings.update(copy.deepcopy(token_state["bindings_before"]))
284
+ self._bindings_revision += 1
285
+ raise
286
+
287
+ def _build_relay(
288
+ self,
289
+ body: dict[str, Any],
290
+ extra_headers: dict[str, str],
291
+ should_tokenize: bool,
292
+ *,
293
+ method: str = "POST",
294
+ path: str = "/api/v1/messages",
295
+ state: dict[str, Any] | None = None,
296
+ ) -> BuiltRelay:
297
+ """Body bytes, headers and notices of one request. No review, no network."""
298
+ return build_relay(
299
+ self,
300
+ body,
301
+ extra_headers,
302
+ should_tokenize,
303
+ method=method,
304
+ path=path,
305
+ engine_available=engine_available,
306
+ require_pinned_engine_identity=require_pinned_engine_identity,
307
+ state=state,
308
+ )
309
+
310
+ async def _attach_proof(self, built: BuiltRelay) -> None:
311
+ """Mint the MAC over the reviewed bytes and attach the proof headers."""
312
+ refuse_plaintext_tier(self)
313
+ mac = await fetch_mac_async(
314
+ self._http_client,
315
+ self.api_key,
316
+ built.content,
317
+ base_url=self._base_url,
318
+ max_retries=self.max_retries,
319
+ timeout=self.timeout,
320
+ )
321
+ built.headers.update(proof_headers(mac))
322
+
323
+ # ── step form: prepare -> approve -> send ────────────────────────────────
324
+
325
+ def _register_prepared(
326
+ self, built: BuiltRelay, *, kind: str, token_map: dict[str, str]
327
+ ) -> Prepared:
328
+ prepared = Prepared(
329
+ text=built.text,
330
+ coverage=built.coverage.value,
331
+ warnings=built.warnings,
332
+ state=built.state,
333
+ method=built.method,
334
+ url=built.url,
335
+ )
336
+ self._pending[id(prepared._handle)] = {
337
+ "prepared": prepared,
338
+ "built": built,
339
+ "kind": kind,
340
+ "token_map": dict(token_map),
341
+ "approved": None,
342
+ }
343
+ return prepared
344
+
345
+ def _prepare_step(
346
+ self,
347
+ body: dict[str, Any],
348
+ extra_headers: dict[str, str],
349
+ should_tokenize: bool,
350
+ *,
351
+ path: str,
352
+ kind: str,
353
+ state: dict[str, Any],
354
+ ) -> Prepared:
355
+ """``prepare()``: build the request and hand it back for review."""
356
+ bindings_revision = self._bindings_revision
357
+ try:
358
+ built = self._build_relay(
359
+ body, extra_headers, should_tokenize, method="POST", path=path, state=state
360
+ )
361
+ except BaseException:
362
+ if self._bindings_revision == bindings_revision:
363
+ self._bindings.clear()
364
+ self._bindings.update(copy.deepcopy(state["bindings_before"]))
365
+ self._bindings_revision += 1
366
+ raise
367
+ return self._register_prepared(built, kind=kind, token_map=state["token_map"])
368
+
369
+ def approve(self, prepared: Prepared) -> Approved:
370
+ """Record that the text of ``prepared`` was reviewed and may be sent.
371
+
372
+ Returns the object :meth:`send` requires. Approving something this
373
+ client did not prepare is ``review_required``.
374
+ """
375
+ entry = self._pending.get(id(getattr(prepared, "_handle", None)))
376
+ if entry is None or entry["prepared"] is not prepared:
377
+ raise ReviewRequiredError()
378
+ approved = Approved(prepared=prepared, text=prepared.text)
379
+ entry["approved"] = approved
380
+ return approved
381
+
382
+ async def send(self, approved: Approved) -> Any:
383
+ """Send a request that :meth:`approve` approved, exactly as it was shown.
384
+
385
+ Anything else (a prepared request that was not approved, an object
386
+ built by hand, text that differs by one byte, a second send of the same
387
+ approval) raises :class:`~cloakapi.errors.ReviewRequiredError` before
388
+ any network call.
389
+ """
390
+ prepared = getattr(approved, "prepared", None)
391
+ entry = self._pending.get(id(getattr(prepared, "_handle", None)))
392
+ if (
393
+ not isinstance(approved, Approved)
394
+ or entry is None
395
+ or entry["approved"] is not approved
396
+ or entry["prepared"] is not prepared
397
+ ):
398
+ raise ReviewRequiredError()
399
+ built: BuiltRelay = entry["built"]
400
+ if approved.text.encode("utf-8") != built.content or prepared.text != approved.text:
401
+ raise ReviewRequiredError()
402
+ del self._pending[id(prepared._handle)]
403
+ # A send that fails must not leave an earlier reply's receipt behind.
404
+ self._last_receipt = None
405
+ await self._attach_proof(built)
406
+ response = await self._unsafe_raw_async_request(
407
+ built.method, built.path, content=built.content, extra_headers=built.headers
408
+ )
409
+ self._record_receipt(response, built)
410
+ data = response_json(response)
411
+ from .types import ChatCompletion, Message
412
+
413
+ if entry["kind"] == "chat":
414
+ data = self._detokenize_chat(data, entry["token_map"])
415
+ return self._annotate(
416
+ validate_response(data, ChatCompletion, endpoint=built.path), built
417
+ )
418
+ data = self._detokenize_message(data, entry["token_map"])
419
+ return self._annotate(validate_response(data, Message, endpoint=built.path), built)
420
+
421
+ def _annotate(self, result: _ReplyT, built: BuiltRelay | None = None) -> _ReplyT:
422
+ """Put this send's coverage and notices on the reply object."""
423
+ coverage = built.coverage if built is not None else self._last_coverage
424
+ warnings = built.warnings if built is not None else self._last_warnings
425
+ with contextlib.suppress(Exception):
426
+ reply: Any = result
427
+ reply.coverage = coverage.value if coverage is not None else None
428
+ reply.warnings = list(warnings)
429
+ reply.receipt = built.receipt if built is not None else self._last_receipt
430
+ return result
431
+
432
+ def _record_receipt(self, response: Any, built: BuiltRelay | None = None) -> None:
433
+ """Keep the receipt the gateway returned with this reply, if any.
434
+
435
+ On the request itself (it is put on the reply as ``reply.receipt``) and
436
+ as ``last_receipt``, which is "the most recent reply on this client".
437
+ """
438
+ receipt = receipt_from_headers(getattr(response, "headers", None))
439
+ if built is not None:
440
+ built.receipt = receipt
441
+ self._last_receipt = receipt
442
+
443
+ @property
444
+ def last_receipt(self) -> dict[str, Any] | None:
445
+ """The signed receipt of the most recent completed non-streaming call.
446
+
447
+ The decoded ``X-CloakAPI-Receipt-V2`` response header, or ``None`` when
448
+ the gateway returned none (it does not for every call), and ``None``
449
+ after a streaming call or a call that failed. Each reply also carries
450
+ its own as ``reply.receipt``; use that when requests overlap. Verify it
451
+ without a network round trip with
452
+ ``client.receipts.verify_offline(receipt, jwks=...)``.
453
+ """
454
+ return self._last_receipt
455
+
456
+ @property
457
+ def last_warnings(self) -> tuple[SendWarning, ...]:
458
+ """Notices of the most recently prepared request (empty when none)."""
459
+ return self._last_warnings
460
+
461
+ # ── PII helpers (identical logic to sync client) ──────────────────────────
462
+
463
+ def _prepare_messages(
464
+ self,
465
+ messages: list[dict[str, Any]],
466
+ should_tokenize: bool,
467
+ *,
468
+ return_state: bool = False,
469
+ ) -> Any:
470
+ extra_headers: dict[str, str] = {}
471
+ # ADR-0015 — attribution app slug rides on EVERY relay call (all tiers).
472
+ if self.app_id:
473
+ extra_headers["X-Cloak-App-Id"] = self.app_id
474
+ if not should_tokenize:
475
+ raise PlaintextRelayNotSupportedError(
476
+ "tokenize=False is not supported for request bodies; enable on-device tokenization."
477
+ )
478
+
479
+ # Every text channel of a message, not only a `str` content — one walker
480
+ # shared with the sync client (see cloakapi._request_strings).
481
+ # token_map is per call (the reply is restored from that call only).
482
+ # bindings is the conversation ledger and must be the same object next turn.
483
+ state: dict[str, Any] = {
484
+ "token_map": {},
485
+ "bindings": self._bindings,
486
+ "bindings_before": copy.deepcopy(self._bindings),
487
+ "coverage": None,
488
+ }
489
+ self._last_coverage = None
490
+ self._last_value_not_replaced = False
491
+ # A receipt belongs to one reply. A call that ends without one (a
492
+ # stream, an error) must not leave the previous call's receipt behind.
493
+ self._last_receipt = None
494
+ tok = bind_tokeniser(self, state)
495
+ try:
496
+ processed = [tokenise_message(msg, tok) for msg in messages]
497
+ except BaseException:
498
+ self._bindings.clear()
499
+ self._bindings.update(copy.deepcopy(state["bindings_before"]))
500
+ self._bindings_revision += 1
501
+ raise
502
+
503
+ extra_headers["X-CloakAPI-Tokenized"] = "true" # coverage is added once it is final
504
+ # Read from this request's own state: the client attributes are "the
505
+ # latest request" and another thread may have reset them meanwhile.
506
+ coverage = state.get("coverage") or Coverage.FULL
507
+ extra_headers["X-Cloak-Coverage"] = coverage.value
508
+ last_mode = state.get("surrogate_mode") or self._last_surrogate_mode
509
+ actual_mode = getattr(last_mode, "mode", last_mode)
510
+ extra_headers["X-CloakAPI-Mode"] = (
511
+ "max" if coverage is Coverage.RULES_ONLY and actual_mode == "max" else self.tier
512
+ )
513
+ if coverage is not Coverage.RULES_ONLY:
514
+ ident = engine_identity()
515
+ if isinstance(ident, dict) and ident.get("version"):
516
+ extra_headers["X-Cloak-Engine-Version"] = ident["version"]
517
+ if ident.get("hash"):
518
+ extra_headers["X-Cloak-Engine-Hash"] = ident["hash"]
519
+ if return_state:
520
+ return processed, extra_headers, state
521
+ return processed, extra_headers
522
+
523
+ def _prepare_body_strings(
524
+ self, body: dict[str, Any], should_tokenize: bool, state: dict[str, Any] | None = None
525
+ ) -> dict[str, Any]:
526
+ """Async twin of :meth:`CloakAPI._prepare_body_strings` for all body text slots."""
527
+ if not should_tokenize:
528
+ return body
529
+ try:
530
+ return tokenise_body_strings(body, bind_tokeniser(self, state))
531
+ except BaseException:
532
+ if state is not None:
533
+ self._bindings.clear()
534
+ self._bindings.update(copy.deepcopy(state["bindings_before"]))
535
+ self._bindings_revision += 1
536
+ raise
537
+
538
+ @property
539
+ def last_surrogate_mode(self) -> SurrogateModeReport | None:
540
+ """What the most recent outbound tokenise pass ACTUALLY did (mode +
541
+ continuity scope). Feed ``.continuity`` into
542
+ :func:`cloakapi.disclosure.with_disclosure`. ``None`` before the first
543
+ tokenised call."""
544
+ return self._last_surrogate_mode
545
+
546
+ @property
547
+ def last_coverage(self) -> Coverage | None:
548
+ """Local detection coverage applied to the most recently prepared relay."""
549
+ return self._last_coverage
550
+
551
+ def _end_user_header(self, end_user: str | None) -> dict[str, str]:
552
+ """TOKENISE, NEVER REFUSE. Identical
553
+ to the sync client: a shape-valid non-PII token goes out exactly as
554
+ supplied, everything else is tokenised HERE before the network, and the
555
+ header is omitted only when there is nothing to attribute."""
556
+ value = end_user_header_value(end_user, self._end_user_secret or self.api_key)
557
+ return {"X-Cloak-End-User": value} if value else {}
558
+
559
+ def _prepare_attachments(
560
+ self,
561
+ attachments: list[Any],
562
+ names: list[str] | None = None,
563
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, str], bool]:
564
+ """Refuse convenience attachments until the caller can review their text."""
565
+ from .attachments import ATTACHMENT_REVIEW_MESSAGE, AttachmentReviewRequiredError
566
+
567
+ raise AttachmentReviewRequiredError(ATTACHMENT_REVIEW_MESSAGE)
568
+
569
+ def _detokenize_message(self, data: dict[str, Any], token_map: TokenMap | None = None) -> dict[str, Any]:
570
+ token_map = self._token_map if token_map is None else token_map
571
+ if not token_map or not isinstance(data, dict):
572
+ return data
573
+ content = data.get("content")
574
+ if isinstance(content, list):
575
+ new_content = []
576
+ for block in content:
577
+ if isinstance(block, dict) and isinstance(block.get("text"), str):
578
+ block = {**block, "text": detokenize(block["text"], token_map)}
579
+ # A tool call the model wrote: its argument VALUES may hold
580
+ # stand-ins, and the caller's tool must receive the real values.
581
+ if isinstance(block, dict) and isinstance(block.get("input"), (dict, list)):
582
+ restored = restore_arguments(block["input"], token_map)
583
+ if restored is not block["input"]:
584
+ block = {**block, "input": restored}
585
+ new_content.append(block)
586
+ data = {**data, "content": new_content}
587
+ return data
588
+
589
+ def _detokenize_chat(self, data: dict[str, Any], token_map: TokenMap | None = None) -> dict[str, Any]:
590
+ token_map = self._token_map if token_map is None else token_map
591
+ if not token_map or not isinstance(data, dict):
592
+ return data
593
+ choices = data.get("choices")
594
+ if not isinstance(choices, list):
595
+ return data
596
+ new_choices = []
597
+ for choice in choices:
598
+ if not isinstance(choice, dict):
599
+ new_choices.append(choice)
600
+ continue
601
+ msg = choice.get("message")
602
+ if isinstance(msg, dict) and isinstance(msg.get("content"), str):
603
+ msg = {**msg, "content": detokenize(msg["content"], token_map)}
604
+ choice = {**choice, "message": msg}
605
+ if isinstance(msg, dict):
606
+ restored_msg = restore_reply_tool_calls(msg, token_map)
607
+ if restored_msg is not msg:
608
+ msg = restored_msg
609
+ choice = {**choice, "message": msg}
610
+ new_choices.append(choice)
611
+ return {**data, "choices": new_choices}
612
+
613
+ def _detokenize_stream_event(self, event: Any) -> Any:
614
+ if not self._token_map:
615
+ return event
616
+ from .types import ContentBlockDelta
617
+
618
+ if isinstance(event, ContentBlockDelta):
619
+ delta = event.delta
620
+ if isinstance(delta, dict) and isinstance(delta.get("text"), str):
621
+ new_delta = {**delta, "text": detokenize(delta["text"], self._token_map)}
622
+ return ContentBlockDelta(type=event.type, index=event.index, delta=new_delta)
623
+ return event
624
+
625
+ def _detokenize_chat_chunk(self, chunk: Any) -> Any:
626
+ if not self._token_map:
627
+ return chunk
628
+ from .types import ChatCompletionChunk, ChunkDelta, StreamChoice
629
+
630
+ new_choices = []
631
+ for choice in chunk.choices:
632
+ delta = choice.delta
633
+ if isinstance(delta.content, str):
634
+ new_delta = ChunkDelta(
635
+ role=delta.role,
636
+ content=detokenize(delta.content, self._token_map),
637
+ )
638
+ choice = StreamChoice(
639
+ index=choice.index,
640
+ delta=new_delta,
641
+ finish_reason=choice.finish_reason,
642
+ )
643
+ new_choices.append(choice)
644
+ return ChatCompletionChunk(
645
+ id=chunk.id,
646
+ object=chunk.object,
647
+ created=chunk.created,
648
+ model=chunk.model,
649
+ choices=new_choices,
650
+ usage=chunk.usage,
651
+ )
652
+
653
+ def _new_stream_detokenizer(self, token_map: TokenMap | None = None) -> Any:
654
+ from .pii.tokenizer import StreamDetokenizer
655
+
656
+ return StreamDetokenizer((self._token_map if token_map is None else token_map) or {})
657
+
658
+ async def adetok_anthropic_stream(self, events: Any, token_map: TokenMap | None = None) -> Any:
659
+ """Boundary-safe Anthropic stream. See :mod:`cloakapi._stream_detok`."""
660
+ from ._stream_detok import adetok_stream, anthropic_shape
661
+
662
+ token_map = self._token_map if token_map is None else token_map
663
+ if not token_map:
664
+ async for e in events:
665
+ yield e
666
+ return
667
+ async for e in adetok_stream(events, self._new_stream_detokenizer(token_map), anthropic_shape()):
668
+ yield e
669
+
670
+ async def adetok_openai_stream(self, chunks: Any, token_map: TokenMap | None = None) -> Any:
671
+ """Boundary-safe OpenAI stream. See :mod:`cloakapi._stream_detok`."""
672
+ from ._stream_detok import adetok_stream, openai_shape
673
+
674
+ token_map = self._token_map if token_map is None else token_map
675
+ if not token_map:
676
+ async for c in chunks:
677
+ yield c
678
+ return
679
+ async for c in adetok_stream(chunks, self._new_stream_detokenizer(token_map), openai_shape()):
680
+ yield c
681
+
682
+ # ── Context manager ──────────────────────────────────────────────────────
683
+
684
+ async def __aenter__(self) -> AsyncCloakAPI:
685
+ return self
686
+
687
+ async def __aexit__(self, *args: Any) -> None:
688
+ await self.aclose()
689
+
690
+ def clear_token_map(self) -> None:
691
+ """Clear the PII token map.
692
+
693
+ Call this between independent conversations to prevent token accumulation
694
+ from leaking context across sessions. Long-lived clients that tokenize
695
+ many requests should call this periodically for privacy hygiene.
696
+ """
697
+ self._token_map = {}
698
+ self._bindings = {}
699
+ self._bindings_revision += 1
700
+
701
+ async def aclose(self) -> None:
702
+ """Close the underlying async HTTP client (only if we created it)."""
703
+ if self._owns_client:
704
+ await self._http_client.aclose()