backspin 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.
- backspin/__init__.py +38 -0
- backspin/cli.py +379 -0
- backspin/cost.py +85 -0
- backspin/diff.py +121 -0
- backspin/export.py +71 -0
- backspin/fakes.py +151 -0
- backspin/integrations/__init__.py +0 -0
- backspin/integrations/anthropic.py +279 -0
- backspin/integrations/openai.py +298 -0
- backspin/proxy.py +367 -0
- backspin/py.typed +0 -0
- backspin/pytest_plugin.py +83 -0
- backspin/recorder.py +346 -0
- backspin/redaction.py +49 -0
- backspin/replay.py +327 -0
- backspin/runfile.py +182 -0
- backspin/server.py +91 -0
- backspin/share.py +58 -0
- backspin/testing.py +121 -0
- backspin/tui.py +130 -0
- backspin/ui/app.js +327 -0
- backspin/ui/i18n.js +75 -0
- backspin/ui/index.html +68 -0
- backspin/ui/style.css +214 -0
- backspin-0.5.1.dist-info/METADATA +308 -0
- backspin-0.5.1.dist-info/RECORD +29 -0
- backspin-0.5.1.dist-info/WHEEL +4 -0
- backspin-0.5.1.dist-info/entry_points.txt +5 -0
- backspin-0.5.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""pytest integration: one fixture for deterministic agent testing.
|
|
2
|
+
|
|
3
|
+
Activated automatically when backspin is installed (pytest11 entry point)::
|
|
4
|
+
|
|
5
|
+
def test_my_agent(backspin):
|
|
6
|
+
client = FakeOpenAI(["answer"]) # or a real OpenAI client
|
|
7
|
+
with backspin.record(agent="t") as rec:
|
|
8
|
+
run_agent(rec.capture_openai(client))
|
|
9
|
+
backspin.assert_replays_identically() # the run must replay clean
|
|
10
|
+
|
|
11
|
+
``assert_replays_identically()`` loads the recording, replays every LLM
|
|
12
|
+
call through its cassette and fails the test on any mismatch — catching
|
|
13
|
+
corrupted runs and broken fingerprints before they ship.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from . import Cassette, Recorder, load_run
|
|
22
|
+
from .replay import ReplayMismatch, stub_client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BackspinFixture:
|
|
26
|
+
"""Recorder factory + replay assertions, scoped to the test's tmp dir."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, tmp_path):
|
|
29
|
+
self._tmp = tmp_path
|
|
30
|
+
self._rec: Optional[Recorder] = None
|
|
31
|
+
|
|
32
|
+
def record(self, agent: str = "test", **kwargs: Any) -> Recorder:
|
|
33
|
+
self._rec = Recorder(dir=str(self._tmp), agent=agent, **kwargs)
|
|
34
|
+
return self._rec
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def path(self) -> Optional[str]:
|
|
38
|
+
return self._rec.path if self._rec is not None else None
|
|
39
|
+
|
|
40
|
+
def assert_replays_identically(self, rec: Optional[Recorder] = None) -> None:
|
|
41
|
+
"""Every LLM call in the recording must replay through its cassette.
|
|
42
|
+
|
|
43
|
+
Strict: each call must match a recording by exact request
|
|
44
|
+
fingerprint — order fallbacks are failures, because they mean the
|
|
45
|
+
run is not deterministically replayable.
|
|
46
|
+
"""
|
|
47
|
+
rec = rec or self._rec
|
|
48
|
+
if rec is None:
|
|
49
|
+
raise AssertionError("no recording: call backspin.record() first")
|
|
50
|
+
run = load_run(rec.path)
|
|
51
|
+
cassette = Cassette.from_run(run)
|
|
52
|
+
stub = stub_client(cassette)
|
|
53
|
+
for event in run.llm_calls():
|
|
54
|
+
request = dict(event.get("request") or {})
|
|
55
|
+
try:
|
|
56
|
+
resp = stub.chat.completions.create(
|
|
57
|
+
model=event.get("model"),
|
|
58
|
+
messages=request.get("messages"),
|
|
59
|
+
)
|
|
60
|
+
except ReplayMismatch as exc:
|
|
61
|
+
raise AssertionError(
|
|
62
|
+
f"step #{event.get('seq')} is not replayable: {exc}"
|
|
63
|
+
) from exc
|
|
64
|
+
dumped = resp.model_dump()
|
|
65
|
+
recorded = (event.get("response") or {}).get("choices") or [{}]
|
|
66
|
+
replayed = dumped.get("choices") or [{}]
|
|
67
|
+
if recorded[0].get("message") != replayed[0].get("message"):
|
|
68
|
+
raise AssertionError(
|
|
69
|
+
f"step #{event.get('seq')} replays with a different message"
|
|
70
|
+
)
|
|
71
|
+
mismatches = stub.chat.completions.state.mismatches
|
|
72
|
+
if mismatches:
|
|
73
|
+
raise AssertionError(
|
|
74
|
+
f"{len(mismatches)} LLM call(s) replayed by call-order fallback "
|
|
75
|
+
"instead of exact fingerprint match — the run is not "
|
|
76
|
+
"deterministically replayable (steps: "
|
|
77
|
+
+ ", ".join(str(m["call"]) for m in mismatches) + ")"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
def backspin(tmp_path):
|
|
83
|
+
return _BackspinFixture(tmp_path)
|
backspin/recorder.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""The flight recorder itself.
|
|
2
|
+
|
|
3
|
+
``Recorder`` writes events to a run file as they happen. It is designed to
|
|
4
|
+
be safe to wrap around code you do not trust: serialization problems
|
|
5
|
+
degrade to reprs, and exceptions in recorded code are captured as events
|
|
6
|
+
before being re-raised.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextvars
|
|
11
|
+
import functools
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
import uuid
|
|
20
|
+
import warnings
|
|
21
|
+
from contextlib import contextmanager, suppress
|
|
22
|
+
from typing import Any, Callable, Dict, Literal, Optional
|
|
23
|
+
|
|
24
|
+
from .runfile import (
|
|
25
|
+
ERROR,
|
|
26
|
+
FILE_SUFFIX,
|
|
27
|
+
LLM,
|
|
28
|
+
LOG,
|
|
29
|
+
TOOL,
|
|
30
|
+
fingerprint_request,
|
|
31
|
+
jsonable,
|
|
32
|
+
make_header,
|
|
33
|
+
new_run_id,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Fields that stay in clear even under a redactor: they are structural
|
|
37
|
+
# metadata the viewer, differ and replay matching rely on.
|
|
38
|
+
_STRUCTURAL_FIELDS = frozenset(
|
|
39
|
+
{"model", "name", "duration_ms", "fingerprint", "error_type", "level",
|
|
40
|
+
"span_id", "parent", "depth", "phase", "provider"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Current span path, per execution context (async tasks get isolated stacks).
|
|
44
|
+
_span_stack: contextvars.ContextVar = contextvars.ContextVar(
|
|
45
|
+
"backspin_span_stack", default=()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Recorder:
|
|
50
|
+
"""Record an agent run to a single ``*.backspin.jsonl`` file.
|
|
51
|
+
|
|
52
|
+
Usable as a context manager (records an error event if the body raises)
|
|
53
|
+
or standalone — recording starts immediately in the constructor.
|
|
54
|
+
|
|
55
|
+
Run files are created by :mod:`tempfile` inside ``dir`` with a unique
|
|
56
|
+
generated name (see ``rec.path`` after construction), so concurrent
|
|
57
|
+
recorders never collide and output can never land outside the resolved
|
|
58
|
+
directory. ``base_dir`` lets embedding hosts further confine ``dir``
|
|
59
|
+
under an allowed root.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
dir: str = "runs",
|
|
65
|
+
*,
|
|
66
|
+
agent: str = "agent",
|
|
67
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
68
|
+
base_dir: Optional[str] = None,
|
|
69
|
+
redact: Optional[Callable[[Any], Any]] = None,
|
|
70
|
+
):
|
|
71
|
+
self.run_id = new_run_id()
|
|
72
|
+
self._redact = redact
|
|
73
|
+
root = os.path.realpath(os.path.expanduser(dir))
|
|
74
|
+
if base_dir is not None:
|
|
75
|
+
allowed = os.path.realpath(os.path.expanduser(base_dir))
|
|
76
|
+
if os.path.commonpath([root, allowed]) != allowed:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"directory {dir!r} is outside the allowed root {base_dir!r}"
|
|
79
|
+
)
|
|
80
|
+
os.makedirs(root, exist_ok=True)
|
|
81
|
+
slug = "".join(c if c.isalnum() or c in "-_" else "-" for c in agent)
|
|
82
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._seq = 0
|
|
85
|
+
self._closed = False
|
|
86
|
+
# no context manager here on purpose: the handle must outlive the
|
|
87
|
+
# constructor until close(); delete=False keeps a crash from
|
|
88
|
+
# destroying the evidence.
|
|
89
|
+
self._fp = tempfile.NamedTemporaryFile( # noqa: SIM115
|
|
90
|
+
mode="w",
|
|
91
|
+
dir=root,
|
|
92
|
+
prefix=f"{stamp}-{slug}-{self.run_id}-",
|
|
93
|
+
suffix=FILE_SUFFIX,
|
|
94
|
+
delete=False,
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
newline="\n",
|
|
97
|
+
)
|
|
98
|
+
self.path = self._fp.name
|
|
99
|
+
self._write_raw(make_header(self.run_id, agent, metadata))
|
|
100
|
+
|
|
101
|
+
# -- plumbing ---------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _write_raw(self, ev: Dict[str, Any]) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._try_write(ev)
|
|
106
|
+
|
|
107
|
+
def _write_line(self, ev: Dict[str, Any]) -> None:
|
|
108
|
+
with self._lock:
|
|
109
|
+
if self._closed:
|
|
110
|
+
return
|
|
111
|
+
self._seq += 1
|
|
112
|
+
ev["seq"] = self._seq
|
|
113
|
+
self._try_write(ev)
|
|
114
|
+
|
|
115
|
+
def _try_write(self, ev: Dict[str, Any]) -> None:
|
|
116
|
+
"""A recorder must never crash the agent it watches: on a write
|
|
117
|
+
failure (disk full, file vanished, closed file, ...) warn once and
|
|
118
|
+
stop writing instead of raising out of instrumented code."""
|
|
119
|
+
try:
|
|
120
|
+
self._fp.write(json.dumps(ev, ensure_ascii=False, default=str) + "\n")
|
|
121
|
+
self._fp.flush()
|
|
122
|
+
except (OSError, ValueError) as exc:
|
|
123
|
+
self._closed = True
|
|
124
|
+
warnings.warn(
|
|
125
|
+
f"backspin: stopping recording of run {self.run_id}: "
|
|
126
|
+
f"cannot write {self.path}: {exc}",
|
|
127
|
+
RuntimeWarning,
|
|
128
|
+
stacklevel=3,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _emit(self, kind: str, **payload: Any) -> Dict[str, Any]:
|
|
132
|
+
ev: Dict[str, Any] = {"kind": kind, "ts": time.time()}
|
|
133
|
+
ev.update({k: v for k, v in payload.items() if v is not None})
|
|
134
|
+
if self._redact is not None:
|
|
135
|
+
for key in ev:
|
|
136
|
+
# Structural fields stay in clear: the viewer, the differ
|
|
137
|
+
# and replay matching all depend on them. Everything else
|
|
138
|
+
# (including unknown custom payload keys) goes through the
|
|
139
|
+
# redactor.
|
|
140
|
+
if key not in ("kind", "ts") and key not in _STRUCTURAL_FIELDS:
|
|
141
|
+
ev[key] = self._redact(ev[key])
|
|
142
|
+
stack = _span_stack.get()
|
|
143
|
+
if stack:
|
|
144
|
+
# span events carry their own ids/depths; regular events inherit
|
|
145
|
+
# the innermost open span
|
|
146
|
+
ev.setdefault("span_id", stack[-1][0])
|
|
147
|
+
ev.setdefault("depth", len(stack))
|
|
148
|
+
self._write_line(ev)
|
|
149
|
+
return ev
|
|
150
|
+
|
|
151
|
+
@contextmanager
|
|
152
|
+
def span(self, name: str, meta: Optional[Dict[str, Any]] = None):
|
|
153
|
+
"""Open a named span: everything recorded inside is nested under it.
|
|
154
|
+
|
|
155
|
+
Spans nest arbitrarily (agent -> tool -> sub-LLM) and are safe under
|
|
156
|
+
concurrency — each asyncio task (or thread that copies the context)
|
|
157
|
+
gets its own stack, so parallel branches don't interleave. An
|
|
158
|
+
exception inside the span is recorded on the exit event and
|
|
159
|
+
re-raised.
|
|
160
|
+
"""
|
|
161
|
+
sid = uuid.uuid4().hex[:8]
|
|
162
|
+
parent_stack = _span_stack.get()
|
|
163
|
+
parent = parent_stack[-1][0] if parent_stack else None
|
|
164
|
+
depth = len(parent_stack)
|
|
165
|
+
self._emit(
|
|
166
|
+
"span", phase="enter", name=name, span_id=sid,
|
|
167
|
+
parent=parent, depth=depth, meta=jsonable(meta),
|
|
168
|
+
)
|
|
169
|
+
token = _span_stack.set((*parent_stack, (sid, name)))
|
|
170
|
+
t0 = time.perf_counter()
|
|
171
|
+
try:
|
|
172
|
+
yield self
|
|
173
|
+
except BaseException as exc:
|
|
174
|
+
self._emit(
|
|
175
|
+
"span", phase="exit", name=name, span_id=sid, parent=parent,
|
|
176
|
+
depth=depth, error=str(exc), error_type=type(exc).__name__,
|
|
177
|
+
duration_ms=round((time.perf_counter() - t0) * 1000, 1),
|
|
178
|
+
)
|
|
179
|
+
raise
|
|
180
|
+
else:
|
|
181
|
+
self._emit(
|
|
182
|
+
"span", phase="exit", name=name, span_id=sid, parent=parent,
|
|
183
|
+
depth=depth, duration_ms=round((time.perf_counter() - t0) * 1000, 1),
|
|
184
|
+
)
|
|
185
|
+
finally:
|
|
186
|
+
with suppress(ValueError):
|
|
187
|
+
_span_stack.reset(token)
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
with self._lock:
|
|
191
|
+
self._closed = True
|
|
192
|
+
with suppress(OSError): # release even a descriptor a failed write left open
|
|
193
|
+
self._fp.close()
|
|
194
|
+
|
|
195
|
+
def __enter__(self) -> "Recorder":
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, exc_type, exc, tb) -> Literal[False]:
|
|
199
|
+
if exc is not None:
|
|
200
|
+
self.record_error(exc)
|
|
201
|
+
self.close()
|
|
202
|
+
return False # never swallow
|
|
203
|
+
|
|
204
|
+
# -- recording API ------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def log(self, message: str, level: str = "info", **extra: Any) -> None:
|
|
207
|
+
"""Record a free-form log line inside the run timeline."""
|
|
208
|
+
self._emit(LOG, level=level, message=message, **jsonable(extra))
|
|
209
|
+
|
|
210
|
+
def event(self, kind: str, **payload: Any) -> Dict[str, Any]:
|
|
211
|
+
"""Record a custom event kind (rendered as 'custom' in the viewer)."""
|
|
212
|
+
return self._emit(kind, **jsonable(payload))
|
|
213
|
+
|
|
214
|
+
def record_llm(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
request: Optional[Dict[str, Any]] = None,
|
|
218
|
+
response: Optional[Any] = None,
|
|
219
|
+
error: Optional[BaseException] = None,
|
|
220
|
+
duration_ms: float = 0.0,
|
|
221
|
+
model: Optional[str] = None,
|
|
222
|
+
usage: Optional[Dict[str, Any]] = None,
|
|
223
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
224
|
+
provider: Optional[str] = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Record one LLM call. Prefer ``capture_openai``/``capture_anthropic``
|
|
227
|
+
over calling directly. ``provider`` labels non-OpenAI protocols."""
|
|
228
|
+
if model is None and request:
|
|
229
|
+
model = request.get("model")
|
|
230
|
+
fp = None
|
|
231
|
+
if request and request.get("messages") is not None:
|
|
232
|
+
fp = fingerprint_request(model, request.get("messages"))
|
|
233
|
+
self._emit(
|
|
234
|
+
LLM,
|
|
235
|
+
duration_ms=round(duration_ms, 1),
|
|
236
|
+
model=model,
|
|
237
|
+
request=jsonable(request),
|
|
238
|
+
response=jsonable(response, max_len=4000),
|
|
239
|
+
usage=jsonable(usage),
|
|
240
|
+
error=str(error) if error else None,
|
|
241
|
+
fingerprint=fp,
|
|
242
|
+
meta=jsonable(meta),
|
|
243
|
+
provider=provider,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def record_tool(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
name: str,
|
|
250
|
+
args: Optional[Dict[str, Any]] = None,
|
|
251
|
+
result: Optional[Any] = None,
|
|
252
|
+
error: Optional[BaseException] = None,
|
|
253
|
+
duration_ms: float = 0.0,
|
|
254
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Record one tool call."""
|
|
257
|
+
self._emit(
|
|
258
|
+
TOOL,
|
|
259
|
+
name=name,
|
|
260
|
+
duration_ms=round(duration_ms, 1),
|
|
261
|
+
args=jsonable(args),
|
|
262
|
+
result=jsonable(result, max_len=4000),
|
|
263
|
+
error=str(error) if error else None,
|
|
264
|
+
meta=jsonable(meta),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def record_error(self, error: BaseException, **meta: Any) -> None:
|
|
268
|
+
tb = "".join(
|
|
269
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
270
|
+
)
|
|
271
|
+
self._emit(
|
|
272
|
+
ERROR,
|
|
273
|
+
message=str(error),
|
|
274
|
+
error_type=type(error).__name__,
|
|
275
|
+
traceback=tb,
|
|
276
|
+
**jsonable(meta),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# -- instrumentation helpers ---------------------------------------------
|
|
280
|
+
|
|
281
|
+
def tool(self, fn: Callable) -> Callable:
|
|
282
|
+
"""Decorator: record every call to ``fn`` (sync or async) as a step."""
|
|
283
|
+
|
|
284
|
+
def _record(name, args, kwargs, result=None, error=None, duration_ms=0.0):
|
|
285
|
+
self.record_tool(
|
|
286
|
+
name=name,
|
|
287
|
+
args={"args": jsonable(list(args)), "kwargs": jsonable(dict(kwargs))},
|
|
288
|
+
result=result,
|
|
289
|
+
error=error,
|
|
290
|
+
duration_ms=duration_ms,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if inspect.iscoroutinefunction(fn):
|
|
294
|
+
|
|
295
|
+
@functools.wraps(fn)
|
|
296
|
+
async def async_wrapper(*args, **kwargs):
|
|
297
|
+
t0 = time.perf_counter()
|
|
298
|
+
try:
|
|
299
|
+
result = await fn(*args, **kwargs)
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
_record(fn.__name__, args, kwargs, error=exc,
|
|
302
|
+
duration_ms=(time.perf_counter() - t0) * 1000)
|
|
303
|
+
raise
|
|
304
|
+
_record(fn.__name__, args, kwargs, result=result,
|
|
305
|
+
duration_ms=(time.perf_counter() - t0) * 1000)
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
return async_wrapper
|
|
309
|
+
|
|
310
|
+
@functools.wraps(fn)
|
|
311
|
+
def wrapper(*args, **kwargs):
|
|
312
|
+
t0 = time.perf_counter()
|
|
313
|
+
try:
|
|
314
|
+
result = fn(*args, **kwargs)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
_record(fn.__name__, args, kwargs, error=exc,
|
|
317
|
+
duration_ms=(time.perf_counter() - t0) * 1000)
|
|
318
|
+
raise
|
|
319
|
+
_record(fn.__name__, args, kwargs, result=result,
|
|
320
|
+
duration_ms=(time.perf_counter() - t0) * 1000)
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
return wrapper
|
|
324
|
+
|
|
325
|
+
def capture_openai(self, client: Any) -> Any:
|
|
326
|
+
"""Patch ``client.chat.completions.create`` so every call is recorded.
|
|
327
|
+
|
|
328
|
+
Works on the real ``openai.OpenAI`` / ``openai.AsyncOpenAI`` clients
|
|
329
|
+
and on anything duck-type-shaped the same way (see
|
|
330
|
+
:mod:`backspin.testing`). Returns the same client for chaining.
|
|
331
|
+
"""
|
|
332
|
+
from .integrations.openai import capture_openai
|
|
333
|
+
|
|
334
|
+
return capture_openai(self, client)
|
|
335
|
+
|
|
336
|
+
def capture_anthropic(self, client: Any) -> Any:
|
|
337
|
+
"""Patch ``client.messages.create`` so every call is recorded.
|
|
338
|
+
|
|
339
|
+
Works on the real ``anthropic.Anthropic`` / ``anthropic.AsyncAnthropic``
|
|
340
|
+
clients and anything duck-type-shaped the same way. Recorded events
|
|
341
|
+
carry ``provider="anthropic"`` with native payloads; usage is
|
|
342
|
+
normalized so costs and diffs work across providers.
|
|
343
|
+
"""
|
|
344
|
+
from .integrations.anthropic import capture_anthropic
|
|
345
|
+
|
|
346
|
+
return capture_anthropic(self, client)
|
backspin/redaction.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Sensitive-data redaction for recordings.
|
|
2
|
+
|
|
3
|
+
Pass ``redact=`` to :class:`~backspin.Recorder` and every payload value is
|
|
4
|
+
transformed before it touches disk::
|
|
5
|
+
|
|
6
|
+
from backspin import Recorder
|
|
7
|
+
from backspin.redaction import mask, redact_strings
|
|
8
|
+
|
|
9
|
+
rec = Recorder(
|
|
10
|
+
agent="support-bot",
|
|
11
|
+
redact=redact_strings(mask(r"sk-[A-Za-z0-9]{8,}")),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Recordings still replay, but replayed values are the redacted ones — so
|
|
15
|
+
redact *before* you record and keep raw data out of files entirely.
|
|
16
|
+
LLM request fingerprints are computed from the raw request, so replay
|
|
17
|
+
matching is unaffected by redaction.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Callable, Union
|
|
23
|
+
|
|
24
|
+
Transform = Callable[[Any], Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def redact_strings(transform: Callable[[str], str]) -> Transform:
|
|
28
|
+
"""Build a deep redactor that applies ``transform`` to every string.
|
|
29
|
+
|
|
30
|
+
Dict keys are preserved (only values pass through ``transform``);
|
|
31
|
+
numbers, booleans and nested structures pass through recursively.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def deep(obj: Any) -> Any:
|
|
35
|
+
if isinstance(obj, str):
|
|
36
|
+
return transform(obj)
|
|
37
|
+
if isinstance(obj, dict):
|
|
38
|
+
return {k: deep(v) for k, v in obj.items()}
|
|
39
|
+
if isinstance(obj, (list, tuple)):
|
|
40
|
+
return [deep(v) for v in obj]
|
|
41
|
+
return obj
|
|
42
|
+
|
|
43
|
+
return deep
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mask(pattern: Union[str, "re.Pattern[str]"], repl: str = "[redacted]") -> Callable[[str], str]:
|
|
47
|
+
"""Build a string transform that replaces regex matches."""
|
|
48
|
+
compiled = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
49
|
+
return lambda s: compiled.sub(repl, s)
|