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/async_client.py
ADDED
|
@@ -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()
|