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
|
@@ -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
|