toolbound 0.2.0__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.
- toolbound/__init__.py +123 -0
- toolbound/adapters/__init__.py +9 -0
- toolbound/adapters/_common.py +84 -0
- toolbound/adapters/anthropic.py +81 -0
- toolbound/adapters/openai.py +115 -0
- toolbound/cli.py +69 -0
- toolbound/core/__init__.py +4 -0
- toolbound/core/approval.py +314 -0
- toolbound/core/budget.py +42 -0
- toolbound/core/context.py +56 -0
- toolbound/core/contracts.py +689 -0
- toolbound/core/credentials.py +41 -0
- toolbound/core/errors.py +117 -0
- toolbound/core/exposure.py +68 -0
- toolbound/core/gateway.py +1461 -0
- toolbound/core/network.py +267 -0
- toolbound/core/observe.py +60 -0
- toolbound/core/paths.py +138 -0
- toolbound/core/ports.py +166 -0
- toolbound/core/pyschema.py +119 -0
- toolbound/core/ratelimit.py +65 -0
- toolbound/core/result.py +100 -0
- toolbound/core/schema.py +201 -0
- toolbound/core/shaping.py +711 -0
- toolbound/core/signing.py +137 -0
- toolbound/core/state.py +102 -0
- toolbound/estimators.py +24 -0
- toolbound/loaders/__init__.py +202 -0
- toolbound/loaders/meta_schema.py +162 -0
- toolbound/py.typed +0 -0
- toolbound/sync.py +76 -0
- toolbound/transport/__init__.py +19 -0
- toolbound/transport/http.py +269 -0
- toolbound/transport/mock.py +89 -0
- toolbound-0.2.0.dist-info/METADATA +547 -0
- toolbound-0.2.0.dist-info/RECORD +39 -0
- toolbound-0.2.0.dist-info/WHEEL +4 -0
- toolbound-0.2.0.dist-info/entry_points.txt +2 -0
- toolbound-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""The output pipeline: from an upstream value to bounded, model-visible data.
|
|
2
|
+
|
|
3
|
+
Order (each step is deterministic)::
|
|
4
|
+
|
|
5
|
+
select root -> schema check -> skip continuation offset -> project (shape)
|
|
6
|
+
-> trusted transform -> redact -> item/string limits -> fit token budget
|
|
7
|
+
|
|
8
|
+
Everything that is dropped or shortened is recorded as an :class:`Omission`, so
|
|
9
|
+
the result can say exactly what is missing and why. Nothing is summarized.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import quote, quote_plus
|
|
21
|
+
|
|
22
|
+
from jsonschema import Draft202012Validator
|
|
23
|
+
|
|
24
|
+
from .budget import to_json
|
|
25
|
+
from .contracts import Field, FieldSpec, Output
|
|
26
|
+
from .errors import ConfigurationError, ErrorCode, ToolFailure
|
|
27
|
+
from .paths import MISSING, Path, parse_path
|
|
28
|
+
from .ports import TokenEstimator
|
|
29
|
+
|
|
30
|
+
REDACTED = "[REDACTED]"
|
|
31
|
+
ELLIPSIS = "…"
|
|
32
|
+
_MAX_BUDGET_STEPS = 400
|
|
33
|
+
_MIN_STRING = 32
|
|
34
|
+
#: Upper bound on bytes serialized while trimming one result. Past it the call
|
|
35
|
+
#: fails with ``result_too_large`` instead of burning CPU.
|
|
36
|
+
_MAX_TRIM_WORK = 64 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
#: Every token pattern starts only at the beginning of a token-character run
|
|
39
|
+
#: (negative lookbehind) and uses possessive, bounded quantifiers, so matching is
|
|
40
|
+
#: linear in the input. Private-key blocks are handled by ``_redact_pem`` with
|
|
41
|
+
#: plain string searches, never with a lazy ``[\s\S]*?`` scan.
|
|
42
|
+
_T = r"(?<![A-Za-z0-9_\-])"
|
|
43
|
+
_SECRET_PATTERN = re.compile(
|
|
44
|
+
# One shared lookbehind in front of the alternation lets the engine dispatch on
|
|
45
|
+
# the first character; it is faster than per-branch anchors and stays linear.
|
|
46
|
+
_T
|
|
47
|
+
+ "(?:"
|
|
48
|
+
+ "|".join(
|
|
49
|
+
[
|
|
50
|
+
r"(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])",
|
|
51
|
+
r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,4096}+",
|
|
52
|
+
r"github_pat_[A-Za-z0-9_]{22,4096}+",
|
|
53
|
+
r"xox[abposr]-[A-Za-z0-9-]{10,4096}+",
|
|
54
|
+
r"(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,4096}+",
|
|
55
|
+
r"sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,4096}+",
|
|
56
|
+
r"AIza[0-9A-Za-z_-]{35}",
|
|
57
|
+
r"eyJ[A-Za-z0-9_-]{8,4096}+\.eyJ[A-Za-z0-9_-]{8,4096}+\.[A-Za-z0-9_-]{8,4096}+",
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
+ r")|(?i:(?<![A-Za-z0-9])bearer[ \t]{1,8}+[A-Za-z0-9._~+/-]{16,4096}+=*+)"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _first(value: Any) -> Any:
|
|
65
|
+
if isinstance(value, list):
|
|
66
|
+
return value[0] if value else None
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _join(value: Any) -> Any:
|
|
71
|
+
if isinstance(value, list):
|
|
72
|
+
return ", ".join(str(v) for v in value)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _count(value: Any) -> Any:
|
|
77
|
+
return len(value) if isinstance(value, list | str | dict) else value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
BUILTIN_TRANSFORMS: dict[str, Callable[[Any], Any]] = {
|
|
81
|
+
"lower": lambda v: v.lower() if isinstance(v, str) else v,
|
|
82
|
+
"upper": lambda v: v.upper() if isinstance(v, str) else v,
|
|
83
|
+
"strip": lambda v: v.strip() if isinstance(v, str) else v,
|
|
84
|
+
"count": _count,
|
|
85
|
+
"first": _first,
|
|
86
|
+
"join": _join,
|
|
87
|
+
"bool": bool,
|
|
88
|
+
"str": lambda v: v if v is None else str(v),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- reports
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class Omission:
|
|
97
|
+
"""Something left out of the result. ``count`` = items dropped or values cut."""
|
|
98
|
+
|
|
99
|
+
path: str
|
|
100
|
+
reason: str
|
|
101
|
+
count: int = 1
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
return {"path": self.path, "reason": self.reason, "count": self.count}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class ShapeReport:
|
|
109
|
+
omitted: dict[tuple[str, str], Omission] = field(default_factory=dict)
|
|
110
|
+
redacted: int = 0
|
|
111
|
+
warnings: list[str] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
def merge(self, other: ShapeReport) -> None:
|
|
114
|
+
for omission in other.omitted.values():
|
|
115
|
+
self.omit(omission.path, omission.reason, omission.count)
|
|
116
|
+
self.redacted += other.redacted
|
|
117
|
+
self.warnings.extend(w for w in other.warnings if w not in self.warnings)
|
|
118
|
+
|
|
119
|
+
def omit(self, path: str, reason: str, count: int = 1) -> None:
|
|
120
|
+
key = (path or "$", reason)
|
|
121
|
+
if key in self.omitted:
|
|
122
|
+
self.omitted[key].count += count
|
|
123
|
+
else:
|
|
124
|
+
self.omitted[key] = Omission(path or "$", reason, count)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def truncated(self) -> bool:
|
|
128
|
+
return any(o.reason != "missing" for o in self.omitted.values())
|
|
129
|
+
|
|
130
|
+
def omissions(self) -> list[Omission]:
|
|
131
|
+
return list(self.omitted.values())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(slots=True)
|
|
135
|
+
class ShapeResult:
|
|
136
|
+
data: Any
|
|
137
|
+
report: ShapeReport
|
|
138
|
+
total_items: int | None = None
|
|
139
|
+
next_offset: int | None = None
|
|
140
|
+
estimated_tokens: int = 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- compile
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class CompiledField:
|
|
148
|
+
key: str
|
|
149
|
+
path: Path
|
|
150
|
+
max_chars: int | None
|
|
151
|
+
max_items: int | None
|
|
152
|
+
transform: Callable[[Any], Any] | None
|
|
153
|
+
default: Any
|
|
154
|
+
shape: dict[str, CompiledField] | None
|
|
155
|
+
#: Lower-cased last key of the source path ("" when it ends in an index).
|
|
156
|
+
source_key: str = ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class CompiledOutput:
|
|
161
|
+
spec: Output
|
|
162
|
+
root: Path
|
|
163
|
+
shape: dict[str, CompiledField] | None
|
|
164
|
+
error_shape: dict[str, CompiledField] | None
|
|
165
|
+
required: frozenset[str]
|
|
166
|
+
redact_keys: frozenset[str]
|
|
167
|
+
validator: Draft202012Validator | None
|
|
168
|
+
#: The longest string any field may keep (global or per-field ``max_chars``).
|
|
169
|
+
max_string_chars: int = 4000
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def selectable_fields(self) -> list[str]:
|
|
173
|
+
return list(self.shape) if self.shape else []
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compile_output(
|
|
177
|
+
output: Output,
|
|
178
|
+
transforms: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
179
|
+
) -> CompiledOutput:
|
|
180
|
+
"""Parse paths and resolve transforms once, at registration time."""
|
|
181
|
+
registry = {**BUILTIN_TRANSFORMS, **(transforms or {})}
|
|
182
|
+
validator = None
|
|
183
|
+
if output.schema is not None:
|
|
184
|
+
try:
|
|
185
|
+
Draft202012Validator.check_schema(dict(output.schema))
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
raise ConfigurationError(f"invalid output schema: {exc}") from exc
|
|
188
|
+
validator = Draft202012Validator(dict(output.schema))
|
|
189
|
+
return CompiledOutput(
|
|
190
|
+
spec=output,
|
|
191
|
+
root=parse_path(output.root),
|
|
192
|
+
shape=_compile_shape(output.shape, registry) if output.shape is not None else None,
|
|
193
|
+
error_shape=(
|
|
194
|
+
_compile_shape(output.error_shape, registry) if output.error_shape is not None else None
|
|
195
|
+
),
|
|
196
|
+
required=frozenset(output.required),
|
|
197
|
+
redact_keys=frozenset(normalize_key(k) for k in output.redact_keys),
|
|
198
|
+
validator=validator,
|
|
199
|
+
max_string_chars=max([output.max_chars, *_field_limits(output.shape)]),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _field_limits(shape: Mapping[str, FieldSpec] | None) -> list[int]:
|
|
204
|
+
limits: list[int] = []
|
|
205
|
+
for spec in (shape or {}).values():
|
|
206
|
+
if isinstance(spec, Field):
|
|
207
|
+
if spec.max_chars is not None:
|
|
208
|
+
limits.append(spec.max_chars)
|
|
209
|
+
limits.extend(_field_limits(spec.shape))
|
|
210
|
+
return limits
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _compile_shape(
|
|
214
|
+
shape: Mapping[str, FieldSpec], registry: Mapping[str, Callable[[Any], Any]]
|
|
215
|
+
) -> dict[str, CompiledField]:
|
|
216
|
+
if not shape:
|
|
217
|
+
raise ConfigurationError("shape must contain at least one field")
|
|
218
|
+
compiled: dict[str, CompiledField] = {}
|
|
219
|
+
for key, spec in shape.items():
|
|
220
|
+
if not isinstance(key, str) or not key:
|
|
221
|
+
raise ConfigurationError("shape keys must be non-empty strings")
|
|
222
|
+
fspec = Field(spec) if isinstance(spec, str) else spec
|
|
223
|
+
if not isinstance(fspec, Field):
|
|
224
|
+
raise ConfigurationError(f"shape field {key!r} must be a path string or Field")
|
|
225
|
+
transform: Callable[[Any], Any] | None
|
|
226
|
+
if fspec.transform is None:
|
|
227
|
+
transform = None
|
|
228
|
+
elif callable(fspec.transform):
|
|
229
|
+
transform = fspec.transform
|
|
230
|
+
elif fspec.transform in registry:
|
|
231
|
+
transform = registry[fspec.transform]
|
|
232
|
+
else:
|
|
233
|
+
raise ConfigurationError(f"shape field {key!r}: unknown transform {fspec.transform!r}")
|
|
234
|
+
if fspec.max_chars is not None and fspec.max_chars < 1:
|
|
235
|
+
raise ConfigurationError(f"shape field {key!r}: max_chars must be >= 1")
|
|
236
|
+
if fspec.max_items is not None and fspec.max_items < 1:
|
|
237
|
+
raise ConfigurationError(f"shape field {key!r}: max_items must be >= 1")
|
|
238
|
+
compiled[key] = CompiledField(
|
|
239
|
+
key=key,
|
|
240
|
+
path=parse_path(fspec.source),
|
|
241
|
+
max_chars=fspec.max_chars,
|
|
242
|
+
max_items=fspec.max_items,
|
|
243
|
+
transform=transform,
|
|
244
|
+
default=fspec.default,
|
|
245
|
+
shape=_compile_shape(fspec.shape, registry) if fspec.shape is not None else None,
|
|
246
|
+
source_key=_last_key(fspec.source),
|
|
247
|
+
)
|
|
248
|
+
return compiled
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _last_key(source: str) -> str:
|
|
252
|
+
name = source.rsplit(".", 1)[-1].split("[", 1)[0]
|
|
253
|
+
return normalize_key(name)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --------------------------------------------------------------------------- pipeline
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def process(
|
|
260
|
+
compiled: CompiledOutput,
|
|
261
|
+
value: Any,
|
|
262
|
+
*,
|
|
263
|
+
estimator: TokenEstimator,
|
|
264
|
+
offset: int = 0,
|
|
265
|
+
selected: Sequence[str] | None = None,
|
|
266
|
+
secrets: Sequence[str] = (),
|
|
267
|
+
) -> ShapeResult:
|
|
268
|
+
"""Run the full output pipeline. Raises :class:`ToolFailure` on hard failures."""
|
|
269
|
+
spec = compiled.spec
|
|
270
|
+
report = ShapeReport()
|
|
271
|
+
|
|
272
|
+
data = compiled.root.get(value)
|
|
273
|
+
if data is MISSING:
|
|
274
|
+
_schema_problem(compiled, report, f"root path {compiled.root} not found in response")
|
|
275
|
+
data = None
|
|
276
|
+
if compiled.validator is not None and data is not None:
|
|
277
|
+
error = next(iter(compiled.validator.iter_errors(data)), None)
|
|
278
|
+
if error is not None:
|
|
279
|
+
where = ".".join(str(p) for p in error.absolute_path) or "$"
|
|
280
|
+
_schema_problem(compiled, report, f"response does not match schema at {where}")
|
|
281
|
+
|
|
282
|
+
total_items: int | None = None
|
|
283
|
+
selected_keys = None if selected is None else set(selected) | compiled.required
|
|
284
|
+
secret_values = _secret_values(secrets)
|
|
285
|
+
|
|
286
|
+
if isinstance(data, list) and spec.transform is None:
|
|
287
|
+
# Per-item reports, so omissions are only reported for items actually returned.
|
|
288
|
+
total_items = len(data)
|
|
289
|
+
data = data[offset:]
|
|
290
|
+
root_dropped = 0
|
|
291
|
+
if len(data) > spec.max_items:
|
|
292
|
+
root_dropped = len(data) - spec.max_items
|
|
293
|
+
report.omit("$", "max_items", root_dropped)
|
|
294
|
+
data = data[: spec.max_items]
|
|
295
|
+
items: list[Any] = []
|
|
296
|
+
item_reports: list[ShapeReport] = []
|
|
297
|
+
raw_index: list[int] = [] # position of each kept item in the sliced upstream list
|
|
298
|
+
not_objects = 0
|
|
299
|
+
for position, raw in enumerate(data):
|
|
300
|
+
item_report = ShapeReport()
|
|
301
|
+
item = raw
|
|
302
|
+
if compiled.shape is not None:
|
|
303
|
+
if not isinstance(raw, dict):
|
|
304
|
+
not_objects += 1
|
|
305
|
+
continue
|
|
306
|
+
item = _apply_shape(compiled.shape, raw, "[]", item_report, compiled, selected_keys)
|
|
307
|
+
item = _redact(item, "[]", compiled, item_report, secret_values)
|
|
308
|
+
item = _limit(item, "[]", spec.max_items, spec.max_chars, item_report, root=False)
|
|
309
|
+
items.append(item)
|
|
310
|
+
item_reports.append(item_report)
|
|
311
|
+
raw_index.append(position)
|
|
312
|
+
if not_objects:
|
|
313
|
+
report.omit("$", "not_object", not_objects)
|
|
314
|
+
budget_report = ShapeReport()
|
|
315
|
+
fitted, budget_dropped, tokens = _fit_budget(items, compiled, estimator, budget_report)
|
|
316
|
+
for item_report in item_reports[: len(fitted)]:
|
|
317
|
+
report.merge(item_report)
|
|
318
|
+
for item_report in item_reports[len(fitted) :]:
|
|
319
|
+
report.warnings.extend(w for w in item_report.warnings if w not in report.warnings)
|
|
320
|
+
report.merge(budget_report)
|
|
321
|
+
next_offset = None
|
|
322
|
+
if root_dropped or budget_dropped:
|
|
323
|
+
# Resume at the first upstream element that was not returned.
|
|
324
|
+
consumed = raw_index[len(fitted)] if len(fitted) < len(raw_index) else len(data)
|
|
325
|
+
next_offset = offset + consumed
|
|
326
|
+
return ShapeResult(fitted, report, total_items, next_offset, tokens)
|
|
327
|
+
|
|
328
|
+
if isinstance(data, list):
|
|
329
|
+
total_items = len(data)
|
|
330
|
+
data = data[offset:]
|
|
331
|
+
if compiled.shape is not None and data is not None:
|
|
332
|
+
if isinstance(data, list):
|
|
333
|
+
data = [
|
|
334
|
+
_apply_shape(compiled.shape, item, "[]", report, compiled, selected_keys)
|
|
335
|
+
for item in data
|
|
336
|
+
if isinstance(item, dict)
|
|
337
|
+
]
|
|
338
|
+
elif isinstance(data, dict):
|
|
339
|
+
data = _apply_shape(compiled.shape, data, "", report, compiled, selected_keys)
|
|
340
|
+
else:
|
|
341
|
+
_schema_problem(compiled, report, "response root is not an object or list")
|
|
342
|
+
|
|
343
|
+
if spec.transform is not None:
|
|
344
|
+
data = spec.transform(data)
|
|
345
|
+
|
|
346
|
+
data = _redact(data, "", compiled, report, secret_values)
|
|
347
|
+
|
|
348
|
+
root_dropped = 0
|
|
349
|
+
if isinstance(data, list) and len(data) > spec.max_items:
|
|
350
|
+
root_dropped = len(data) - spec.max_items
|
|
351
|
+
report.omit("$", "max_items", root_dropped)
|
|
352
|
+
data = data[: spec.max_items]
|
|
353
|
+
data = _limit(data, "", spec.max_items, spec.max_chars, report, root=True)
|
|
354
|
+
|
|
355
|
+
data, budget_dropped, tokens = _fit_budget(data, compiled, estimator, report)
|
|
356
|
+
|
|
357
|
+
next_offset = None
|
|
358
|
+
if isinstance(data, list) and (root_dropped or budget_dropped):
|
|
359
|
+
next_offset = offset + len(data)
|
|
360
|
+
return ShapeResult(data, report, total_items, next_offset, tokens)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def shape_error_body(compiled: CompiledOutput, body: Any, *, secrets: Sequence[str] = ()) -> Any:
|
|
364
|
+
"""Apply ``error_shape`` to an upstream error body, tightly bounded."""
|
|
365
|
+
if compiled.error_shape is None or not isinstance(body, dict):
|
|
366
|
+
return None
|
|
367
|
+
report = ShapeReport()
|
|
368
|
+
shaped = _apply_shape(compiled.error_shape, body, "", report, compiled, None, check=False)
|
|
369
|
+
shaped = _redact(shaped, "", compiled, report, _secret_values(secrets))
|
|
370
|
+
return _limit(shaped, "", 5, 300, report, root=False)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _schema_problem(compiled: CompiledOutput, report: ShapeReport, message: str) -> None:
|
|
374
|
+
if compiled.spec.schema_mode == "strict":
|
|
375
|
+
raise ToolFailure(ErrorCode.UPSTREAM_SCHEMA_MISMATCH, message, retryable=False)
|
|
376
|
+
report.warnings.append(message)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _apply_shape(
|
|
380
|
+
shape: Mapping[str, CompiledField],
|
|
381
|
+
obj: Any,
|
|
382
|
+
prefix: str,
|
|
383
|
+
report: ShapeReport,
|
|
384
|
+
compiled: CompiledOutput,
|
|
385
|
+
selected: set[str] | None,
|
|
386
|
+
*,
|
|
387
|
+
check: bool = True,
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
out: dict[str, Any] = {}
|
|
390
|
+
if not isinstance(obj, dict):
|
|
391
|
+
return out
|
|
392
|
+
for key, fld in shape.items():
|
|
393
|
+
if selected is not None and key not in selected:
|
|
394
|
+
continue
|
|
395
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
396
|
+
value = fld.path.get(obj)
|
|
397
|
+
# Renaming a sensitive source field must not bypass key-based redaction.
|
|
398
|
+
if (
|
|
399
|
+
value is not MISSING
|
|
400
|
+
and fld.source_key in compiled.redact_keys
|
|
401
|
+
and value not in (None, "")
|
|
402
|
+
):
|
|
403
|
+
report.redacted += 1
|
|
404
|
+
out[key] = REDACTED
|
|
405
|
+
continue
|
|
406
|
+
if value is MISSING:
|
|
407
|
+
if fld.default is not MISSING:
|
|
408
|
+
out[key] = fld.default
|
|
409
|
+
continue
|
|
410
|
+
if check and key in compiled.required and prefix in ("", "[]"):
|
|
411
|
+
_schema_problem(compiled, report, f"required field {path!r} missing upstream")
|
|
412
|
+
report.omit(path, "missing")
|
|
413
|
+
continue
|
|
414
|
+
if fld.shape is not None:
|
|
415
|
+
if isinstance(value, list):
|
|
416
|
+
value = [
|
|
417
|
+
_apply_shape(fld.shape, item, f"{path}[]", report, compiled, None, check=False)
|
|
418
|
+
for item in value
|
|
419
|
+
if isinstance(item, dict)
|
|
420
|
+
]
|
|
421
|
+
elif isinstance(value, dict):
|
|
422
|
+
value = _apply_shape(fld.shape, value, path, report, compiled, None, check=False)
|
|
423
|
+
if fld.transform is not None:
|
|
424
|
+
value = fld.transform(value)
|
|
425
|
+
if fld.max_items is not None and isinstance(value, list) and len(value) > fld.max_items:
|
|
426
|
+
report.omit(path, "max_items", len(value) - fld.max_items)
|
|
427
|
+
value = value[: fld.max_items]
|
|
428
|
+
if fld.max_chars is not None and isinstance(value, str) and len(value) > fld.max_chars:
|
|
429
|
+
report.omit(path, "max_chars")
|
|
430
|
+
value = _cut(value, fld.max_chars)
|
|
431
|
+
out[key] = value
|
|
432
|
+
return out
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
_PEM_BEGIN = "-----BEGIN "
|
|
436
|
+
_PEM_LABEL = re.compile(r"[A-Z0-9 ]{0,40}PRIVATE KEY-----")
|
|
437
|
+
_PEM_END = "-----END "
|
|
438
|
+
#: Extra characters kept beyond the string limit while redacting, so a secret that
|
|
439
|
+
#: straddles the cut is still seen whole before the final truncation.
|
|
440
|
+
_REDACT_MARGIN = 4096
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _redact_pem(text: str) -> tuple[str, int]:
|
|
444
|
+
"""Replace private-key blocks in linear time. An unterminated block is redacted
|
|
445
|
+
to the end of the string (a partial key is still a secret)."""
|
|
446
|
+
if _PEM_BEGIN not in text:
|
|
447
|
+
return text, 0
|
|
448
|
+
out: list[str] = []
|
|
449
|
+
count = 0
|
|
450
|
+
pos = 0
|
|
451
|
+
while True:
|
|
452
|
+
start = text.find(_PEM_BEGIN, pos)
|
|
453
|
+
if start < 0:
|
|
454
|
+
out.append(text[pos:])
|
|
455
|
+
break
|
|
456
|
+
label_end = start + len(_PEM_BEGIN)
|
|
457
|
+
match = _PEM_LABEL.match(text, label_end)
|
|
458
|
+
if match is None:
|
|
459
|
+
out.append(text[pos:label_end])
|
|
460
|
+
pos = label_end
|
|
461
|
+
continue
|
|
462
|
+
out.append(text[pos:start])
|
|
463
|
+
out.append(REDACTED)
|
|
464
|
+
count += 1
|
|
465
|
+
end = text.find(_PEM_END, match.end())
|
|
466
|
+
if end < 0:
|
|
467
|
+
break
|
|
468
|
+
tail = _PEM_LABEL.match(text, end + len(_PEM_END))
|
|
469
|
+
pos = tail.end() if tail is not None else end + len(_PEM_END)
|
|
470
|
+
return "".join(out), count
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _secret_values(secrets: Sequence[str]) -> tuple[str, ...]:
|
|
474
|
+
"""Credential values plus the encodings in which an upstream may echo them."""
|
|
475
|
+
variants: set[str] = set()
|
|
476
|
+
for secret in secrets:
|
|
477
|
+
variants.update(
|
|
478
|
+
{
|
|
479
|
+
secret,
|
|
480
|
+
quote(secret, safe=""),
|
|
481
|
+
quote_plus(secret),
|
|
482
|
+
json.dumps(secret)[1:-1],
|
|
483
|
+
base64.b64encode(secret.encode()).decode(),
|
|
484
|
+
base64.urlsafe_b64encode(secret.encode()).decode(),
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
# Only redact values long enough not to collide with ordinary text.
|
|
488
|
+
return tuple(sorted({v for v in variants if len(v) >= 8}, key=len, reverse=True))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def normalize_key(key: str) -> str:
|
|
492
|
+
"""``X-Api-Key`` / ``api_key`` / ``apiKey`` all become ``apikey``."""
|
|
493
|
+
return key.lower().replace("-", "").replace("_", "")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _redact(
|
|
497
|
+
value: Any,
|
|
498
|
+
path: str,
|
|
499
|
+
compiled: CompiledOutput,
|
|
500
|
+
report: ShapeReport,
|
|
501
|
+
secrets: tuple[str, ...],
|
|
502
|
+
) -> Any:
|
|
503
|
+
spec = compiled.spec
|
|
504
|
+
if isinstance(value, dict):
|
|
505
|
+
out: dict[str, Any] = {}
|
|
506
|
+
for key, item in value.items():
|
|
507
|
+
child = f"{path}.{key}" if path else str(key)
|
|
508
|
+
clean = key
|
|
509
|
+
if isinstance(key, str) and len(key) >= 8 and _key_holds_secret(key, compiled, secrets):
|
|
510
|
+
clean = f"{REDACTED}#{len(out)}" # a credential used as a key: hide the key
|
|
511
|
+
report.redacted += 1
|
|
512
|
+
if (
|
|
513
|
+
isinstance(key, str)
|
|
514
|
+
and normalize_key(key) in compiled.redact_keys
|
|
515
|
+
and item not in (None, "")
|
|
516
|
+
):
|
|
517
|
+
out[clean] = REDACTED
|
|
518
|
+
report.redacted += 1
|
|
519
|
+
else:
|
|
520
|
+
out[clean] = _redact(item, child, compiled, report, secrets)
|
|
521
|
+
return out
|
|
522
|
+
if isinstance(value, list):
|
|
523
|
+
return [_redact(item, f"{path}[]", compiled, report, secrets) for item in value]
|
|
524
|
+
if isinstance(value, str):
|
|
525
|
+
value = _redact_text(value, compiled, report, secrets)
|
|
526
|
+
for hook in spec.redactors:
|
|
527
|
+
value = hook(path or "$", value)
|
|
528
|
+
return value
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _key_holds_secret(key: str, compiled: CompiledOutput, secrets: tuple[str, ...]) -> bool:
|
|
532
|
+
if any(secret in key for secret in secrets):
|
|
533
|
+
return True
|
|
534
|
+
# Ordinary keys are short identifiers; only long ones can hold a token pattern.
|
|
535
|
+
return (
|
|
536
|
+
compiled.spec.redact_secrets
|
|
537
|
+
and len(key) >= 20
|
|
538
|
+
and (_SECRET_PATTERN.search(key) is not None or _PEM_BEGIN in key)
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _redact_text(
|
|
543
|
+
text: str, compiled: CompiledOutput, report: ShapeReport, secrets: tuple[str, ...]
|
|
544
|
+
) -> str:
|
|
545
|
+
# Nothing past the longest allowed string (plus a margin) can reach the model,
|
|
546
|
+
# so never spend work on it. The final cut happens in ``_limit``.
|
|
547
|
+
window = compiled.max_string_chars + _REDACT_MARGIN
|
|
548
|
+
if len(text) > window:
|
|
549
|
+
text = text[:window]
|
|
550
|
+
for secret in secrets:
|
|
551
|
+
if secret in text:
|
|
552
|
+
report.redacted += text.count(secret)
|
|
553
|
+
text = text.replace(secret, REDACTED)
|
|
554
|
+
if compiled.spec.redact_secrets:
|
|
555
|
+
text, n = _redact_pem(text)
|
|
556
|
+
report.redacted += n
|
|
557
|
+
text, n = _SECRET_PATTERN.subn(REDACTED, text)
|
|
558
|
+
report.redacted += n
|
|
559
|
+
return text
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _cut(text: str, limit: int) -> str:
|
|
563
|
+
if len(text) <= limit:
|
|
564
|
+
return text
|
|
565
|
+
return text[: max(limit - 1, 0)] + ELLIPSIS
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _limit(
|
|
569
|
+
value: Any, path: str, max_items: int, max_chars: int, report: ShapeReport, *, root: bool
|
|
570
|
+
) -> Any:
|
|
571
|
+
if isinstance(value, dict):
|
|
572
|
+
return {
|
|
573
|
+
k: _limit(
|
|
574
|
+
v, f"{path}.{k}" if path else str(k), max_items, max_chars, report, root=False
|
|
575
|
+
)
|
|
576
|
+
for k, v in value.items()
|
|
577
|
+
}
|
|
578
|
+
if isinstance(value, list):
|
|
579
|
+
if not root and len(value) > max_items:
|
|
580
|
+
report.omit(path, "max_items", len(value) - max_items)
|
|
581
|
+
value = value[:max_items]
|
|
582
|
+
return [_limit(v, f"{path}[]", max_items, max_chars, report, root=False) for v in value]
|
|
583
|
+
if isinstance(value, str) and len(value) > max_chars:
|
|
584
|
+
report.omit(path, "max_chars")
|
|
585
|
+
return _cut(value, max_chars)
|
|
586
|
+
return value
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# --------------------------------------------------------------------------- budget
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _fit_budget(
|
|
593
|
+
data: Any,
|
|
594
|
+
compiled: CompiledOutput,
|
|
595
|
+
estimator: TokenEstimator,
|
|
596
|
+
report: ShapeReport,
|
|
597
|
+
) -> tuple[Any, int, int]:
|
|
598
|
+
"""Return (data, root items dropped, estimated tokens)."""
|
|
599
|
+
spec = compiled.spec
|
|
600
|
+
tokens = estimator.count(to_json(data))
|
|
601
|
+
if spec.max_tokens is None or tokens <= spec.max_tokens:
|
|
602
|
+
return data, 0, tokens
|
|
603
|
+
if spec.overflow == "reject":
|
|
604
|
+
raise ToolFailure(
|
|
605
|
+
ErrorCode.RESULT_TOO_LARGE,
|
|
606
|
+
f"result needs ~{tokens} tokens but the budget is {spec.max_tokens}; "
|
|
607
|
+
"narrow the request (filters, fields, or a smaller page)",
|
|
608
|
+
retryable=False,
|
|
609
|
+
)
|
|
610
|
+
budget = spec.max_tokens
|
|
611
|
+
dropped = 0
|
|
612
|
+
if isinstance(data, list) and len(data) > 1:
|
|
613
|
+
lo, hi = 1, len(data)
|
|
614
|
+
while lo < hi: # largest prefix that fits
|
|
615
|
+
mid = (lo + hi + 1) // 2
|
|
616
|
+
if estimator.count(to_json(data[:mid])) <= budget:
|
|
617
|
+
lo = mid
|
|
618
|
+
else:
|
|
619
|
+
hi = mid - 1
|
|
620
|
+
if lo < len(data):
|
|
621
|
+
dropped = len(data) - lo
|
|
622
|
+
report.omit("$", "budget", dropped)
|
|
623
|
+
data = data[:lo]
|
|
624
|
+
tokens = estimator.count(to_json(data))
|
|
625
|
+
if tokens <= budget:
|
|
626
|
+
return data, dropped, tokens
|
|
627
|
+
|
|
628
|
+
data = json.loads(to_json(data)) # one private, JSON-safe copy, trimmed in place
|
|
629
|
+
work = 0
|
|
630
|
+
for _ in range(_MAX_BUDGET_STEPS):
|
|
631
|
+
candidates = _trimmable(data, compiled.required)
|
|
632
|
+
if not candidates:
|
|
633
|
+
break
|
|
634
|
+
if tokens > 2 * budget:
|
|
635
|
+
# Far over budget: halve every value at least half as large as the
|
|
636
|
+
# largest in one step, so the number of steps grows with log(size).
|
|
637
|
+
largest = candidates[0][0]
|
|
638
|
+
batch = [c for c in candidates if c[0] * 2 > largest]
|
|
639
|
+
else:
|
|
640
|
+
batch = candidates[:1]
|
|
641
|
+
# Deepest first, so halving a list never invalidates a later location.
|
|
642
|
+
for _size, path_key, size_path in sorted(batch, key=lambda c: -len(c[2])):
|
|
643
|
+
data = _halve_at(data, size_path)
|
|
644
|
+
report.omit(path_key, "budget")
|
|
645
|
+
text = to_json(data)
|
|
646
|
+
work += len(text)
|
|
647
|
+
tokens = estimator.count(text)
|
|
648
|
+
if tokens <= budget:
|
|
649
|
+
return data, dropped, tokens
|
|
650
|
+
if work > _MAX_TRIM_WORK:
|
|
651
|
+
raise ToolFailure(
|
|
652
|
+
ErrorCode.RESULT_TOO_LARGE,
|
|
653
|
+
f"result is far over the {budget}-token budget; narrow the request "
|
|
654
|
+
"(filters, fields, or a smaller page)",
|
|
655
|
+
retryable=False,
|
|
656
|
+
)
|
|
657
|
+
raise ToolFailure(
|
|
658
|
+
ErrorCode.RESULT_TOO_LARGE,
|
|
659
|
+
f"result cannot fit the {budget}-token budget even after trimming (required "
|
|
660
|
+
"fields and object keys are never removed); narrow the request",
|
|
661
|
+
retryable=False,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _trimmable(data: Any, required: frozenset[str]) -> list[tuple[int, str, tuple[str | int, ...]]]:
|
|
666
|
+
"""Every value that may be halved, largest first (ties by path, deterministic)."""
|
|
667
|
+
found: list[tuple[int, str, tuple[str | int, ...]]] = []
|
|
668
|
+
|
|
669
|
+
def visit(node: Any, norm: str, loc: tuple[str | int, ...]) -> None:
|
|
670
|
+
if isinstance(node, dict):
|
|
671
|
+
for key, child in node.items():
|
|
672
|
+
if key in required and _record_level(loc):
|
|
673
|
+
continue
|
|
674
|
+
visit(child, f"{norm}.{key}" if norm else key, (*loc, key))
|
|
675
|
+
return
|
|
676
|
+
if isinstance(node, list):
|
|
677
|
+
if len(node) > 1 and loc:
|
|
678
|
+
found.append((len(to_json(node)), norm or "$", loc))
|
|
679
|
+
for i, child in enumerate(node):
|
|
680
|
+
visit(child, f"{norm}[]", (*loc, i))
|
|
681
|
+
return
|
|
682
|
+
if isinstance(node, str) and len(node) > _MIN_STRING:
|
|
683
|
+
found.append((len(node.encode()), norm or "$", loc))
|
|
684
|
+
|
|
685
|
+
visit(data, "", ())
|
|
686
|
+
found.sort(key=lambda c: (-c[0], c[1], [str(p) for p in c[2]]))
|
|
687
|
+
return found
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _record_level(loc: tuple[str | int, ...]) -> bool:
|
|
691
|
+
# A top-level object (loc == ()) or an element of the root list (loc == (i,)).
|
|
692
|
+
return len(loc) == 0 or (len(loc) == 1 and isinstance(loc[0], int))
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _halve_at(data: Any, loc: tuple[str | int, ...]) -> Any:
|
|
696
|
+
"""Halve the value at ``loc`` in place (``data`` is a private copy)."""
|
|
697
|
+
if not loc:
|
|
698
|
+
return _halve(data)
|
|
699
|
+
parent = data
|
|
700
|
+
for step in loc[:-1]:
|
|
701
|
+
parent = parent[step]
|
|
702
|
+
parent[loc[-1]] = _halve(parent[loc[-1]])
|
|
703
|
+
return data
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _halve(value: Any) -> Any:
|
|
707
|
+
if isinstance(value, list):
|
|
708
|
+
return value[: max(1, len(value) // 2)]
|
|
709
|
+
if isinstance(value, str):
|
|
710
|
+
return _cut(value, max(_MIN_STRING, len(value) // 2))
|
|
711
|
+
return value
|