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
backspin/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""backspin — the flight recorder for AI agents.
|
|
2
|
+
|
|
3
|
+
Record every LLM and tool call of an agent run to one portable file,
|
|
4
|
+
replay the run deterministically without any API access, and diff two
|
|
5
|
+
runs to find the exact step where behavior diverged.
|
|
6
|
+
"""
|
|
7
|
+
from .diff import DiffReport, StepDiff, diff_runs
|
|
8
|
+
from .recorder import Recorder
|
|
9
|
+
from .replay import (
|
|
10
|
+
Cassette,
|
|
11
|
+
ReplayMismatch,
|
|
12
|
+
ReplayMismatchWarning,
|
|
13
|
+
branch,
|
|
14
|
+
branch_agent,
|
|
15
|
+
patch_openai,
|
|
16
|
+
stub_client,
|
|
17
|
+
)
|
|
18
|
+
from .runfile import FILE_SUFFIX, Run, load_run
|
|
19
|
+
|
|
20
|
+
__version__ = "0.5.1"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"FILE_SUFFIX",
|
|
24
|
+
"Cassette",
|
|
25
|
+
"DiffReport",
|
|
26
|
+
"Recorder",
|
|
27
|
+
"ReplayMismatch",
|
|
28
|
+
"ReplayMismatchWarning",
|
|
29
|
+
"Run",
|
|
30
|
+
"StepDiff",
|
|
31
|
+
"__version__",
|
|
32
|
+
"branch",
|
|
33
|
+
"branch_agent",
|
|
34
|
+
"diff_runs",
|
|
35
|
+
"load_run",
|
|
36
|
+
"patch_openai",
|
|
37
|
+
"stub_client",
|
|
38
|
+
]
|
backspin/cli.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The ``backspin`` command line: ls, show, diff, ui."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .diff import DiffReport, diff_runs
|
|
13
|
+
from .runfile import FILE_SUFFIX, load_run
|
|
14
|
+
|
|
15
|
+
# --- tiny ANSI helpers (ASCII output only, Windows-console safe) ----------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class C:
|
|
19
|
+
"""ANSI helpers: c.red("text") when colors are on, plain text otherwise."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, enabled: bool):
|
|
22
|
+
self.on = enabled
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def reset(self) -> str:
|
|
26
|
+
return "\033[0m" if self.on else ""
|
|
27
|
+
|
|
28
|
+
def _w(self, code: str, s: str) -> str:
|
|
29
|
+
return f"\033[{code}m{s}\033[0m" if self.on else str(s)
|
|
30
|
+
|
|
31
|
+
def dim(self, s):
|
|
32
|
+
return self._w("2", s)
|
|
33
|
+
|
|
34
|
+
def bold(self, s):
|
|
35
|
+
return self._w("1", s)
|
|
36
|
+
|
|
37
|
+
def red(self, s):
|
|
38
|
+
return self._w("31", s)
|
|
39
|
+
|
|
40
|
+
def green(self, s):
|
|
41
|
+
return self._w("32", s)
|
|
42
|
+
|
|
43
|
+
def yellow(self, s):
|
|
44
|
+
return self._w("33", s)
|
|
45
|
+
|
|
46
|
+
def blue(self, s):
|
|
47
|
+
return self._w("36", s)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _color() -> C:
|
|
51
|
+
return C(sys.stdout.isatty())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fmt_ms(ms: float) -> str:
|
|
55
|
+
if ms >= 1000:
|
|
56
|
+
return f"{ms / 1000:.2f}s"
|
|
57
|
+
return f"{ms:.0f}ms"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _find_runs(dir_path: str) -> List[str]:
|
|
61
|
+
pattern = os.path.join(dir_path, f"*{FILE_SUFFIX}")
|
|
62
|
+
return sorted(glob.glob(pattern), reverse=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- subcommands -----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
69
|
+
c = _color()
|
|
70
|
+
files = _find_runs(args.dir)
|
|
71
|
+
if not files:
|
|
72
|
+
print(c.dim(f"no {FILE_SUFFIX} files under {args.dir!r}") + c.reset)
|
|
73
|
+
return 1
|
|
74
|
+
header = f"{'run':<44} {'agent':<14} {'steps':>5} {'llm':>4} {'tool':>4} {'tokens':>7}"
|
|
75
|
+
print(c.bold(header) + c.reset)
|
|
76
|
+
for path in files:
|
|
77
|
+
try:
|
|
78
|
+
run = load_run(path)
|
|
79
|
+
except ValueError as exc:
|
|
80
|
+
print(f"{os.path.basename(path):<44} {c.red('invalid: ' + str(exc))}{c.reset}")
|
|
81
|
+
continue
|
|
82
|
+
t = run.totals()
|
|
83
|
+
print(
|
|
84
|
+
f"{os.path.basename(path):<44} {run.agent:<14} {t['steps']:>5} "
|
|
85
|
+
f"{t['llm_calls']:>4} {t['tool_calls']:>4} {t['total_tokens']:>7}"
|
|
86
|
+
)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
91
|
+
c = _color()
|
|
92
|
+
run = load_run(args.file)
|
|
93
|
+
if args.json:
|
|
94
|
+
print(json.dumps(run.summary() | {"events": run.events}, ensure_ascii=False, indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
if args.step is not None:
|
|
97
|
+
for ev in run.events:
|
|
98
|
+
if ev.get("seq") == args.step:
|
|
99
|
+
print(json.dumps(ev, ensure_ascii=False, indent=2, default=str))
|
|
100
|
+
return 0
|
|
101
|
+
print(c.red(f"no step #{args.step} in {args.file}") + c.reset)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
t = run.totals()
|
|
105
|
+
meta = run.metadata
|
|
106
|
+
lineage = []
|
|
107
|
+
if "replay_of" in meta:
|
|
108
|
+
lineage.append(f"replay of {meta['replay_of']}")
|
|
109
|
+
if "branch_of" in meta:
|
|
110
|
+
lineage.append(f"branch of {meta['branch_of']}")
|
|
111
|
+
lineage_s = f" ({', '.join(lineage)})" if lineage else ""
|
|
112
|
+
cost = f" ~${t['cost_usd']:.4f}" + ("" if t.get("cost_complete") else "+")
|
|
113
|
+
print(c.bold(f"run {run.run_id}") + f" agent={run.agent}{lineage_s}{cost}")
|
|
114
|
+
print(
|
|
115
|
+
c.dim(
|
|
116
|
+
f"steps={t['steps']} llm={t['llm_calls']} tool={t['tool_calls']} "
|
|
117
|
+
f"tokens={t['prompt_tokens']}+{t['completion_tokens']} "
|
|
118
|
+
f"duration={_fmt_ms(t['duration_ms'])}"
|
|
119
|
+
)
|
|
120
|
+
+ c.reset
|
|
121
|
+
)
|
|
122
|
+
print()
|
|
123
|
+
for ev in run.events:
|
|
124
|
+
kind = ev.get("kind", "?")
|
|
125
|
+
seq = ev.get("seq", 0)
|
|
126
|
+
dur = _fmt_ms(ev.get("duration_ms") or 0)
|
|
127
|
+
indent = " " * (ev.get("depth") or 0)
|
|
128
|
+
if kind == "llm":
|
|
129
|
+
usage = ev.get("usage") or {}
|
|
130
|
+
tok = f"tok {usage.get('prompt_tokens', 0)}+{usage.get('completion_tokens', 0)}"
|
|
131
|
+
err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
|
|
132
|
+
print(f"{indent} #{seq:<3} llm {ev.get('model') or '?':<20} {dur:>8} {tok}{err}")
|
|
133
|
+
elif kind == "tool":
|
|
134
|
+
err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
|
|
135
|
+
print(f"{indent} #{seq:<3} tool {ev.get('name') or '?':<20} {dur:>8}{err}")
|
|
136
|
+
elif kind == "span":
|
|
137
|
+
phase = "enter" if ev.get("phase") == "enter" else "exit "
|
|
138
|
+
err = " " + c.red("ERR " + ev["error"]) if ev.get("error") else ""
|
|
139
|
+
print(
|
|
140
|
+
f"{indent} #{seq:<3} span {c.blue('[' + phase + '] ' + str(ev.get('name', '')))}"
|
|
141
|
+
f"{c.reset}{dur:>8}{err}"
|
|
142
|
+
)
|
|
143
|
+
elif kind == "log":
|
|
144
|
+
print(f"{indent} #{seq:<3} log {c.dim(str(ev.get('message', '')))}{c.reset}")
|
|
145
|
+
elif kind == "error":
|
|
146
|
+
etype = str(ev.get("error_type", ""))
|
|
147
|
+
msg = str(ev.get("message", ""))
|
|
148
|
+
print(f"{indent} #{seq:<3} {c.red('error ' + etype + ': ' + msg)}{c.reset}")
|
|
149
|
+
else:
|
|
150
|
+
print(f"{indent} #{seq:<3} {kind}")
|
|
151
|
+
print()
|
|
152
|
+
print(c.dim(f"inspect a step: backspin show {os.path.basename(args.file)} --step N") + c.reset)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
157
|
+
c = _color()
|
|
158
|
+
a = load_run(args.a)
|
|
159
|
+
b = load_run(args.b)
|
|
160
|
+
report = diff_runs(a, b, llm_only=args.llm_only)
|
|
161
|
+
_print_diff(report, c)
|
|
162
|
+
return 0 if report.identical else 1
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _print_diff(report: DiffReport, c: C) -> None:
|
|
166
|
+
ta, tb = report.totals_a, report.totals_b
|
|
167
|
+
if report.identical:
|
|
168
|
+
print(c.green("runs are identical (same steps, same LLM requests)") + c.reset)
|
|
169
|
+
elif report.first_divergence is not None:
|
|
170
|
+
print(
|
|
171
|
+
c.yellow(f"runs diverge at step #{report.first_divergence}") + c.reset
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
print(c.yellow("same requests, different step counts") + c.reset)
|
|
175
|
+
print(
|
|
176
|
+
c.dim(
|
|
177
|
+
f"tokens: {ta['total_tokens']} vs {tb['total_tokens']} "
|
|
178
|
+
f"duration: {_fmt_ms(ta['duration_ms'])} vs {_fmt_ms(tb['duration_ms'])} "
|
|
179
|
+
f"steps: {ta['steps']} vs {tb['steps']}"
|
|
180
|
+
)
|
|
181
|
+
+ c.reset
|
|
182
|
+
)
|
|
183
|
+
print()
|
|
184
|
+
print(f"{'#':>4} {'kind':<5} {'A':<28} {'B':<28} {'same':>5}")
|
|
185
|
+
for s in report.steps:
|
|
186
|
+
la = s.a["label"] if s.a else "-"
|
|
187
|
+
lb = s.b["label"] if s.b else "-"
|
|
188
|
+
if s.same is None:
|
|
189
|
+
mark = c.red("solo")
|
|
190
|
+
elif s.same:
|
|
191
|
+
mark = c.green("yes")
|
|
192
|
+
else:
|
|
193
|
+
mark = c.red("NO")
|
|
194
|
+
print(f"{s.index:>4} {s.kind:<5} {la:<28} {lb:<28} {mark}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_ui(args: argparse.Namespace) -> int:
|
|
198
|
+
try:
|
|
199
|
+
import uvicorn
|
|
200
|
+
except ImportError:
|
|
201
|
+
print("the viewer needs two extra packages:")
|
|
202
|
+
print(" pip install 'backspin[ui]'")
|
|
203
|
+
return 1
|
|
204
|
+
from .server import create_app
|
|
205
|
+
|
|
206
|
+
app = create_app(args.dir)
|
|
207
|
+
print(f"backspin viewer -> http://{args.host}:{args.port} (runs dir: {args.dir})")
|
|
208
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_branch(args: argparse.Namespace) -> int:
|
|
213
|
+
c = _color()
|
|
214
|
+
from .replay import branch as make_branch
|
|
215
|
+
|
|
216
|
+
change: dict = {}
|
|
217
|
+
if args.content is not None:
|
|
218
|
+
change["content"] = args.content
|
|
219
|
+
if args.tool_args:
|
|
220
|
+
try:
|
|
221
|
+
change["tool_arguments"] = json.loads(args.tool_args)
|
|
222
|
+
except json.JSONDecodeError as exc:
|
|
223
|
+
print(c.red(f"--tool-args is not valid JSON: {exc}") + c.reset)
|
|
224
|
+
return 2
|
|
225
|
+
if not change:
|
|
226
|
+
print(c.red("nothing to mutate: pass --content and/or --tool-args") + c.reset)
|
|
227
|
+
return 2
|
|
228
|
+
|
|
229
|
+
path = make_branch(args.file, {args.step: change}, dir=args.dir)
|
|
230
|
+
print(c.bold("branch run:") + f" {path}")
|
|
231
|
+
print()
|
|
232
|
+
report = diff_runs(load_run(args.file), load_run(path), llm_only=True)
|
|
233
|
+
_print_diff(report, c)
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_proxy(args: argparse.Namespace) -> int:
|
|
238
|
+
if bool(args.upstream) == bool(args.replay):
|
|
239
|
+
print("choose exactly one: --upstream URL (record) or --replay FILE (replay)")
|
|
240
|
+
return 2
|
|
241
|
+
try:
|
|
242
|
+
import uvicorn
|
|
243
|
+
except ImportError:
|
|
244
|
+
print("proxy mode needs: pip install 'backspin[proxy]'")
|
|
245
|
+
return 1
|
|
246
|
+
from .proxy import create_proxy_app
|
|
247
|
+
|
|
248
|
+
cassette = None
|
|
249
|
+
if args.replay:
|
|
250
|
+
from .replay import Cassette
|
|
251
|
+
|
|
252
|
+
cassette = Cassette.from_run(load_run(args.replay))
|
|
253
|
+
app = create_proxy_app(
|
|
254
|
+
upstream=args.upstream, cassette=cassette, runs_dir=args.dir
|
|
255
|
+
)
|
|
256
|
+
mode = (
|
|
257
|
+
f"replay of {os.path.basename(args.replay)}" if args.replay
|
|
258
|
+
else f"record -> {args.upstream}"
|
|
259
|
+
)
|
|
260
|
+
print(f"backspin proxy [{mode}]")
|
|
261
|
+
print(f"endpoint : http://{args.host}:{args.port}/v1 (point your agent's base_url here)")
|
|
262
|
+
if cassette is None:
|
|
263
|
+
print(f"recording: {app.state.recorder.path}")
|
|
264
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
269
|
+
from .export import export
|
|
270
|
+
|
|
271
|
+
text = export(load_run(args.file), fmt=args.format)
|
|
272
|
+
if args.out:
|
|
273
|
+
from pathlib import Path
|
|
274
|
+
|
|
275
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
276
|
+
print(f"wrote {args.out} ({len(text.splitlines())} lines)")
|
|
277
|
+
else:
|
|
278
|
+
sys.stdout.write(text)
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_share(args: argparse.Namespace) -> int:
|
|
283
|
+
from .share import write_share_html
|
|
284
|
+
|
|
285
|
+
out = write_share_html(args.file, args.out)
|
|
286
|
+
print(f"shared viewer written to: {out}")
|
|
287
|
+
print("send it to anyone — it opens in a browser, no install needed")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_tui(args: argparse.Namespace) -> int:
|
|
292
|
+
from .tui import run_tui
|
|
293
|
+
|
|
294
|
+
run_tui(args.dir)
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --- parser -----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
302
|
+
p = argparse.ArgumentParser(
|
|
303
|
+
prog="backspin",
|
|
304
|
+
description="The flight recorder for AI agents: record, replay, diff.",
|
|
305
|
+
)
|
|
306
|
+
p.add_argument("--version", action="version", version=f"backspin {__version__}")
|
|
307
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
308
|
+
|
|
309
|
+
ls = sub.add_parser("ls", help="list recorded runs in a directory")
|
|
310
|
+
ls.add_argument("dir", nargs="?", default="runs", help="runs directory (default: runs)")
|
|
311
|
+
ls.set_defaults(fn=cmd_ls)
|
|
312
|
+
|
|
313
|
+
show = sub.add_parser("show", help="show one run's timeline")
|
|
314
|
+
show.add_argument("file", help="path to a .backspin.jsonl run file")
|
|
315
|
+
show.add_argument("--step", type=int, default=None, help="print one step as JSON")
|
|
316
|
+
show.add_argument("--json", action="store_true", help="dump the whole run as JSON")
|
|
317
|
+
show.set_defaults(fn=cmd_show)
|
|
318
|
+
|
|
319
|
+
diff = sub.add_parser("diff", help="diff two runs; exits 1 when they differ")
|
|
320
|
+
diff.add_argument("a", help="first run file")
|
|
321
|
+
diff.add_argument("b", help="second run file")
|
|
322
|
+
diff.add_argument("--llm-only", action="store_true", help="align LLM calls only")
|
|
323
|
+
diff.set_defaults(fn=cmd_diff)
|
|
324
|
+
|
|
325
|
+
br = sub.add_parser("branch", help="what-if: replay a run with one answer mutated")
|
|
326
|
+
br.add_argument("file", help="run file to branch from")
|
|
327
|
+
br.add_argument("--step", type=int, required=True, help="0-based LLM-call index to mutate")
|
|
328
|
+
br.add_argument("--content", default=None, help="replacement assistant content")
|
|
329
|
+
br.add_argument(
|
|
330
|
+
"--tool-args", default=None,
|
|
331
|
+
help='replacement tool args as JSON, e.g. \'{"city": "Rome"}\'',
|
|
332
|
+
)
|
|
333
|
+
br.add_argument("--dir", default="runs", help="where to write the branch run")
|
|
334
|
+
br.set_defaults(fn=cmd_branch)
|
|
335
|
+
|
|
336
|
+
px = sub.add_parser("proxy", help="OpenAI-compatible local proxy: record or replay")
|
|
337
|
+
px.add_argument("--upstream", default=None, help="record mode: e.g. https://api.openai.com")
|
|
338
|
+
px.add_argument("--replay", default=None, help="replay mode: a recorded run file")
|
|
339
|
+
px.add_argument("--dir", default="runs", help="where recorded runs are written")
|
|
340
|
+
px.add_argument("--host", default="127.0.0.1")
|
|
341
|
+
px.add_argument("--port", type=int, default=8840)
|
|
342
|
+
px.set_defaults(fn=cmd_proxy)
|
|
343
|
+
|
|
344
|
+
ex = sub.add_parser("export", help="export a run as a dataset (JSONL)")
|
|
345
|
+
ex.add_argument("file", help="run file to export")
|
|
346
|
+
ex.add_argument("--format", choices=["pairs", "sft"], default="pairs",
|
|
347
|
+
help="pairs: one line per LLM call; sft: one chat sample per run")
|
|
348
|
+
ex.add_argument("-o", "--out", default=None, help="output file (default: stdout)")
|
|
349
|
+
ex.set_defaults(fn=cmd_export)
|
|
350
|
+
|
|
351
|
+
sh = sub.add_parser("share", help="bundle a run + viewer into one HTML file")
|
|
352
|
+
sh.add_argument("file", help="run file to share")
|
|
353
|
+
sh.add_argument("-o", "--out", default=None, help="output path (default: <run>.share.html)")
|
|
354
|
+
sh.set_defaults(fn=cmd_share)
|
|
355
|
+
|
|
356
|
+
tui = sub.add_parser("tui", help="keyboard-driven terminal viewer")
|
|
357
|
+
tui.add_argument("--dir", default="runs", help="runs directory")
|
|
358
|
+
tui.set_defaults(fn=cmd_tui)
|
|
359
|
+
|
|
360
|
+
ui = sub.add_parser("ui", help="launch the local timeline viewer")
|
|
361
|
+
ui.add_argument("--dir", default="runs", help="runs directory (default: runs)")
|
|
362
|
+
ui.add_argument("--host", default="127.0.0.1")
|
|
363
|
+
ui.add_argument("--port", type=int, default=8787)
|
|
364
|
+
ui.set_defaults(fn=cmd_ui)
|
|
365
|
+
|
|
366
|
+
return p
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
370
|
+
args = build_parser().parse_args(argv)
|
|
371
|
+
try:
|
|
372
|
+
return args.fn(args)
|
|
373
|
+
except (ValueError, OSError) as exc:
|
|
374
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
375
|
+
return 2
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
sys.exit(main())
|
backspin/cost.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Token cost estimation for recorded runs.
|
|
2
|
+
|
|
3
|
+
Prices are USD per 1M tokens from public list pricing (collected 2026-08;
|
|
4
|
+
models drift — override or extend ``PRICE_TABLE`` as needed). Matching is
|
|
5
|
+
exact first, then longest-prefix, so dated snapshots like
|
|
6
|
+
``gpt-4o-2024-11-20`` resolve to their base model.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
# model prefix -> (USD per 1M input tokens, USD per 1M output tokens)
|
|
14
|
+
PRICE_TABLE: Dict[str, Tuple[float, float]] = {
|
|
15
|
+
"gpt-4o-mini": (0.15, 0.60),
|
|
16
|
+
"gpt-4o": (2.50, 10.00),
|
|
17
|
+
"gpt-4.1-mini": (0.40, 1.60),
|
|
18
|
+
"gpt-4.1-nano": (0.10, 0.40),
|
|
19
|
+
"gpt-4.1": (2.00, 8.00),
|
|
20
|
+
"gpt-4-turbo": (10.00, 30.00),
|
|
21
|
+
"gpt-4": (30.00, 60.00),
|
|
22
|
+
"gpt-3.5-turbo": (0.50, 1.50),
|
|
23
|
+
"o3-mini": (1.10, 4.40),
|
|
24
|
+
"o3": (2.00, 8.00),
|
|
25
|
+
"o4-mini": (1.10, 4.40),
|
|
26
|
+
"claude-opus-4": (15.00, 75.00),
|
|
27
|
+
"claude-sonnet-4": (3.00, 15.00),
|
|
28
|
+
"claude-3-7-sonnet": (3.00, 15.00),
|
|
29
|
+
"claude-3-5-haiku": (0.80, 4.00),
|
|
30
|
+
"gemini-2.5-pro": (1.25, 10.00),
|
|
31
|
+
"gemini-2.5-flash": (0.30, 2.50),
|
|
32
|
+
"deepseek-chat": (0.27, 1.10),
|
|
33
|
+
"deepseek-reasoner": (0.55, 2.19),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_prefixes = sorted(PRICE_TABLE, key=len, reverse=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def lookup_price(model: Optional[str]) -> Optional[Tuple[float, float]]:
|
|
40
|
+
"""Find (input, output) per-1M-token prices for a model name."""
|
|
41
|
+
if not model:
|
|
42
|
+
return None
|
|
43
|
+
name = re.sub(r"^(anthropic/|openai/|google/|deepseek/)", "", model.strip(), flags=re.I)
|
|
44
|
+
if name in PRICE_TABLE:
|
|
45
|
+
return PRICE_TABLE[name]
|
|
46
|
+
for prefix in _prefixes:
|
|
47
|
+
if name.startswith(prefix):
|
|
48
|
+
return PRICE_TABLE[prefix]
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def estimate_cost(
|
|
53
|
+
model: Optional[str], prompt_tokens: Optional[int], completion_tokens: Optional[int]
|
|
54
|
+
) -> Optional[float]:
|
|
55
|
+
"""Cost of one LLM call in USD, or None when the model is unknown."""
|
|
56
|
+
price = lookup_price(model)
|
|
57
|
+
if price is None or not (prompt_tokens or completion_tokens):
|
|
58
|
+
return None
|
|
59
|
+
in_price, out_price = price
|
|
60
|
+
return (prompt_tokens or 0) / 1e6 * in_price + (completion_tokens or 0) / 1e6 * out_price
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cost_report(run: Any) -> Dict[str, Any]:
|
|
64
|
+
"""Sum estimated cost over a run's LLM calls.
|
|
65
|
+
|
|
66
|
+
Returns ``{"total_usd": float, "complete": bool, "unknown_models": [...]}``.
|
|
67
|
+
``complete`` is False when at least one LLM call used a model outside
|
|
68
|
+
the price table (its cost is not counted).
|
|
69
|
+
"""
|
|
70
|
+
total = 0.0
|
|
71
|
+
unknown = set()
|
|
72
|
+
for ev in run.llm_calls():
|
|
73
|
+
usage = ev.get("usage") or {}
|
|
74
|
+
cost = estimate_cost(
|
|
75
|
+
ev.get("model"), usage.get("prompt_tokens"), usage.get("completion_tokens")
|
|
76
|
+
)
|
|
77
|
+
if cost is None:
|
|
78
|
+
unknown.add(ev.get("model") or "?")
|
|
79
|
+
else:
|
|
80
|
+
total += cost
|
|
81
|
+
return {
|
|
82
|
+
"total_usd": round(total, 6),
|
|
83
|
+
"complete": not unknown,
|
|
84
|
+
"unknown_models": sorted(unknown),
|
|
85
|
+
}
|
backspin/diff.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Compare two runs: where they diverged, and by how much.
|
|
2
|
+
|
|
3
|
+
Runs are aligned step by step. Each step gets a *signature* — what the
|
|
4
|
+
agent chose to do (LLM request fingerprint, tool name, log text) — so the
|
|
5
|
+
first mismatch marks the moment the two runs stopped doing the same thing.
|
|
6
|
+
Durations and token counts are reported as deltas, never compared.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .runfile import LLM, TOOL, Run
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _signature(ev: Dict[str, Any]) -> Tuple:
|
|
17
|
+
kind = ev.get("kind")
|
|
18
|
+
if kind == LLM:
|
|
19
|
+
fp = ev.get("fingerprint")
|
|
20
|
+
if fp is None:
|
|
21
|
+
req = ev.get("request") or {}
|
|
22
|
+
fp = str(req.get("messages"))
|
|
23
|
+
return (LLM, fp)
|
|
24
|
+
if kind == TOOL:
|
|
25
|
+
return (TOOL, ev.get("name"))
|
|
26
|
+
if kind == "span":
|
|
27
|
+
return ("span", ev.get("phase"), ev.get("name"))
|
|
28
|
+
return (kind, ev.get("message") or ev.get("error_type"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _metrics(ev: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
32
|
+
if ev is None:
|
|
33
|
+
return None
|
|
34
|
+
usage = ev.get("usage") or {}
|
|
35
|
+
label = ev.get("model") or ev.get("name") or (ev.get("message") or "")[:60]
|
|
36
|
+
return {
|
|
37
|
+
"label": label,
|
|
38
|
+
"duration_ms": ev.get("duration_ms") or 0.0,
|
|
39
|
+
"prompt_tokens": usage.get("prompt_tokens") or 0,
|
|
40
|
+
"completion_tokens": usage.get("completion_tokens") or 0,
|
|
41
|
+
"error": ev.get("error") or None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class StepDiff:
|
|
47
|
+
index: int
|
|
48
|
+
kind: str
|
|
49
|
+
a: Optional[Dict[str, Any]]
|
|
50
|
+
b: Optional[Dict[str, Any]]
|
|
51
|
+
same: Optional[bool] # None when the step exists on only one side
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DiffReport:
|
|
56
|
+
a: Dict[str, Any]
|
|
57
|
+
b: Dict[str, Any]
|
|
58
|
+
steps: List[StepDiff] = field(default_factory=list)
|
|
59
|
+
first_divergence: Optional[int] = None
|
|
60
|
+
identical: bool = False
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def totals_a(self) -> Dict[str, Any]:
|
|
64
|
+
return self.a["totals"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def totals_b(self) -> Dict[str, Any]:
|
|
68
|
+
return self.b["totals"]
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"a": self.a,
|
|
73
|
+
"b": self.b,
|
|
74
|
+
"steps": [asdict(s) for s in self.steps],
|
|
75
|
+
"first_divergence": self.first_divergence,
|
|
76
|
+
"identical": self.identical,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def diff_runs(a: Run, b: Run, *, llm_only: bool = False) -> DiffReport:
|
|
81
|
+
"""Diff two recorded runs of (nominally) the same agent.
|
|
82
|
+
|
|
83
|
+
``llm_only=True`` restricts the alignment to LLM calls — useful when
|
|
84
|
+
comparing a :func:`backspin.replay.branch` run (LLM-only shape) against
|
|
85
|
+
the full original.
|
|
86
|
+
"""
|
|
87
|
+
if llm_only:
|
|
88
|
+
from .runfile import Run as _Run
|
|
89
|
+
|
|
90
|
+
a = _Run(header=a.header, events=a.llm_calls(), path=a.path)
|
|
91
|
+
b = _Run(header=b.header, events=b.llm_calls(), path=b.path)
|
|
92
|
+
sigs_a = [_signature(e) for e in a.events]
|
|
93
|
+
sigs_b = [_signature(e) for e in b.events]
|
|
94
|
+
n = max(len(a.events), len(b.events))
|
|
95
|
+
|
|
96
|
+
steps: List[StepDiff] = []
|
|
97
|
+
first_divergence: Optional[int] = None
|
|
98
|
+
identical = len(a.events) == len(b.events)
|
|
99
|
+
|
|
100
|
+
for i in range(n):
|
|
101
|
+
ev_a = a.events[i] if i < len(a.events) else None
|
|
102
|
+
ev_b = b.events[i] if i < len(b.events) else None
|
|
103
|
+
same: Optional[bool] = None
|
|
104
|
+
if ev_a is not None and ev_b is not None:
|
|
105
|
+
same = sigs_a[i] == sigs_b[i]
|
|
106
|
+
if same is False and first_divergence is None:
|
|
107
|
+
first_divergence = i
|
|
108
|
+
if same is not True:
|
|
109
|
+
identical = False
|
|
110
|
+
kind = (ev_a or ev_b or {}).get("kind", "?")
|
|
111
|
+
steps.append(
|
|
112
|
+
StepDiff(index=i, kind=kind, a=_metrics(ev_a), b=_metrics(ev_b), same=same)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return DiffReport(
|
|
116
|
+
a=a.summary(),
|
|
117
|
+
b=b.summary(),
|
|
118
|
+
steps=steps,
|
|
119
|
+
first_divergence=first_divergence,
|
|
120
|
+
identical=identical,
|
|
121
|
+
)
|
backspin/export.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Export recorded runs as training/eval datasets.
|
|
2
|
+
|
|
3
|
+
Two formats:
|
|
4
|
+
|
|
5
|
+
- ``pairs`` — one JSON line per LLM call: {"messages", "response", "model"}.
|
|
6
|
+
Good for building eval sets from real traffic.
|
|
7
|
+
- ``sft`` — one JSON line per run: the final conversation
|
|
8
|
+
({"messages": [..., {"role": "assistant", ...}]}) in chat fine-tune style.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Dict, List
|
|
14
|
+
|
|
15
|
+
from .runfile import Run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _assistant_text(response: Any) -> str:
|
|
19
|
+
"""Extract plain assistant text from either protocol's response shape."""
|
|
20
|
+
if not isinstance(response, dict):
|
|
21
|
+
return ""
|
|
22
|
+
if "choices" in response: # OpenAI shape
|
|
23
|
+
message = (response.get("choices") or [{}])[0].get("message") or {}
|
|
24
|
+
content = message.get("content")
|
|
25
|
+
return content if isinstance(content, str) else ""
|
|
26
|
+
if "content" in response: # Anthropic shape
|
|
27
|
+
parts = []
|
|
28
|
+
for block in response.get("content") or []:
|
|
29
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
30
|
+
parts.append(block.get("text") or "")
|
|
31
|
+
return "".join(parts)
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def export_pairs(run: Run) -> List[Dict[str, Any]]:
|
|
36
|
+
rows = []
|
|
37
|
+
for event in run.llm_calls():
|
|
38
|
+
response = event.get("response")
|
|
39
|
+
if not response:
|
|
40
|
+
continue
|
|
41
|
+
request = dict(event.get("request") or {})
|
|
42
|
+
rows.append({
|
|
43
|
+
"messages": request.get("messages") or [],
|
|
44
|
+
"response": _assistant_text(response),
|
|
45
|
+
"model": event.get("model"),
|
|
46
|
+
"run_id": run.run_id,
|
|
47
|
+
"seq": event.get("seq"),
|
|
48
|
+
})
|
|
49
|
+
return rows
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def export_sft(run: Run) -> List[Dict[str, Any]]:
|
|
53
|
+
calls = [e for e in run.llm_calls() if e.get("response")]
|
|
54
|
+
if not calls:
|
|
55
|
+
return []
|
|
56
|
+
last = calls[-1]
|
|
57
|
+
messages = list((last.get("request") or {}).get("messages") or [])
|
|
58
|
+
messages = [dict(m) for m in messages]
|
|
59
|
+
messages.append({"role": "assistant", "content": _assistant_text(last["response"])})
|
|
60
|
+
return [{"messages": messages, "model": last.get("model"), "run_id": run.run_id}]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def export(run: Run, fmt: str = "pairs") -> str:
|
|
64
|
+
"""Export a run as JSONL text in the given format."""
|
|
65
|
+
if fmt == "pairs":
|
|
66
|
+
rows = export_pairs(run)
|
|
67
|
+
elif fmt == "sft":
|
|
68
|
+
rows = export_sft(run)
|
|
69
|
+
else:
|
|
70
|
+
raise ValueError(f"unknown export format: {fmt!r} (use 'pairs' or 'sft')")
|
|
71
|
+
return "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows)
|