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,1267 @@
1
+ """
2
+ The request-body CHANNEL LIST for the Python SDK: which strings of a relay request
3
+ carry customer text, walked by ONE module that the sync client, the async client, the
4
+ messages resource, the chat resource, ``create`` and ``stream`` all share.
5
+
6
+ WHY THIS FILE EXISTS
7
+ --------------------
8
+ Until this module, ``_prepare_messages`` tokenised ``messages[].content`` when it was a
9
+ ``str`` and nothing else, and ``MessagesResource.create`` bolted a second, separate
10
+ tokenise call onto ``system=<str>``. Everything else a caller can put text in went out
11
+ as typed, in ``create`` AND ``stream``: the top-level ``metadata``, a message's own
12
+ ``metadata``, ``tool_calls[].function.arguments``, ``tool_use.input``, the ``text`` of a
13
+ list-shaped ``content`` (Anthropic text blocks, OpenAI text parts), ``tool_result``
14
+ content (string or nested text blocks) and a ``system`` given as text blocks. Measured on
15
+ the wire (loopback capture): 44 of 56 probes carried the raw e-mail
16
+ and IBAN. Commit ``cdefce5b7`` told customers every SDK tokenised those channels.
17
+
18
+ Two copies of one rule drift, so the walk lives here once and takes the per-string
19
+ transform as an argument (``tok``). The client binds ``tok`` to its conversation ledger
20
+ (:func:`bind_tokeniser`), so every string in a channel below shares one token map and one
21
+ set of stand-ins: the same e-mail in ``metadata`` and in a message becomes the same token.
22
+
23
+ CONTRACT OF EVERY FUNCTION BELOW
24
+ --------------------------------
25
+ * Only string TEXT is handed to ``tok``. Identifiers and structure (``role``, ``type``,
26
+ ``id``, ``name``, ``tool_use_id``, ``tool_call_id``, image/document ``source``,
27
+ ``cache_control``, ``signature``) are never touched, and dict KEYS are never touched.
28
+ * Copy on change: the caller's objects are never mutated, and a value in which no string
29
+ changed is returned as the SAME object, so PII-free input crosses the wire exactly as
30
+ supplied.
31
+ * Unknown or odd shapes (``None``, numbers, non-dict blocks) pass through unchanged
32
+ instead of raising.
33
+ * Nothing here raises because of what a string CONTAINS. The only refusals are for a
34
+ body that does not have the shape of a request (a message or block that is not an
35
+ object, ``attachments`` that is not a list).
36
+
37
+ WHERE A DETECTED VALUE SITS (one rule for every CloakAPI SDK)
38
+ -------------------------------------------------------------
39
+ * Text the model reads (message text, system, tool results, tool-call argument VALUES,
40
+ descriptions, metadata values, attachment text and names): replaced when the
41
+ engine or the structured rules can replace it. A ``thinking`` block and a
42
+ ``redacted_thinking`` block stay byte for byte as written, with or without a
43
+ ``signature``: the text, the signature and ``data`` are not replaced, so a
44
+ provider signature still covers the bytes it signed. The rules still run over
45
+ that text. Personal data they see is sent as written and carries
46
+ ``unchecked_content`` / ``value_not_replaced``. Thai and Khmer text still
47
+ carries ``unchecked_content`` / ``unverified_script``. The send is not stopped.
48
+ * An object KEY in free-form data that is not request or message ``metadata``
49
+ is replaced when the structured rules match it (:func:`settle_structure`).
50
+ A stand-in that would equal a sibling key is not applied.
51
+ Metadata keys are never tokenised (owner decision F-068, 2026-11-14). The
52
+ value under that key is still tokenised. The send is not stopped.
53
+ * Structure that must stay as it is: a tool name and a ``tool_choice`` name (a
54
+ provider rejects a renamed tool), a key inside tool-call arguments, a tool schema's
55
+ ``enum``/``const``/``default`` literal or property name, an identifier (``id``,
56
+ ``tool_use_id``, ``tool_call_id``, ``type``, ``role``, ``model``), a JSON number.
57
+ Left as written. A value a substantive rule matches carries
58
+ ``unchecked_content`` / ``value_not_replaced``. Bare-word and bare-date matches
59
+ on a tool name, a ``tool_choice`` name or a schema ``enum``/``const``/``default``
60
+ do not: they are not personal data in those places and do not degrade the request.
61
+ * Inline media and media references (image, audio or file bytes, ``data:`` URLs, remote
62
+ URLs, file ids): this path cannot read them. Sent as provided; the request carries
63
+ ``unchecked_content`` / ``media_not_checked``.
64
+
65
+ The notices are recorded on the request's own state (``tok.note_unreplaced``), reach the
66
+ review step before anything is sent, and come back on the reply.
67
+
68
+ NUMERIC IDENTIFIER REVIEW
69
+ -------------------------
70
+ Numbers are not text. Long numeric leaves and exponent-form numbers in metadata,
71
+ tool input, and JSON tool arguments keep their JSON type and are sent with an
72
+ ``unchecked_content`` notice. Leaving a number as written does not lower coverage
73
+ to ``rules_only``: coverage stays what the detector reported.
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import copy
79
+ import json
80
+ import math
81
+ import re
82
+ import threading
83
+ from collections.abc import Callable
84
+ from typing import Any
85
+
86
+ from .coverage import Coverage
87
+ from .errors import RequestBodyRejectedError
88
+ from .pii.tokenizer import detokenize, tokenize_text_ex
89
+
90
+ Tokenise = Callable[[str], str]
91
+ #: For a caller that binds a tokeniser to something that is not a client.
92
+ _SHARED_LOCK = threading.RLock()
93
+ _STRUCTURAL_STRING_KEYS = {"type", "role", "model", "id", "enum"}
94
+ #: Strings that must round-trip byte for byte. Never rewritten; a detected value
95
+ #: in one of them is reported, not replaced.
96
+ _IDENTIFIER_KEYS = {"id", "tool_use_id", "tool_call_id", "type", "role", "model", "signature"}
97
+ #: Leaves of a media object that hold bytes or a reference, not text.
98
+ _OPAQUE_MEDIA_KEYS = {"data", "url", "file_data", "content_b64", "file_id"}
99
+ _SCHEMA_LITERAL_KEYS = ("enum", "const", "default")
100
+ #: Bare codes and bare dates. They match ordinary words (``VALIDATE``, ``PLATFORM``)
101
+ #: and ISO dates used as structure. They do not apply to tool names, ``tool_choice``
102
+ #: names, schema literals or metadata keys.
103
+ _BARE_STRUCTURAL_TYPES = frozenset({"FIN_BIC_SWIFT", "DATE_OF_BIRTH"})
104
+ _TOOL_NAME_BLOCK_TYPES = frozenset({"tool_use", "server_tool_use", "mcp_tool_use", "function_call"})
105
+ #: A provider's own encrypted payload inside a block it returned earlier. Not text.
106
+ _PROVIDER_OPAQUE_KEYS = {"encrypted_content", "encrypted_index"}
107
+
108
+ # Kinds of place where a value is left as written (see the module docstring).
109
+ PLACE_OBJECT_KEY = "object_key"
110
+ PLACE_ARGUMENT_KEY = "tool_argument_key"
111
+ PLACE_TOOL_SCHEMA = "tool_schema"
112
+ PLACE_IDENTIFIER = "identifier"
113
+ PLACE_NUMBER = "number"
114
+ PLACE_MEDIA = "media"
115
+ PLACE_UNKNOWN_BLOCK = "unknown_block"
116
+ PLACE_AS_WRITTEN = "as_written"
117
+ #: Content block types this SDK names. Another type is walked like other blocks
118
+ #: (every string leaf outside protocol fields is tokenised) and reported.
119
+ _KNOWN_BLOCK_TYPES = frozenset({
120
+ "text", "image", "document", "tool_use", "tool_result", "server_tool_use", "mcp_tool_use",
121
+ "thinking", "redacted_thinking", "input_text", "input_image", "input_audio", "input_file",
122
+ "output_text", "refusal", "image_url", "file", "audio", "output_audio", "tool_call", "function_call",
123
+ })
124
+ _JSON_NUMBER = re.compile(r"(?<![\w.])-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?(?![\w.])")
125
+
126
+
127
+ def _has_sensitive_number(value: Any) -> bool:
128
+ """Flag numeric leaves that cannot be tokenised without changing JSON type."""
129
+ if isinstance(value, bool) or value is None:
130
+ return False
131
+ if isinstance(value, int):
132
+ return len(str(abs(value))) > 5
133
+ return isinstance(value, float) and not _gateway_accepts_float(value)
134
+
135
+
136
+ def _gateway_accepts_float(value: float) -> bool:
137
+ """Mirror PrivacyTokenisationMiddleware's `%.15G` numeric primitive check."""
138
+ if not math.isfinite(value) or abs(value) >= 100_000.0:
139
+ return False
140
+ rendered = format(abs(value), ".15G")
141
+ if "E" in rendered:
142
+ return False
143
+ digits = rendered.replace(".", "").lstrip("0")
144
+ return len(digits) <= 5
145
+
146
+
147
+ def _has_sensitive_numbers(value: Any) -> bool:
148
+ """Check whether number leaves need a reduced-coverage review."""
149
+ if isinstance(value, dict):
150
+ return any(_has_sensitive_numbers(item) for item in value.values())
151
+ if isinstance(value, (list, tuple)):
152
+ return any(_has_sensitive_numbers(item) for item in value)
153
+ return _has_sensitive_number(value)
154
+
155
+
156
+ def _has_structured_pii(text: str) -> bool:
157
+ """Check opaque JSON labels with the local structured detector, without rewriting them."""
158
+ from .pii.patterns import detect_pii
159
+
160
+ return bool(detect_pii(text))
161
+
162
+
163
+ def _substantive_pii(text: str) -> bool:
164
+ """A match other than a bare word (BIC/SWIFT) or a bare date.
165
+
166
+ Those two rule types fire on tool names, schema enums and metadata keys that
167
+ are not personal data (``VALIDATE``, ``PLATFORM``, ``2024-01-01``). They do
168
+ not count in those places. Every other rule type still does.
169
+ """
170
+ from .pii.patterns import detect_pii
171
+
172
+ return any(match.pii_type not in _BARE_STRUCTURAL_TYPES for match in detect_pii(text))
173
+
174
+
175
+ def _note_tool_name(value: str, tok: Tokenise) -> None:
176
+ """A tool name is never rewritten. A substantive match is a notice."""
177
+ if _substantive_pii(value):
178
+ _note(tok, PLACE_IDENTIFIER)
179
+
180
+
181
+ def _has_sensitive_tool_argument_number(value: Any) -> bool:
182
+ """Flag OpenAI argument numbers that cannot be rewritten without changing type."""
183
+ if isinstance(value, bool) or value is None:
184
+ return False
185
+ if isinstance(value, int):
186
+ digits = len(str(abs(value)))
187
+ return 6 <= digits <= 16
188
+ elif isinstance(value, float):
189
+ return not _gateway_accepts_float(value)
190
+ elif isinstance(value, dict):
191
+ return any(_has_sensitive_tool_argument_number(item) for item in value.values())
192
+ elif isinstance(value, (list, tuple)):
193
+ return any(_has_sensitive_tool_argument_number(item) for item in value)
194
+ return False
195
+
196
+
197
+ def _has_sensitive_json_number(text: str) -> bool:
198
+ """Flag risky JSON number tokens before parsing loses their original notation."""
199
+ # Ignore quoted strings and keys: digit-heavy user text remains tokenisable text.
200
+ strings_masked = re.sub(r'"(?:[^"\\]|\\.)*"', lambda match: " " * len(match.group(0)), text)
201
+ for match in _JSON_NUMBER.finditer(strings_masked):
202
+ literal = match.group(0)
203
+ if "e" in literal.lower():
204
+ return True
205
+ if "." not in literal and 6 <= len(literal.lstrip("-")) <= 16:
206
+ return True
207
+ if "." in literal:
208
+ number = json.loads(literal)
209
+ if isinstance(number, float) and not _gateway_accepts_float(number):
210
+ return True
211
+ return False
212
+
213
+
214
+ def _mark_reduced_coverage(tok: Tokenise) -> None:
215
+ mark = getattr(tok, "mark_reduced_coverage", None)
216
+ if callable(mark):
217
+ mark()
218
+
219
+
220
+ def _note(tok: Tokenise, place: str) -> None:
221
+ """Record on the request that a value in this kind of place is sent as written."""
222
+ note = getattr(tok, "note_unreplaced", None)
223
+ if callable(note):
224
+ note(place)
225
+
226
+
227
+ def _is_inline_payload(text: str) -> bool:
228
+ """A ``data:`` URL: bytes, not text. Never handed to the detector."""
229
+ return text.startswith("data:") and ";base64," in text[:256]
230
+
231
+
232
+ def tokenise_unclassified(value: Any, tok: Tokenise) -> Any:
233
+ """Tokenise every string in an unclassified JSON field, retaining protocol labels."""
234
+ if isinstance(value, str):
235
+ done = tok(value)
236
+ return value if done == value else done
237
+ if isinstance(value, dict):
238
+ changed = False
239
+ result: dict[Any, Any] = {}
240
+ for key, item in value.items():
241
+ # A structural string is never rewritten. A detected value in one is
242
+ # reported on the request by settle_structure(); it does not stop the send.
243
+ done = (
244
+ item
245
+ if key in _STRUCTURAL_STRING_KEYS or key in _PROVIDER_OPAQUE_KEYS
246
+ else tokenise_unclassified(item, tok)
247
+ )
248
+ result[key] = done
249
+ changed = changed or done is not item
250
+ return result if changed else value
251
+ if isinstance(value, list):
252
+ items = [tokenise_unclassified(item, tok) for item in value]
253
+ return items if any(a is not b for a, b in zip(items, value, strict=True)) else value
254
+ if isinstance(value, tuple):
255
+ items = [tokenise_unclassified(item, tok) for item in value]
256
+ return items if any(a is not b for a, b in zip(items, value, strict=True)) else value
257
+ return value
258
+
259
+
260
+ def bind_tokeniser(client: Any, state: dict[str, Any] | None = None) -> Tokenise:
261
+ """A ``str -> str`` transform bound to ``client``'s conversation ledger.
262
+
263
+ Engine-first (shared FFI engine: shared rules + checksums + NER), pure-Python rules fallback
264
+ when the library is unavailable. The ledger is passed IN and the advanced ledger stored
265
+ back on every call, so a later string (or turn) reuses an earlier stand-in.
266
+ """
267
+
268
+ call_state = state if state is not None else {
269
+ "token_map": client._token_map,
270
+ "bindings": client._bindings,
271
+ "source_texts": [],
272
+ "processed_texts": [],
273
+ }
274
+
275
+ def tok(text: str) -> str:
276
+ if not text:
277
+ # The engine refuses an empty input ("file is empty"), and there is nothing to
278
+ # tokenise: an empty string is a value, not a document. Walking `metadata`, tool
279
+ # arguments and content blocks reaches empty strings far more often than message
280
+ # `content` ever did, and one of them must not fail the whole request. (The Ruby
281
+ # and PHP clients already return an empty string unchanged.)
282
+ return text
283
+ if _is_inline_payload(text):
284
+ # Bytes written as text (an image as a data: URL). Not text the
285
+ # detector can read, and rewriting inside it would corrupt it.
286
+ note_unreplaced(PLACE_MEDIA)
287
+ return text
288
+ # One string at a time per client: read the conversation ledger, run
289
+ # detection, write the advanced ledger back. Two threads sharing a
290
+ # client used to interleave here; the one that finished last wrote a
291
+ # ledger that had never seen the other's values, so a value could lose
292
+ # its stand-in and get another one later. Detection is untouched; the
293
+ # calls are put in order.
294
+ with getattr(client, "_tokenise_lock", None) or _SHARED_LOCK:
295
+ return _tokenise_one(text)
296
+
297
+ def _tokenise_one(text: str) -> str:
298
+ source_texts = call_state.setdefault("source_texts", [])
299
+ processed_texts = call_state.setdefault("processed_texts", [])
300
+ # `_prepare_body_strings` walks messages after `_prepare_messages` has
301
+ # already tokenised them. Those exact output objects are not caller text;
302
+ # avoid treating their generated markers as literal collisions.
303
+ if not any(text is processed for processed in processed_texts):
304
+ source_texts.append(text)
305
+ res = tokenize_text_ex(
306
+ text,
307
+ call_state["token_map"],
308
+ seed_hex=client.seed_hex,
309
+ mode=client.surrogate_mode,
310
+ bindings=call_state["bindings"],
311
+ )
312
+ call_state["token_map"].clear()
313
+ call_state["token_map"].update(res.token_map)
314
+ # A caller may have supplied the same marker literally in this field or
315
+ # an earlier field. Keep it literal by excluding every colliding key
316
+ # from this request's local restore map; never reject the request.
317
+ for marker in tuple(call_state["token_map"]):
318
+ if any(marker and marker in source for source in source_texts):
319
+ del call_state["token_map"][marker]
320
+ processed_texts.append(res.text)
321
+ call_state["bindings"].clear()
322
+ call_state["bindings"].update(res.bindings)
323
+ client._bindings_revision = getattr(client, "_bindings_revision", 0) + 1
324
+ client._last_surrogate_mode = res.surrogate_mode
325
+ prior = call_state.get("coverage")
326
+ result_coverage = getattr(res, "coverage", Coverage.FULL)
327
+ call_state["coverage"] = Coverage.least(prior, result_coverage) if prior else result_coverage
328
+ client._last_coverage = call_state["coverage"]
329
+ # What the detector itself reported, apart from values left as written.
330
+ seen = call_state.get("detector_coverage")
331
+ call_state["detector_coverage"] = (
332
+ Coverage.least(seen, result_coverage) if seen else result_coverage
333
+ )
334
+ # Kept with THIS request (the client attributes above are "the latest
335
+ # request" and are overwritten by an overlapping call): the style that
336
+ # was written, and whether the structured rules prepared any string.
337
+ call_state["surrogate_mode"] = res.surrogate_mode
338
+ if getattr(res, "engine", None) == "regex":
339
+ call_state["rules_prepared"] = True
340
+ return res.text
341
+
342
+ def mark_reduced_coverage() -> None:
343
+ # A number left as written is reported. It does not lower coverage:
344
+ # ``rules_only`` means the name layer did not run, which a number does not decide.
345
+ client._last_value_not_replaced = True
346
+ call_state["value_not_replaced"] = True
347
+ note_unreplaced(PLACE_NUMBER)
348
+
349
+ def note_unreplaced(place: str) -> None:
350
+ places = call_state.setdefault("unreplaced_places", [])
351
+ if place not in places:
352
+ places.append(place)
353
+
354
+ def would_change(texts: list[str]) -> bool:
355
+ """Whether the detector would replace anything in any of ``texts``. Changes
356
+ nothing: it runs on a copy of the request's state (one copy for the whole
357
+ list), so a value that is going to stay as written gets no stand-in."""
358
+ texts = [text for text in texts if text and not _is_inline_payload(text)]
359
+ if not texts:
360
+ return False
361
+ with getattr(client, "_tokenise_lock", None) or _SHARED_LOCK:
362
+ token_map = dict(call_state["token_map"])
363
+ bindings = copy.deepcopy(call_state["bindings"])
364
+ for text in texts:
365
+ res = tokenize_text_ex(
366
+ text,
367
+ token_map,
368
+ seed_hex=client.seed_hex,
369
+ mode=client.surrogate_mode,
370
+ bindings=bindings,
371
+ )
372
+ if res.text != text:
373
+ return True
374
+ return False
375
+
376
+ tok.mark_reduced_coverage = mark_reduced_coverage # type: ignore[attr-defined]
377
+ tok.note_unreplaced = note_unreplaced # type: ignore[attr-defined]
378
+ tok.would_change = would_change # type: ignore[attr-defined]
379
+ tok.state = call_state # type: ignore[attr-defined]
380
+ return tok
381
+
382
+
383
+ # ── generic JSON-shaped values ───────────────────────────────────────────────
384
+
385
+
386
+ def tokenise_leaves(value: Any, tok: Tokenise) -> Any:
387
+ """Tokenise every string leaf of an arbitrary JSON-shaped value (dict values, list items).
388
+
389
+ Non-strings (numbers, booleans, ``None``) and dict keys are left as supplied.
390
+ """
391
+ if isinstance(value, str):
392
+ out = tok(value)
393
+ return value if out == value else out
394
+ if isinstance(value, dict):
395
+ changed = False
396
+ new: dict[Any, Any] = {}
397
+ for key, item in value.items():
398
+ done = tokenise_leaves(item, tok)
399
+ changed = changed or done is not item
400
+ new[key] = done
401
+ return new if changed else value
402
+ if isinstance(value, (list, tuple)):
403
+ items = [tokenise_leaves(item, tok) for item in value]
404
+ if any(done is not item for done, item in zip(items, value, strict=True)):
405
+ return items
406
+ return value
407
+ return value
408
+
409
+
410
+ # A JSON string literal. In VALID JSON every `"` outside a literal opens one, so scanning left to
411
+ # right finds the literals unambiguously; a literal followed by `:` is an object KEY.
412
+ _JSON_STRING = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
413
+ _KEY_COLON = re.compile(r"\s*:")
414
+
415
+
416
+ def _splice_json_strings(text: str, tok: Tokenise) -> str:
417
+ """Tokenise the string VALUES of a valid JSON text IN PLACE, leaving every other byte as is.
418
+
419
+ Each value literal is decoded, tokenised, and (only when it changed) written back as a fresh
420
+ literal. Whitespace, key order, numbers (``12345678901234567890``, ``1.0``, ``1e2``), ``{}``
421
+ against ``[]`` and object KEYS are the caller's own bytes. Decoding a literal first is what
422
+ lets the detector see ``Bj\\u00f8rn`` as ``Bjørn``.
423
+ """
424
+
425
+ def repl(match: re.Match[str]) -> str:
426
+ if _KEY_COLON.match(text, match.end()):
427
+ return match.group(0)
428
+ literal = match.group(0)
429
+ value = json.loads(literal)
430
+ out = tok(value)
431
+ return literal if out == value else json.dumps(out, ensure_ascii=False)
432
+
433
+ return _JSON_STRING.sub(repl, text)
434
+
435
+
436
+ def tokenise_arguments(arguments: Any, tok: Tokenise) -> Any:
437
+ """OpenAI ``tool_calls[].function.arguments``: a JSON STRING (or, for some callers, an object).
438
+
439
+ A JSON string has its string VALUES tokenised in place (:func:`_splice_json_strings`); when
440
+ none changed, the caller's own string comes back untouched. Malformed JSON is tokenised as
441
+ plain text; valid JSON beyond the decoder's recursion limit uses the key-preserving literal
442
+ walker. An object/list is walked leaf by leaf.
443
+ """
444
+ if isinstance(arguments, str):
445
+ try:
446
+ json.loads(arguments)
447
+ except RecursionError:
448
+ # A valid but deeply nested JSON value can exceed the decoder's
449
+ # recursion limit. The literal scanner is iterative and still
450
+ # distinguishes keys from string values, so do not text-tokenise
451
+ # the whole document and rewrite object keys.
452
+ return _splice_json_strings(arguments, tok)
453
+ except ValueError:
454
+ return tok(arguments)
455
+ return _splice_json_strings(arguments, tok)
456
+ if isinstance(arguments, int) and not isinstance(arguments, bool):
457
+ # A root number stays a number (owner decision 2026-11-13). The caller
458
+ # already recorded value_not_replaced when the digits look like personal
459
+ # data. Replacing it would change the JSON type.
460
+ return arguments
461
+ return tokenise_leaves(arguments, tok)
462
+
463
+
464
+ # ── message content and blocks ───────────────────────────────────────────────
465
+
466
+
467
+ def _tokenise_block(block: Any, tok: Tokenise) -> Any:
468
+ """One content block (Anthropic block, OpenAI content part). Text-bearing keys only."""
469
+ if not isinstance(block, dict):
470
+ raise RequestBodyRejectedError()
471
+ block_type = block.get("type")
472
+ if block_type == "redacted_thinking":
473
+ # Provider blob. Not text this pass can rewrite. A value the rules still
474
+ # match is sent as written and named; the request is not stopped.
475
+ data = block.get("data")
476
+ if isinstance(data, str) and _substantive_pii(data):
477
+ _note(tok, PLACE_AS_WRITTEN)
478
+ return block
479
+ if block_type == "thinking":
480
+ # THINKING-AS-WRITTEN. The provider signs these bytes. Replacing the
481
+ # text and leaving ``signature`` makes the two disagree, so the block
482
+ # is returned as the caller supplied it, signature or not. The rules
483
+ # still run over the thinking text. A substantive match is sent as
484
+ # written and named. A reply does not restore thinking.
485
+ thinking = block.get("thinking")
486
+ if isinstance(thinking, str) and _substantive_pii(thinking):
487
+ _note(tok, PLACE_AS_WRITTEN)
488
+ return block
489
+ if isinstance(block_type, str) and block_type not in _KNOWN_BLOCK_TYPES:
490
+ _note(tok, PLACE_UNKNOWN_BLOCK)
491
+ # A block type this SDK does not know (a provider adds them: a file part, a
492
+ # server tool call and its result, a search result, a refusal) is a valid
493
+ # request, not a malformed one. It is walked like every other block: its
494
+ # text-bearing strings are tokenised, bytes and references are left as
495
+ # provided and reported as media. Refusing it stopped a caller from
496
+ # continuing a conversation that holds such a block.
497
+ new: dict[str, Any] | None = None
498
+
499
+ def put(key: str, value: Any) -> None:
500
+ nonlocal new
501
+ if new is None:
502
+ new = dict(block)
503
+ new[key] = value
504
+
505
+ text = block.get("text")
506
+ if isinstance(text, str):
507
+ done = tok(text)
508
+ if done != text:
509
+ put("text", done)
510
+ name = block.get("name")
511
+ if isinstance(name, str):
512
+ if block.get("type") in _TOOL_NAME_BLOCK_TYPES:
513
+ _note_tool_name(name, tok)
514
+ else:
515
+ done = tok(name)
516
+ if done != name:
517
+ put("name", done)
518
+ for key in ("function_call", "audio", "input_audio", "image_url", "file"):
519
+ if key in block:
520
+ value = block[key]
521
+ done = _tokenise_text_leaves_of_media(value, tok)
522
+ if done is not value:
523
+ put(key, done)
524
+ # A document whose source is text (not bytes) is text for the model.
525
+ source = block.get("source")
526
+ if isinstance(source, dict):
527
+ if source.get("type") == "text" and isinstance(source.get("data"), str):
528
+ done = tok(source["data"])
529
+ if done != source["data"]:
530
+ put("source", {**source, "data": done})
531
+ elif source.get("type") == "content" and "content" in source:
532
+ done = tokenise_content(source["content"], tok)
533
+ if done is not source["content"]:
534
+ put("source", {**source, "content": done})
535
+ elif isinstance(source, str) and source:
536
+ # A string source is the citation source of a search result: text, not media.
537
+ done = tok(source)
538
+ if done != source:
539
+ put("source", done)
540
+ # Anthropic tool_use / server_tool_use / mcp_tool_use: the arguments are an OBJECT in
541
+ # `input`. Keyed on the presence of a container `input`, not on `type`, so block types
542
+ # Anthropic adds next are covered too.
543
+ tool_input = block.get("input")
544
+ if isinstance(tool_input, (dict, list, tuple)):
545
+ if _has_sensitive_numbers(tool_input):
546
+ _mark_reduced_coverage(tok)
547
+ done = tokenise_leaves(tool_input, tok)
548
+ if done is not tool_input:
549
+ put("input", done)
550
+ # Anthropic tool_result nests its own `content` (a string or a list of blocks).
551
+ if "content" in block:
552
+ nested = block["content"]
553
+ done = tokenise_content(nested, tok)
554
+ if done is not nested:
555
+ put("content", done)
556
+ result = block if new is None else new
557
+ classified = {"type", "text", "name", "function_call", "audio", "input_audio", "image_url", "file", "thinking", "input", "content", "source", "cache_control", "signature", "id", "tool_use_id", "tool_call_id"}
558
+ for key, value in block.items():
559
+ if key not in classified and key not in _PROVIDER_OPAQUE_KEYS:
560
+ done = tokenise_unclassified(value, tok)
561
+ if done is not value:
562
+ if result is block:
563
+ result = dict(block)
564
+ result[key] = done
565
+ return result
566
+
567
+
568
+ def tokenise_content(content: Any, tok: Tokenise) -> Any:
569
+ """Message ``content``: text, or list/tuple content blocks (recursive).
570
+
571
+ ``json.dumps`` serialises tuples as arrays too, so walk them just like lists.
572
+ """
573
+ if isinstance(content, str):
574
+ out = tok(content)
575
+ return content if out == content else out
576
+ if isinstance(content, dict):
577
+ return _tokenise_block(content, tok)
578
+ if isinstance(content, (list, tuple)):
579
+ blocks = [_tokenise_block(block, tok) for block in content]
580
+ if any(done is not block for done, block in zip(blocks, content, strict=True)):
581
+ return blocks
582
+ return content
583
+
584
+
585
+ def tokenise_message(message: Any, tok: Tokenise) -> Any:
586
+ """Tokenise every free-text slot on an OpenAI/Anthropic message."""
587
+ if not isinstance(message, dict):
588
+ raise RequestBodyRejectedError()
589
+ new: dict[str, Any] | None = None
590
+
591
+ def put(key: str, value: Any) -> None:
592
+ nonlocal new
593
+ if new is None:
594
+ new = dict(message)
595
+ new[key] = value
596
+
597
+ if "content" in message:
598
+ content = message["content"]
599
+ done = tokenise_content(content, tok)
600
+ if done is not content:
601
+ put("content", done)
602
+ if "metadata" in message:
603
+ meta = message["metadata"]
604
+ if _has_sensitive_numbers(meta):
605
+ _mark_reduced_coverage(tok)
606
+ done = tokenise_leaves(meta, tok)
607
+ if done is not meta:
608
+ put("metadata", done)
609
+ # These fields are in the TypeScript request-body slot list. They can carry
610
+ # user supplied prose even though they are usually treated as identifiers.
611
+ for key in ("name", "function_call", "audio", "input_audio"):
612
+ if key not in message:
613
+ continue
614
+ value = message[key]
615
+ if key == "function_call":
616
+ done = _tokenise_legacy_function_call(value, tok)
617
+ elif key in ("audio", "input_audio") or key == "attachments":
618
+ done = _tokenise_text_leaves_of_media(value, tok)
619
+ else:
620
+ done = tok(value) if isinstance(value, str) else value
621
+ if done is not value:
622
+ put(key, done)
623
+ if "attachments" in message:
624
+ done = tokenise_attachments(message["attachments"], tok)
625
+ if done is not message["attachments"]:
626
+ put("attachments", done)
627
+ calls = message.get("tool_calls")
628
+ if isinstance(calls, (list, tuple)):
629
+ new_calls = [_tokenise_tool_call(call, tok) for call in calls]
630
+ if any(done is not call for done, call in zip(new_calls, calls, strict=True)):
631
+ put("tool_calls", new_calls)
632
+ classified = {"role", "content", "metadata", "name", "function_call", "audio", "input_audio", "attachments", "tool_calls", "tool_call_id", "tool_use_id", "id", "type"}
633
+ for key, value in message.items():
634
+ if key not in classified:
635
+ done = tokenise_unclassified(value, tok)
636
+ if done is not value:
637
+ put(key, done)
638
+ return message if new is None else new
639
+
640
+
641
+ def _tokenise_tool_call(call: Any, tok: Tokenise) -> Any:
642
+ """OpenAI tool call: keep the function name as written (a match is a notice), tokenise the arguments."""
643
+ if not isinstance(call, dict):
644
+ raise RequestBodyRejectedError()
645
+ fn = call.get("function")
646
+ if not isinstance(fn, dict):
647
+ raise RequestBodyRejectedError()
648
+ new_fn = dict(fn)
649
+ changed = False
650
+ for key in ("name", "description", "arguments"):
651
+ if key not in fn:
652
+ continue
653
+ value = fn[key]
654
+ if key == "arguments":
655
+ if isinstance(value, str):
656
+ try:
657
+ json.loads(value)
658
+ except (ValueError, RecursionError):
659
+ pass
660
+ else:
661
+ if _has_sensitive_json_number(value):
662
+ _mark_reduced_coverage(tok)
663
+ else:
664
+ if _has_sensitive_tool_argument_number(value):
665
+ _mark_reduced_coverage(tok)
666
+ done = tokenise_arguments(value, tok)
667
+ elif key == "description":
668
+ done = tokenise_unclassified(value, tok)
669
+ elif isinstance(value, str):
670
+ _note_tool_name(value, tok)
671
+ done = value
672
+ else:
673
+ done = value
674
+ if done is not value:
675
+ new_fn[key] = done
676
+ changed = True
677
+ for key, value in fn.items():
678
+ if key not in {"name", "description", "arguments"}:
679
+ done = tokenise_unclassified(value, tok)
680
+ if done is not value:
681
+ new_fn[key] = done
682
+ changed = True
683
+ new_call = call
684
+ for key, value in call.items():
685
+ if key not in {"function", "id", "type"}:
686
+ done = tokenise_unclassified(value, tok)
687
+ if done is not value:
688
+ if new_call is call:
689
+ new_call = dict(call)
690
+ new_call[key] = done
691
+ changed = True
692
+ if not changed:
693
+ return call
694
+ return {**new_call, "function": new_fn}
695
+
696
+
697
+ # ── top-level channels of the relay body ─────────────────────────────────────
698
+
699
+
700
+ def tokenise_system(system: Any, tok: Tokenise) -> Any:
701
+ """Anthropic ``system``: a string or list/tuple of text blocks."""
702
+ if isinstance(system, str):
703
+ out = tok(system)
704
+ return system if out == system else out
705
+ if isinstance(system, (list, tuple)):
706
+ parts = [_tokenise_system_part(part, tok) for part in system]
707
+ if any(done is not part for done, part in zip(parts, system, strict=True)):
708
+ return parts
709
+ if isinstance(system, dict):
710
+ return _tokenise_system_part(system, tok)
711
+ return system
712
+
713
+
714
+ def _tokenise_system_part(part: Any, tok: Tokenise) -> Any:
715
+ if not isinstance(part, dict):
716
+ raise RequestBodyRejectedError()
717
+ result = dict(part)
718
+ changed = False
719
+ text = part.get("text")
720
+ if isinstance(text, str):
721
+ done = tok(text)
722
+ if done != text:
723
+ result["text"] = done
724
+ changed = True
725
+ if "content" in part:
726
+ done = tokenise_content(part["content"], tok)
727
+ if done is not part["content"]:
728
+ result["content"] = done
729
+ changed = True
730
+ for key, value in part.items():
731
+ if key not in {"type", "text", "content", "cache_control", "signature", "id"}:
732
+ done = tokenise_unclassified(value, tok)
733
+ if done is not value:
734
+ result[key] = done
735
+ changed = True
736
+ return result if changed else part
737
+
738
+
739
+ def tokenise_body_strings(body: dict[str, Any], tok: Tokenise) -> dict[str, Any]:
740
+ """Tokenise body text slots not already handled by message traversal.
741
+
742
+ Structural fields stay untouched; a schema contract literal is left as written and
743
+ reported on the request. Returns a new dict only when a string changed.
744
+ """
745
+ new: dict[str, Any] | None = None
746
+ for key, walk in (("system", tokenise_system), ("metadata", tokenise_leaves),
747
+ ("user", tokenise_leaves),
748
+ ("clipboard", tokenise_leaves), ("preprompt", tokenise_leaves),
749
+ ("prompt", tokenise_leaves), ("tool_choice", tokenise_tool_choice)):
750
+ if key not in body:
751
+ continue
752
+ if key == "metadata" and _has_sensitive_numbers(body[key]):
753
+ _mark_reduced_coverage(tok)
754
+ done = walk(body[key], tok)
755
+ if done is not body[key]:
756
+ if new is None:
757
+ new = dict(body)
758
+ new[key] = done
759
+ for key in ("attachments",):
760
+ if key in body:
761
+ done = tokenise_attachments(body[key], tok)
762
+ if done is not body[key]:
763
+ if new is None:
764
+ new = dict(body)
765
+ new[key] = done
766
+ if "stop_sequences" in body:
767
+ done = tokenise_unclassified(body["stop_sequences"], tok)
768
+ if done is not body["stop_sequences"]:
769
+ if new is None:
770
+ new = dict(body)
771
+ new["stop_sequences"] = done
772
+ tools = body.get("tools")
773
+ if isinstance(tools, (list, tuple)):
774
+ # Tool definitions have prose and identifiers. Walk text fields and
775
+ # schema descriptions/examples, but reject a schema contract literal if
776
+ # tokenising it would silently change the tool's meaning.
777
+ processed = []
778
+ changed = False
779
+ for tool in tools:
780
+ if not isinstance(tool, dict):
781
+ raise RequestBodyRejectedError()
782
+ done = _tokenise_tool_definition(tool, tok)
783
+ processed.append(done)
784
+ changed = changed or done is not tool
785
+ if changed:
786
+ if new is None:
787
+ new = dict(body)
788
+ new["tools"] = processed
789
+ classified = {"model", "messages", "system", "metadata", "user", "clipboard", "preprompt", "prompt", "tool_choice", "attachments", "tools", "stop_sequences"}
790
+ for key, value in body.items():
791
+ if key not in classified:
792
+ done = tokenise_unclassified(value, tok)
793
+ if done is not value:
794
+ if new is None:
795
+ new = dict(body)
796
+ new[key] = done
797
+ return body if new is None else new
798
+
799
+
800
+ def assert_no_thai_khmer(value: Any) -> bool:
801
+ """Return whether this request contains Thai or Khmer text."""
802
+ if isinstance(value, str):
803
+ return any(
804
+ "\u0e00" <= char <= "\u0e7f"
805
+ or "\u1780" <= char <= "\u17ff"
806
+ or "\u19e0" <= char <= "\u19ff"
807
+ for char in value
808
+ )
809
+ found = False
810
+ if isinstance(value, dict):
811
+ for key, item in value.items():
812
+ found = assert_no_thai_khmer(key) or found
813
+ found = assert_no_thai_khmer(item) or found
814
+ elif isinstance(value, (list, tuple)):
815
+ for item in value:
816
+ found = assert_no_thai_khmer(item) or found
817
+ return found
818
+
819
+
820
+ def tokenise_attachments(value: Any, tok: Tokenise) -> Any:
821
+ """Walk the text/name fields of attachments. Bytes and URLs are left as provided."""
822
+ if not isinstance(value, list):
823
+ raise RequestBodyRejectedError()
824
+ output = []
825
+ changed = False
826
+ for attachment in value:
827
+ if not isinstance(attachment, dict):
828
+ raise RequestBodyRejectedError()
829
+ next_attachment = dict(attachment)
830
+ item_changed = False
831
+ for key in ("text", "name", "filename"):
832
+ text = attachment.get(key)
833
+ if isinstance(text, str):
834
+ done = tok(text)
835
+ if done != text:
836
+ next_attachment[key] = done
837
+ item_changed = True
838
+ for key, value in attachment.items():
839
+ if key not in {"text", "name", "filename", "id", "type"} | _OPAQUE_MEDIA_KEYS:
840
+ done = tokenise_unclassified(value, tok)
841
+ if done is not value:
842
+ next_attachment[key] = done
843
+ item_changed = True
844
+ output.append(next_attachment if item_changed else attachment)
845
+ changed = changed or item_changed
846
+ return output if changed else value
847
+
848
+
849
+ def _tokenise_legacy_function_call(value: Any, tok: Tokenise) -> Any:
850
+ """OpenAI ``function_call``: the name stays; argument values are tokenised."""
851
+ if not isinstance(value, dict):
852
+ return _tokenise_text_leaves_of_media(value, tok)
853
+ result = value
854
+ name = value.get("name")
855
+ if isinstance(name, str):
856
+ _note_tool_name(name, tok)
857
+ if "arguments" in value:
858
+ done = tokenise_arguments(value["arguments"], tok)
859
+ if done is not value["arguments"]:
860
+ result = {**value, "arguments": done}
861
+ for key, item in value.items():
862
+ if key in {"name", "arguments"}:
863
+ continue
864
+ changed = tokenise_unclassified(item, tok)
865
+ if changed is not item:
866
+ if result is value:
867
+ result = dict(value)
868
+ result[key] = changed
869
+ return result
870
+
871
+
872
+ def tokenise_tool_choice(value: Any, tok: Tokenise) -> Any:
873
+ """A ``tool_choice`` name is never rewritten. A substantive match is a notice."""
874
+ if isinstance(value, str):
875
+ _note_tool_name(value, tok)
876
+ return value
877
+ if not isinstance(value, dict):
878
+ raise RequestBodyRejectedError()
879
+ result = dict(value)
880
+ changed = False
881
+ if isinstance(result.get("name"), str):
882
+ _note_tool_name(result["name"], tok)
883
+ fn = result.get("function")
884
+ if isinstance(fn, dict):
885
+ next_fn = dict(fn)
886
+ if isinstance(next_fn.get("name"), str):
887
+ _note_tool_name(next_fn["name"], tok)
888
+ for key, item in fn.items():
889
+ if key == "name":
890
+ continue
891
+ done = tokenise_unclassified(item, tok)
892
+ if done is not item:
893
+ next_fn[key] = done
894
+ changed = True
895
+ if changed:
896
+ result["function"] = next_fn
897
+ for key, item in value.items():
898
+ if key in {"name", "function"}:
899
+ continue
900
+ done = tokenise_unclassified(item, tok)
901
+ if done is not item:
902
+ result[key] = done
903
+ changed = True
904
+ return result if changed else value
905
+
906
+
907
+ def _tokenise_text_leaves_of_media(value: Any, tok: Tokenise) -> Any:
908
+ """String leaves of a media or call object, except the leaves that hold bytes or a reference."""
909
+ if isinstance(value, str):
910
+ out = tok(value)
911
+ return value if out == value else out
912
+ if isinstance(value, dict):
913
+ changed = False
914
+ new: dict[Any, Any] = {}
915
+ for key, item in value.items():
916
+ done = item if key in _OPAQUE_MEDIA_KEYS else _tokenise_text_leaves_of_media(item, tok)
917
+ changed = changed or done is not item
918
+ new[key] = done
919
+ return new if changed else value
920
+ if isinstance(value, (list, tuple)):
921
+ items = [_tokenise_text_leaves_of_media(item, tok) for item in value]
922
+ if any(done is not item for done, item in zip(items, value, strict=True)):
923
+ return items
924
+ return value
925
+
926
+
927
+ def _holds_media(value: dict[Any, Any], parent: str) -> bool:
928
+ """Whether this object carries bytes or a reference to media this path did not read."""
929
+ source = value.get("source")
930
+ if (
931
+ isinstance(source, dict)
932
+ and source.get("type") not in ("text", "content")
933
+ and any(source.get(key) for key in ("data", "url", "file_id"))
934
+ ):
935
+ return True
936
+ image_url = value.get("image_url")
937
+ if (isinstance(image_url, dict) and image_url.get("url")) or (
938
+ isinstance(image_url, str) and image_url
939
+ ):
940
+ return True
941
+ file = value.get("file")
942
+ if isinstance(file, dict) and any(file.get(key) for key in ("file_data", "file_id")):
943
+ return True
944
+ for key in ("input_audio", "audio"):
945
+ audio = value.get(key)
946
+ if isinstance(audio, dict) and any(audio.get(k) for k in ("data", "id", "url")):
947
+ return True
948
+ return parent == "attachments" and any(value.get(key) for key in _OPAQUE_MEDIA_KEYS)
949
+
950
+
951
+ def _json_text_keys(text: str) -> list[str]:
952
+ """The object keys of a valid JSON text (empty for anything else)."""
953
+ try:
954
+ json.loads(text)
955
+ except (ValueError, RecursionError):
956
+ return []
957
+ keys = []
958
+ for match in _JSON_STRING.finditer(text):
959
+ if _KEY_COLON.match(text, match.end()):
960
+ try:
961
+ key = json.loads(match.group(0))
962
+ except ValueError:
963
+ continue
964
+ if isinstance(key, str):
965
+ keys.append(key)
966
+ return keys
967
+
968
+
969
+ _TOOL_USE_BLOCK_TYPES = ("tool_use", "server_tool_use", "mcp_tool_use")
970
+
971
+
972
+ def _is_tool_argument_place(trail: tuple[str, ...], key: str, holder: dict[Any, Any], item: Any) -> bool:
973
+ """Whether ``holder[key]`` is tool-call argument data.
974
+
975
+ Only a message carries it: the ``arguments`` of ``messages[].tool_calls[].function`` or
976
+ of legacy ``messages[].function_call``, and the ``input`` of a tool_use block in message
977
+ content (also inside tool_result content). ``trail`` is the path from the body root,
978
+ with ``[]`` for a list index. A property that merely has one of those names elsewhere
979
+ (``metadata``, ``user``, ...) is free-form data, so its keys are replaced like any
980
+ other key (F-001).
981
+ """
982
+ if len(trail) < 2 or trail[0] != "messages" or trail[1] != "[]":
983
+ return False
984
+ rest = trail[2:]
985
+ if key == "arguments":
986
+ return rest == ("tool_calls", "[]", "function") or rest == ("function_call",)
987
+ if key != "input" or holder.get("type") not in _TOOL_USE_BLOCK_TYPES:
988
+ return False
989
+ return (
990
+ isinstance(item, (dict, list, tuple))
991
+ and len(rest) >= 2
992
+ and len(rest) % 2 == 0
993
+ and all(segment == ("content" if index % 2 == 0 else "[]") for index, segment in enumerate(rest))
994
+ )
995
+
996
+
997
+ def _child_context(key: str, item: Any, holder: dict[Any, Any], parent: str, trail: tuple[str, ...]) -> str:
998
+ """Which rule applies below ``key``: tool-call arguments, a tool schema, or free-form data."""
999
+ if _is_tool_argument_place(trail, key, holder, item):
1000
+ return "arguments"
1001
+ if (
1002
+ key == "input_schema"
1003
+ or (key == "parameters" and parent == "function")
1004
+ or (key == "schema" and parent == "json_schema")
1005
+ ):
1006
+ return "schema"
1007
+ return "free"
1008
+
1009
+
1010
+ def settle_structure(body: Any, tok: Tokenise) -> Any:
1011
+ """Last pass over a body whose text has been tokenised: keys, identifiers, literals, media.
1012
+
1013
+ * A key in free-form data that the structured rules match is REPLACED where it stands,
1014
+ except a metadata key, which is never renamed (owner decision F-068, 2026-11-14).
1015
+ Keys inside tool-call arguments and tool schemas follow the tool's contract and
1016
+ are not renamed.
1017
+ * Everything else that still holds a detected value in a place that must not be
1018
+ rewritten is noted on the request (``tok.note_unreplaced``), as is media.
1019
+
1020
+ Never raises for content. Returns the same object when no key was renamed.
1021
+ """
1022
+ state = getattr(tok, "state", None)
1023
+ token_map = state.get("token_map", {}) if isinstance(state, dict) else {}
1024
+
1025
+ def detected(text: str) -> bool:
1026
+ # Short structural strings only; a stand-in this request wrote is not a detection.
1027
+ if not text or len(text) > 512:
1028
+ return False
1029
+ for marker in token_map:
1030
+ if marker and marker in text:
1031
+ text = text.replace(marker, " ")
1032
+ return _has_structured_pii(text)
1033
+
1034
+ def _substantive_literal(value: Any) -> bool:
1035
+ """Schema literal that a rule other than a bare word or a bare date matches."""
1036
+ if isinstance(value, str):
1037
+ return _substantive_pii(value)
1038
+ if isinstance(value, dict):
1039
+ return any(_substantive_literal(item) for item in value.values())
1040
+ if isinstance(value, (list, tuple)):
1041
+ return any(_substantive_literal(item) for item in value)
1042
+ return False
1043
+
1044
+ def visit(value: Any, ctx: str, parent: str, trail: tuple[str, ...], *, metadata_keys: bool = False) -> Any:
1045
+ if isinstance(value, (list, tuple)):
1046
+ items = [visit(item, ctx, parent, trail + ("[]",), metadata_keys=metadata_keys) for item in value]
1047
+ if any(done is not item for done, item in zip(items, value, strict=True)):
1048
+ return items
1049
+ return value
1050
+ if not isinstance(value, dict):
1051
+ return value
1052
+ if _holds_media(value, parent):
1053
+ _note(tok, PLACE_MEDIA)
1054
+ out: dict[Any, Any] = {}
1055
+ changed = False
1056
+ for key, item in value.items():
1057
+ new_key = key
1058
+ child_ctx = ctx
1059
+ child_metadata = metadata_keys or key == "metadata"
1060
+ if isinstance(key, str):
1061
+ if ctx == "free":
1062
+ child_ctx = _child_context(key, item, value, parent, trail)
1063
+ if detected(key) and not metadata_keys:
1064
+ if ctx == "free":
1065
+ renamed = tok(key)
1066
+ if renamed != key and renamed not in value and renamed not in out:
1067
+ new_key = renamed
1068
+ changed = True
1069
+ else:
1070
+ _note(tok, PLACE_OBJECT_KEY)
1071
+ elif ctx == "arguments":
1072
+ _note(tok, PLACE_ARGUMENT_KEY)
1073
+ else:
1074
+ _note(tok, PLACE_TOOL_SCHEMA)
1075
+ if key in _IDENTIFIER_KEYS and isinstance(item, str) and detected(item):
1076
+ _note(tok, PLACE_IDENTIFIER)
1077
+ if (
1078
+ key in _SCHEMA_LITERAL_KEYS
1079
+ and (ctx == "schema" or key == "enum")
1080
+ and _substantive_literal(item)
1081
+ ):
1082
+ _note(tok, PLACE_TOOL_SCHEMA)
1083
+ if (
1084
+ child_ctx == "arguments"
1085
+ and isinstance(item, str)
1086
+ and any(detected(name) for name in _json_text_keys(item))
1087
+ ):
1088
+ _note(tok, PLACE_ARGUMENT_KEY)
1089
+ done = visit(
1090
+ item,
1091
+ child_ctx,
1092
+ key if isinstance(key, str) else "",
1093
+ trail + (key if isinstance(key, str) else "?",),
1094
+ metadata_keys=child_metadata,
1095
+ )
1096
+ changed = changed or done is not item
1097
+ out[new_key] = done
1098
+ return out if changed else value
1099
+
1100
+ return visit(body, "free", "", ())
1101
+
1102
+
1103
+ def assert_no_inline_media(value: Any) -> None:
1104
+ """Kept for 0.4.x imports. Inline media no longer stops a send; see settle_structure()."""
1105
+ return None
1106
+
1107
+
1108
+ # ── restore in a reply ───────────────────────────────────────────────────────
1109
+
1110
+
1111
+ def restore_arguments(arguments: Any, token_map: dict[str, str]) -> Any:
1112
+ """Put original values back into the string VALUES of reply tool-call arguments.
1113
+
1114
+ A JSON string keeps every other byte; an object is walked leaf by leaf. Keys are
1115
+ not touched. Text that is not JSON is restored as plain text.
1116
+ """
1117
+ if not token_map:
1118
+ return arguments
1119
+
1120
+ def back(text: str) -> str:
1121
+ return detokenize(text, token_map)
1122
+
1123
+ if isinstance(arguments, str):
1124
+ try:
1125
+ json.loads(arguments)
1126
+ except (ValueError, RecursionError):
1127
+ return back(arguments)
1128
+ return _splice_json_strings(arguments, back)
1129
+ return tokenise_leaves(arguments, back)
1130
+
1131
+
1132
+ def restore_reply_tool_calls(message: dict[str, Any], token_map: dict[str, str]) -> dict[str, Any]:
1133
+ """A chat-completion reply message with the argument values of its tool calls restored."""
1134
+ result = message
1135
+ calls = message.get("tool_calls")
1136
+ if isinstance(calls, list):
1137
+ new_calls = []
1138
+ for call in calls:
1139
+ fn = call.get("function") if isinstance(call, dict) else None
1140
+ if isinstance(fn, dict) and "arguments" in fn:
1141
+ restored = restore_arguments(fn["arguments"], token_map)
1142
+ if restored is not fn["arguments"] and restored != fn["arguments"]:
1143
+ call = {**call, "function": {**fn, "arguments": restored}}
1144
+ new_calls.append(call)
1145
+ if any(new is not old for new, old in zip(new_calls, calls, strict=True)):
1146
+ result = {**result, "tool_calls": new_calls}
1147
+ legacy = message.get("function_call")
1148
+ if isinstance(legacy, dict) and "arguments" in legacy:
1149
+ restored = restore_arguments(legacy["arguments"], token_map)
1150
+ if restored is not legacy["arguments"] and restored != legacy["arguments"]:
1151
+ result = {**result, "function_call": {**legacy, "arguments": restored}}
1152
+ return result
1153
+
1154
+
1155
+ def _tokenise_tool_definition(tool: Any, tok: Tokenise) -> Any:
1156
+ if not isinstance(tool, dict):
1157
+ raise RequestBodyRejectedError()
1158
+ result = dict(tool)
1159
+ changed = False
1160
+ for key in ("name", "description"):
1161
+ value = tool.get(key)
1162
+ if isinstance(value, str):
1163
+ if key == "name":
1164
+ _note_tool_name(value, tok)
1165
+ continue
1166
+ done = tok(value)
1167
+ if done != value:
1168
+ result[key] = done
1169
+ changed = True
1170
+ for key in ("input_schema",):
1171
+ if key in tool:
1172
+ done = _tokenise_schema_prose(tool[key], tok)
1173
+ if done is not tool[key]:
1174
+ result[key] = done
1175
+ changed = True
1176
+ fn = tool.get("function")
1177
+ if isinstance(fn, dict):
1178
+ next_fn = dict(fn)
1179
+ fn_changed = False
1180
+ for key in ("name", "description"):
1181
+ value = fn.get(key)
1182
+ if isinstance(value, str):
1183
+ if key == "name":
1184
+ _note_tool_name(value, tok)
1185
+ continue
1186
+ done = tok(value)
1187
+ if done != value:
1188
+ next_fn[key] = done
1189
+ fn_changed = True
1190
+ if "parameters" in fn:
1191
+ done = _tokenise_schema_prose(fn["parameters"], tok)
1192
+ if done is not fn["parameters"]:
1193
+ next_fn["parameters"] = done
1194
+ fn_changed = True
1195
+ for key, value in fn.items():
1196
+ if key not in {"name", "description", "parameters", "type"}:
1197
+ done = tokenise_unclassified(value, tok)
1198
+ if done is not value:
1199
+ next_fn[key] = done
1200
+ fn_changed = True
1201
+ if fn_changed:
1202
+ result["function"] = next_fn
1203
+ changed = True
1204
+ for key, value in tool.items():
1205
+ if key not in {"name", "description", "input_schema", "function", "type"}:
1206
+ done = tokenise_unclassified(value, tok)
1207
+ if done is not value:
1208
+ result[key] = done
1209
+ changed = True
1210
+ return result if changed else tool
1211
+
1212
+
1213
+ def _literal_would_change(value: Any, tok: Tokenise) -> bool:
1214
+ """Whether the detector would replace anything in a schema literal. Changes nothing."""
1215
+ strings: list[str] = []
1216
+
1217
+ def collect(item: Any) -> None:
1218
+ if isinstance(item, str):
1219
+ strings.append(item)
1220
+ elif isinstance(item, dict):
1221
+ for child in item.values():
1222
+ collect(child)
1223
+ elif isinstance(item, (list, tuple)):
1224
+ for child in item:
1225
+ collect(child)
1226
+
1227
+ collect(value)
1228
+ # Bare-word and bare-date matches are not a schema-literal notice.
1229
+ return any(_substantive_pii(text) for text in strings)
1230
+
1231
+
1232
+ def _tokenise_schema_prose(value: Any, tok: Tokenise) -> Any:
1233
+ if isinstance(value, list):
1234
+ items = [_tokenise_schema_prose(item, tok) for item in value]
1235
+ return items if any(a is not b for a, b in zip(items, value, strict=True)) else value
1236
+ if not isinstance(value, dict):
1237
+ return value
1238
+ result = dict(value)
1239
+ changed = False
1240
+ for key in ("description", "title"):
1241
+ text = value.get(key)
1242
+ if isinstance(text, str):
1243
+ done = tok(text)
1244
+ if done != text:
1245
+ result[key] = done
1246
+ changed = True
1247
+ examples = value.get("examples")
1248
+ if isinstance(examples, list):
1249
+ done = tokenise_leaves(examples, tok)
1250
+ if done is not examples:
1251
+ result["examples"] = done
1252
+ changed = True
1253
+ for key, item in value.items():
1254
+ if key in ("description", "title", "examples"):
1255
+ continue
1256
+ if key in _SCHEMA_LITERAL_KEYS:
1257
+ # The model must echo such a literal exactly and the caller's code
1258
+ # compares it, so it is not rewritten. A literal the detector would
1259
+ # have replaced is reported on the request; the send goes on.
1260
+ if _literal_would_change(item, tok):
1261
+ _note(tok, PLACE_TOOL_SCHEMA)
1262
+ continue
1263
+ done = _tokenise_schema_prose(item, tok)
1264
+ if done is not item:
1265
+ result[key] = done
1266
+ changed = True
1267
+ return result if changed else value