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/replay.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Deterministic replay: run your agent again with recorded LLM responses.
|
|
2
|
+
|
|
3
|
+
A :class:`Cassette` indexes the LLM calls of a recorded run. A stub client
|
|
4
|
+
answers new calls from the cassette, matching by request fingerprint
|
|
5
|
+
(model + messages) and falling back to call order — so you can re-run the
|
|
6
|
+
agent offline, in tests, or against a fix, with the LLM held constant.
|
|
7
|
+
|
|
8
|
+
What-if branching: :meth:`Cassette.mutate` alters one recorded answer and
|
|
9
|
+
:func:`branch` records the mutated replay as a new run, so you can diff
|
|
10
|
+
"what would have happened if the model had said X".
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import json
|
|
16
|
+
import warnings
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from .fakes import FakeResponse, stream_chunks
|
|
22
|
+
from .recorder import Recorder
|
|
23
|
+
from .runfile import Run, fingerprint_request, load_run
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReplayMismatchWarning(UserWarning):
|
|
27
|
+
"""A replayed request did not exactly match its recording."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReplayMismatch(Exception):
|
|
31
|
+
"""The cassette ran out of recordings before the replay finished."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Cassette:
|
|
35
|
+
"""Indexed recordings of LLM calls from one run."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, entries: List[Dict[str, Any]]):
|
|
38
|
+
self.entries = entries
|
|
39
|
+
self._cursor = 0
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_run(cls, run: Run) -> "Cassette":
|
|
43
|
+
return cls([e for e in run.llm_calls() if e.get("response") is not None])
|
|
44
|
+
|
|
45
|
+
def __len__(self) -> int:
|
|
46
|
+
return len(self.entries)
|
|
47
|
+
|
|
48
|
+
def match(self, fingerprint: str) -> Tuple[Optional[Dict[str, Any]], bool]:
|
|
49
|
+
"""Best-effort match against the remaining tape: exact fingerprint
|
|
50
|
+
first, then the next recording in call order.
|
|
51
|
+
|
|
52
|
+
Returns ``(entry, exact)``. ``entry`` is ``None`` when the cassette
|
|
53
|
+
is exhausted. Matching is sequential — each recording is consumed
|
|
54
|
+
at most once, so looping agents surface as exhaustion instead of
|
|
55
|
+
silently looping the same response forever.
|
|
56
|
+
"""
|
|
57
|
+
for i in range(self._cursor, len(self.entries)):
|
|
58
|
+
if self.entries[i].get("fingerprint") == fingerprint:
|
|
59
|
+
self._cursor = i + 1
|
|
60
|
+
return self.entries[i], True
|
|
61
|
+
if self._cursor < len(self.entries):
|
|
62
|
+
entry = self.entries[self._cursor]
|
|
63
|
+
self._cursor += 1
|
|
64
|
+
return entry, False
|
|
65
|
+
return None, False
|
|
66
|
+
|
|
67
|
+
def take(self, fingerprint: str) -> Dict[str, Any]:
|
|
68
|
+
"""Like :meth:`match` but raises on exhaustion and warns on fallback."""
|
|
69
|
+
entry, exact = self.match(fingerprint)
|
|
70
|
+
if entry is None:
|
|
71
|
+
raise ReplayMismatch(
|
|
72
|
+
f"no recording left in cassette (wanted fingerprint {fingerprint})"
|
|
73
|
+
)
|
|
74
|
+
if not exact:
|
|
75
|
+
warnings.warn(
|
|
76
|
+
f"request fingerprint {fingerprint} not in cassette; "
|
|
77
|
+
f"falling back to recording #{self._cursor} by call order",
|
|
78
|
+
ReplayMismatchWarning,
|
|
79
|
+
stacklevel=3,
|
|
80
|
+
)
|
|
81
|
+
return entry
|
|
82
|
+
|
|
83
|
+
def mutate(
|
|
84
|
+
self,
|
|
85
|
+
index: int,
|
|
86
|
+
*,
|
|
87
|
+
content: Optional[str] = None,
|
|
88
|
+
tool_arguments: Optional[Dict[str, Any]] = None,
|
|
89
|
+
) -> "Cassette":
|
|
90
|
+
"""What-if: return a copy with recording ``#index``'s answer altered.
|
|
91
|
+
|
|
92
|
+
``content`` replaces the assistant message text; ``tool_arguments``
|
|
93
|
+
(a dict) replaces the first tool call's arguments, stored as JSON.
|
|
94
|
+
Requests are untouched, so replay matching still works — only the
|
|
95
|
+
answer the agent sees changes. Combine with :func:`branch` or
|
|
96
|
+
``stub_client`` to measure the downstream effect.
|
|
97
|
+
"""
|
|
98
|
+
if not 0 <= index < len(self.entries):
|
|
99
|
+
raise IndexError(
|
|
100
|
+
f"recording #{index} out of range (cassette has {len(self.entries)})"
|
|
101
|
+
)
|
|
102
|
+
entries = copy.deepcopy(self.entries)
|
|
103
|
+
resp = entries[index]["response"]
|
|
104
|
+
if "choices" in resp:
|
|
105
|
+
message = (resp["choices"] or [{}])[0].setdefault(
|
|
106
|
+
"message", {"role": "assistant"}
|
|
107
|
+
)
|
|
108
|
+
if content is not None:
|
|
109
|
+
message["content"] = content
|
|
110
|
+
if tool_arguments is not None:
|
|
111
|
+
calls = message.get("tool_calls")
|
|
112
|
+
if not calls:
|
|
113
|
+
raise ValueError("that recording has no tool calls to mutate")
|
|
114
|
+
calls[0].setdefault("function", {})["arguments"] = json.dumps(tool_arguments)
|
|
115
|
+
elif "content" in resp: # anthropic message: mutate first text block
|
|
116
|
+
if tool_arguments is not None:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"tool_arguments mutation is not supported for anthropic tool_use blocks yet"
|
|
119
|
+
)
|
|
120
|
+
for block in resp["content"] or []:
|
|
121
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
122
|
+
block["text"] = content
|
|
123
|
+
break
|
|
124
|
+
else:
|
|
125
|
+
if content is not None:
|
|
126
|
+
resp.setdefault("content", []).append({"type": "text", "text": content})
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError("unrecognized response shape; cannot mutate")
|
|
129
|
+
return Cassette(entries)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _StubCompletions:
|
|
133
|
+
"""Duck-typed ``chat.completions`` answered from a cassette."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, cassette: Cassette, *, async_mode: bool = False):
|
|
136
|
+
self.cassette = cassette
|
|
137
|
+
self.state = SimpleNamespace(calls=0, mismatches=[])
|
|
138
|
+
self._async = async_mode
|
|
139
|
+
|
|
140
|
+
def _respond(self, **kwargs: Any):
|
|
141
|
+
self.state.calls += 1
|
|
142
|
+
fp = fingerprint_request(kwargs.get("model"), kwargs.get("messages"))
|
|
143
|
+
entry, exact = self.cassette.match(fp)
|
|
144
|
+
if entry is None:
|
|
145
|
+
raise ReplayMismatch(
|
|
146
|
+
f"LLM call #{self.state.calls}: cassette exhausted "
|
|
147
|
+
f"(fingerprint={fp})"
|
|
148
|
+
)
|
|
149
|
+
if not exact:
|
|
150
|
+
self.state.mismatches.append(
|
|
151
|
+
{"call": self.state.calls, "requested": fp,
|
|
152
|
+
"replayed": entry.get("fingerprint")}
|
|
153
|
+
)
|
|
154
|
+
warnings.warn(
|
|
155
|
+
f"LLM call #{self.state.calls}: fingerprint mismatch, "
|
|
156
|
+
f"replaying recording #{self.cassette._cursor} by call order",
|
|
157
|
+
ReplayMismatchWarning,
|
|
158
|
+
stacklevel=2,
|
|
159
|
+
)
|
|
160
|
+
data = dict(entry["response"])
|
|
161
|
+
if kwargs.get("stream"):
|
|
162
|
+
return iter(stream_chunks(data))
|
|
163
|
+
return FakeResponse(data)
|
|
164
|
+
|
|
165
|
+
def create(self, **kwargs: Any):
|
|
166
|
+
if self._async:
|
|
167
|
+
return self._respond_async(**kwargs)
|
|
168
|
+
return self._respond(**kwargs)
|
|
169
|
+
|
|
170
|
+
async def _respond_async(self, **kwargs: Any):
|
|
171
|
+
return self._respond(**kwargs)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stub_client(cassette: Cassette, *, async_mode: bool = False) -> SimpleNamespace:
|
|
175
|
+
"""A client shaped like ``openai.OpenAI`` (the slice agents touch)."""
|
|
176
|
+
completions = _StubCompletions(cassette, async_mode=async_mode)
|
|
177
|
+
return SimpleNamespace(
|
|
178
|
+
chat=SimpleNamespace(completions=completions), state=completions.state
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@contextmanager
|
|
183
|
+
def patch_openai(cassette: Cassette):
|
|
184
|
+
"""Patch ``openai.OpenAI`` / ``openai.AsyncOpenAI`` so constructors
|
|
185
|
+
yield stub clients backed by ``cassette``. Restores on exit.
|
|
186
|
+
|
|
187
|
+
Lets unmodified agent code (``client = OpenAI()``) replay offline.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
import openai
|
|
191
|
+
except ImportError as exc: # pragma: no cover
|
|
192
|
+
raise ImportError(
|
|
193
|
+
"patch_openai() requires the openai package: pip install openai"
|
|
194
|
+
) from exc
|
|
195
|
+
saved = (openai.OpenAI, openai.AsyncOpenAI)
|
|
196
|
+
sync_client = stub_client(cassette)
|
|
197
|
+
async_client = stub_client(cassette, async_mode=True)
|
|
198
|
+
# monkeypatching the constructors is the whole point of this helper
|
|
199
|
+
openai.OpenAI = lambda *a, **k: sync_client # type: ignore[assignment,misc]
|
|
200
|
+
openai.AsyncOpenAI = lambda *a, **k: async_client # type: ignore[assignment,misc]
|
|
201
|
+
try:
|
|
202
|
+
yield SimpleNamespace(sync=sync_client, async_=async_client)
|
|
203
|
+
finally:
|
|
204
|
+
openai.OpenAI, openai.AsyncOpenAI = saved # type: ignore[misc]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def branch(
|
|
208
|
+
run: Union[Run, str],
|
|
209
|
+
mutations: Dict[int, Dict[str, Any]],
|
|
210
|
+
*,
|
|
211
|
+
dir: str = "runs",
|
|
212
|
+
agent: Optional[str] = None,
|
|
213
|
+
) -> str:
|
|
214
|
+
"""What-if: record a replayed run with selected answers mutated.
|
|
215
|
+
|
|
216
|
+
``mutations`` maps a 0-based LLM-call index to
|
|
217
|
+
:meth:`Cassette.mutate` kwargs, e.g. ``{1: {"content": "No."}}``. Every
|
|
218
|
+
recorded LLM request is re-issued in order against the mutated cassette
|
|
219
|
+
and captured into a new run file marked ``branch_of`` in its metadata.
|
|
220
|
+
|
|
221
|
+
Returns the new run's path. Diff it against the original with
|
|
222
|
+
``diff_runs(original, branch, llm_only=True)`` — requests match until
|
|
223
|
+
the mutated answer flows back into a later request, which is exactly
|
|
224
|
+
where the two timelines split.
|
|
225
|
+
"""
|
|
226
|
+
run_obj = load_run(run) if isinstance(run, str) else run
|
|
227
|
+
if not mutations:
|
|
228
|
+
raise ValueError("branch(): pass at least one mutation, e.g. {0: {'content': 'No.'}}")
|
|
229
|
+
cassette = Cassette.from_run(run_obj)
|
|
230
|
+
calls = run_obj.llm_calls()
|
|
231
|
+
|
|
232
|
+
# Text substitutions that carry mutations forward: when a mutated answer
|
|
233
|
+
# would have been fed back into a later request, rewrite that request's
|
|
234
|
+
# assistant messages to the mutated text. (Content-level rewriting; tool
|
|
235
|
+
# call arguments in follow-up requests are left as recorded.)
|
|
236
|
+
substitutions: Dict[str, str] = {}
|
|
237
|
+
for index in sorted(mutations):
|
|
238
|
+
if not 0 <= index < len(calls):
|
|
239
|
+
raise IndexError(
|
|
240
|
+
f"mutation step {index} out of range (run has {len(calls)} LLM calls)"
|
|
241
|
+
)
|
|
242
|
+
before = (
|
|
243
|
+
(calls[index].get("response") or {})
|
|
244
|
+
.get("choices", [{}])[0].get("message", {}).get("content")
|
|
245
|
+
)
|
|
246
|
+
cassette = cassette.mutate(index, **mutations[index])
|
|
247
|
+
after = (cassette.entries[index]["response"]["choices"][0]["message"].get("content"))
|
|
248
|
+
if before is not None and after != before:
|
|
249
|
+
substitutions[before] = after
|
|
250
|
+
|
|
251
|
+
rec = Recorder(
|
|
252
|
+
dir=dir,
|
|
253
|
+
agent=agent or run_obj.agent,
|
|
254
|
+
metadata={
|
|
255
|
+
"branch_of": run_obj.run_id,
|
|
256
|
+
"mutations": {str(k): v for k, v in sorted(mutations.items())},
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
with rec:
|
|
260
|
+
stub = rec.capture_openai(stub_client(cassette))
|
|
261
|
+
with warnings.catch_warnings():
|
|
262
|
+
# fingerprint fallbacks are the point of a branch: mutated
|
|
263
|
+
# answers change later requests by design
|
|
264
|
+
warnings.simplefilter("ignore", ReplayMismatchWarning)
|
|
265
|
+
for entry in calls:
|
|
266
|
+
if entry.get("response") is None:
|
|
267
|
+
continue
|
|
268
|
+
request = dict(entry.get("request") or {})
|
|
269
|
+
messages = [
|
|
270
|
+
dict(m, content=substitutions[m["content"]])
|
|
271
|
+
if m.get("role") == "assistant" and m.get("content") in substitutions
|
|
272
|
+
else m
|
|
273
|
+
for m in (request.get("messages") or [])
|
|
274
|
+
]
|
|
275
|
+
stub.chat.completions.create(
|
|
276
|
+
model=request.get("model"), messages=messages
|
|
277
|
+
)
|
|
278
|
+
return rec.path
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def branch_agent(
|
|
282
|
+
fn: Callable,
|
|
283
|
+
run: Union[Run, str],
|
|
284
|
+
mutations: Dict[int, Dict[str, Any]],
|
|
285
|
+
*,
|
|
286
|
+
dir: str = "runs",
|
|
287
|
+
agent: Optional[str] = None,
|
|
288
|
+
extra_args: Optional[Tuple] = None,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Agent-level what-if: re-run the *actual agent function* against a
|
|
291
|
+
mutated cassette and record it as a branch run.
|
|
292
|
+
|
|
293
|
+
``fn(client, rec, *extra_args)`` is your real agent function: ``client``
|
|
294
|
+
is a stub answering from the (mutated) cassette, and ``rec`` is the
|
|
295
|
+
branch run's recorder, so logs, spans, tool calls and downstream
|
|
296
|
+
requests all happen for real and preserve the run's full shape (unlike
|
|
297
|
+
:func:`branch`, which replays the request sequence only).
|
|
298
|
+
|
|
299
|
+
Returns the new run's path; metadata carries ``branch_of`` +
|
|
300
|
+
``mutations``. Diff against the original with plain ``diff_runs`` —
|
|
301
|
+
shapes align, and the first divergence is where the mutated answer
|
|
302
|
+
changed the conversation.
|
|
303
|
+
"""
|
|
304
|
+
run_obj = load_run(run) if isinstance(run, str) else run
|
|
305
|
+
if not callable(fn):
|
|
306
|
+
raise TypeError("branch_agent(): fn must be callable, taking (client, *extra_args)")
|
|
307
|
+
if not mutations:
|
|
308
|
+
raise ValueError("branch_agent(): pass at least one mutation, e.g. {0: {'content': 'No.'}}")
|
|
309
|
+
cassette = Cassette.from_run(run_obj)
|
|
310
|
+
for index in sorted(mutations):
|
|
311
|
+
cassette = cassette.mutate(index, **mutations[index])
|
|
312
|
+
|
|
313
|
+
rec = Recorder(
|
|
314
|
+
dir=dir,
|
|
315
|
+
agent=agent or run_obj.agent,
|
|
316
|
+
metadata={
|
|
317
|
+
"branch_of": run_obj.run_id,
|
|
318
|
+
"mutations": {str(k): v for k, v in sorted(mutations.items())},
|
|
319
|
+
"branch_level": "agent",
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
with rec:
|
|
323
|
+
stub = rec.capture_openai(stub_client(cassette))
|
|
324
|
+
with warnings.catch_warnings():
|
|
325
|
+
warnings.simplefilter("ignore", ReplayMismatchWarning)
|
|
326
|
+
fn(stub, rec, *(extra_args or ()))
|
|
327
|
+
return rec.path
|
backspin/runfile.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""On-disk run format.
|
|
2
|
+
|
|
3
|
+
A run is a single self-contained ``*.backspin.jsonl`` file. The first line
|
|
4
|
+
is a header event; every following line is one recorded step — an LLM call,
|
|
5
|
+
a tool call, a log line, an error, or any custom event. Because a run is
|
|
6
|
+
one file, "please attach the failing run" becomes a practical bug-report
|
|
7
|
+
ritual instead of a database dump.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
SCHEMA_VERSION = 1
|
|
20
|
+
FILE_SUFFIX = ".backspin.jsonl"
|
|
21
|
+
|
|
22
|
+
HEADER = "header"
|
|
23
|
+
LLM = "llm"
|
|
24
|
+
TOOL = "tool"
|
|
25
|
+
LOG = "log"
|
|
26
|
+
ERROR = "error"
|
|
27
|
+
SPAN = "span"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_run_id() -> str:
|
|
31
|
+
return uuid.uuid4().hex[:12]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def backspin_version() -> str:
|
|
35
|
+
try:
|
|
36
|
+
from importlib.metadata import version
|
|
37
|
+
|
|
38
|
+
return version("backspin")
|
|
39
|
+
except Exception:
|
|
40
|
+
return "0.0.0+unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_json(obj: Any) -> str:
|
|
44
|
+
"""Stable serialization, used for fingerprints."""
|
|
45
|
+
return json.dumps(
|
|
46
|
+
obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def jsonable(obj: Any, max_len: int = 800) -> Any:
|
|
51
|
+
"""Return obj if JSON-serializable, else a truncated repr.
|
|
52
|
+
|
|
53
|
+
A recorder must never crash the agent it is watching, so anything
|
|
54
|
+
exotic degrades to a repr instead of raising.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
json.dumps(obj)
|
|
58
|
+
return obj
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return repr(obj)[:max_len]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fingerprint_request(model: Optional[str], messages: Any) -> str:
|
|
64
|
+
"""Short stable fingerprint of an LLM request (model + messages).
|
|
65
|
+
|
|
66
|
+
Sampling params (temperature, seed, ...) are deliberately excluded:
|
|
67
|
+
replay matching should survive them changing.
|
|
68
|
+
"""
|
|
69
|
+
payload = canonical_json({"model": model, "messages": messages})
|
|
70
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def make_header(
|
|
74
|
+
run_id: str, agent: str, metadata: Optional[Dict[str, Any]] = None
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"kind": HEADER,
|
|
78
|
+
"schema": SCHEMA_VERSION,
|
|
79
|
+
"run_id": run_id,
|
|
80
|
+
"agent": agent,
|
|
81
|
+
"created_at": time.time(),
|
|
82
|
+
"backspin_version": backspin_version(),
|
|
83
|
+
"metadata": dict(metadata or {}),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _token_sums(events: List[Dict[str, Any]]) -> Dict[str, int]:
|
|
88
|
+
prompt = completion = 0
|
|
89
|
+
for e in events:
|
|
90
|
+
usage = e.get("usage") or {}
|
|
91
|
+
prompt += usage.get("prompt_tokens") or 0
|
|
92
|
+
completion += usage.get("completion_tokens") or 0
|
|
93
|
+
return {"prompt_tokens": prompt, "completion_tokens": completion}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Run:
|
|
98
|
+
"""A loaded run file: header + ordered step events."""
|
|
99
|
+
|
|
100
|
+
header: Dict[str, Any]
|
|
101
|
+
events: List[Dict[str, Any]] = field(default_factory=list)
|
|
102
|
+
path: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def run_id(self) -> str:
|
|
106
|
+
return str(self.header.get("run_id", ""))
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def agent(self) -> str:
|
|
110
|
+
return str(self.header.get("agent", ""))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def metadata(self) -> Dict[str, Any]:
|
|
114
|
+
return dict(self.header.get("metadata") or {})
|
|
115
|
+
|
|
116
|
+
def by_kind(self, kind: str) -> List[Dict[str, Any]]:
|
|
117
|
+
return [e for e in self.events if e.get("kind") == kind]
|
|
118
|
+
|
|
119
|
+
def llm_calls(self) -> List[Dict[str, Any]]:
|
|
120
|
+
return self.by_kind(LLM)
|
|
121
|
+
|
|
122
|
+
def tool_calls(self) -> List[Dict[str, Any]]:
|
|
123
|
+
return self.by_kind(TOOL)
|
|
124
|
+
|
|
125
|
+
def totals(self) -> Dict[str, Any]:
|
|
126
|
+
tokens = _token_sums(self.events)
|
|
127
|
+
duration = sum(
|
|
128
|
+
e.get("duration_ms") or 0.0
|
|
129
|
+
for e in self.events
|
|
130
|
+
if e.get("kind") != "span" # nested spans would double-count
|
|
131
|
+
)
|
|
132
|
+
from .cost import cost_report # lazy: keeps this module dependency-light
|
|
133
|
+
|
|
134
|
+
cost = cost_report(self)
|
|
135
|
+
return {
|
|
136
|
+
"steps": len(self.events),
|
|
137
|
+
"llm_calls": len(self.llm_calls()),
|
|
138
|
+
"tool_calls": len(self.tool_calls()),
|
|
139
|
+
**tokens,
|
|
140
|
+
"total_tokens": tokens["prompt_tokens"] + tokens["completion_tokens"],
|
|
141
|
+
"duration_ms": round(duration, 1),
|
|
142
|
+
"cost_usd": cost["total_usd"],
|
|
143
|
+
"cost_complete": cost["complete"],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def summary(self) -> Dict[str, Any]:
|
|
147
|
+
"""Everything list views (CLI / viewer) need about a run."""
|
|
148
|
+
return {
|
|
149
|
+
"name": os.path.basename(self.path) if self.path else self.run_id,
|
|
150
|
+
"run_id": self.run_id,
|
|
151
|
+
"agent": self.agent,
|
|
152
|
+
"created_at": self.header.get("created_at"),
|
|
153
|
+
"backspin_version": self.header.get("backspin_version"),
|
|
154
|
+
"metadata": self.metadata,
|
|
155
|
+
"totals": self.totals(),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_run(path: str) -> Run:
|
|
160
|
+
"""Load and validate a ``*.backspin.jsonl`` run file."""
|
|
161
|
+
header: Optional[Dict[str, Any]] = None
|
|
162
|
+
events: List[Dict[str, Any]] = []
|
|
163
|
+
with open(path, encoding="utf-8") as f:
|
|
164
|
+
for lineno, line in enumerate(f, 1):
|
|
165
|
+
line = line.strip()
|
|
166
|
+
if not line:
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
ev = json.loads(line)
|
|
170
|
+
except json.JSONDecodeError as exc:
|
|
171
|
+
raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
|
|
172
|
+
if not isinstance(ev, dict) or "kind" not in ev:
|
|
173
|
+
raise ValueError(f"{path}:{lineno}: event must be an object with a 'kind'")
|
|
174
|
+
if header is None:
|
|
175
|
+
if ev["kind"] != HEADER:
|
|
176
|
+
raise ValueError(f"{path}: first event must be a header")
|
|
177
|
+
header = ev
|
|
178
|
+
else:
|
|
179
|
+
events.append(ev)
|
|
180
|
+
if header is None:
|
|
181
|
+
raise ValueError(f"{path}: empty run file")
|
|
182
|
+
return Run(header=header, events=events, path=os.path.abspath(path))
|
backspin/server.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Local viewer: a small FastAPI app that serves the timeline UI.
|
|
2
|
+
|
|
3
|
+
Read-only over the runs directory: the API only ever exposes files that
|
|
4
|
+
are plain names directly inside the resolved runs dir, and never serves
|
|
5
|
+
anything outside it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from starlette.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from .diff import diff_runs
|
|
18
|
+
from .runfile import FILE_SUFFIX, load_run
|
|
19
|
+
|
|
20
|
+
UI_DIR = Path(__file__).resolve().parent / "ui"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_runs_dir(runs_dir: str) -> Path:
|
|
24
|
+
root = Path(os.path.realpath(os.path.expanduser(runs_dir)))
|
|
25
|
+
if not root.is_dir():
|
|
26
|
+
raise NotADirectoryError(f"runs directory not found: {runs_dir}")
|
|
27
|
+
return root
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_run_path(root: Path, name: str) -> Path:
|
|
31
|
+
"""Only bare file names directly inside ``root`` are ever readable."""
|
|
32
|
+
if (
|
|
33
|
+
not name.endswith(FILE_SUFFIX)
|
|
34
|
+
or "/" in name
|
|
35
|
+
or "\\" in name
|
|
36
|
+
or name in (".", "..")
|
|
37
|
+
):
|
|
38
|
+
raise HTTPException(status_code=404, detail="no such run")
|
|
39
|
+
path = Path(os.path.realpath(root / name))
|
|
40
|
+
if os.path.dirname(str(path)) != str(root):
|
|
41
|
+
raise HTTPException(status_code=404, detail="no such run")
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise HTTPException(status_code=404, detail="no such run")
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_app(runs_dir: str = "runs") -> FastAPI:
|
|
48
|
+
app = FastAPI(title="backspin viewer", docs_url=None, redoc_url=None)
|
|
49
|
+
root = _resolve_runs_dir(runs_dir)
|
|
50
|
+
|
|
51
|
+
@app.get("/api/runs")
|
|
52
|
+
def list_runs() -> List[Dict[str, Any]]:
|
|
53
|
+
runs = []
|
|
54
|
+
for name in sorted(os.listdir(root), reverse=True):
|
|
55
|
+
if not name.endswith(FILE_SUFFIX):
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
runs.append(load_run(str(root / name)).summary())
|
|
59
|
+
except ValueError:
|
|
60
|
+
runs.append({"name": name, "agent": "?", "invalid": True})
|
|
61
|
+
return runs
|
|
62
|
+
|
|
63
|
+
@app.get("/api/run/{name}")
|
|
64
|
+
def get_run(name: str) -> Dict[str, Any]:
|
|
65
|
+
path = _safe_run_path(root, name)
|
|
66
|
+
try:
|
|
67
|
+
run = load_run(str(path))
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
70
|
+
data = run.summary()
|
|
71
|
+
data["events"] = run.events
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
@app.get("/api/diff")
|
|
75
|
+
def diff(a: str, b: str) -> Dict[str, Any]:
|
|
76
|
+
run_a = load_run(str(_safe_run_path(root, a)))
|
|
77
|
+
run_b = load_run(str(_safe_run_path(root, b)))
|
|
78
|
+
return diff_runs(run_a, run_b).to_dict()
|
|
79
|
+
|
|
80
|
+
@app.exception_handler(NotADirectoryError)
|
|
81
|
+
def bad_dir(_req, exc: NotADirectoryError):
|
|
82
|
+
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
|
83
|
+
|
|
84
|
+
app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
|
|
85
|
+
return app
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def serve(runs_dir: str = "runs", host: str = "127.0.0.1", port: int = 8787) -> None:
|
|
89
|
+
import uvicorn
|
|
90
|
+
|
|
91
|
+
uvicorn.run(create_app(runs_dir), host=host, port=port, log_level="warning")
|
backspin/share.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Single-file HTML sharing: one run + the whole viewer in one .html file.
|
|
2
|
+
|
|
3
|
+
``build_share_html`` inlines the viewer's CSS/JS and the run's data, so a
|
|
4
|
+
teammate can open the file in any browser and step through the run — no
|
|
5
|
+
server, no install, no data leaves the file.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .runfile import load_run
|
|
14
|
+
|
|
15
|
+
_UI_DIR = Path(__file__).resolve().parent / "ui"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_json(obj: Any) -> str:
|
|
19
|
+
"""JSON that is safe to embed inside a <script> block."""
|
|
20
|
+
return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_share_html(run_path: str, title: Optional[str] = None) -> str:
|
|
24
|
+
run = load_run(run_path)
|
|
25
|
+
data = run.summary()
|
|
26
|
+
data["events"] = run.events
|
|
27
|
+
|
|
28
|
+
html = (_UI_DIR / "index.html").read_text(encoding="utf-8")
|
|
29
|
+
css = (_UI_DIR / "style.css").read_text(encoding="utf-8")
|
|
30
|
+
i18n = (_UI_DIR / "i18n.js").read_text(encoding="utf-8")
|
|
31
|
+
js = (_UI_DIR / "app.js").read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
html = html.replace(
|
|
34
|
+
'<link rel="stylesheet" href="style.css">',
|
|
35
|
+
"<style>\n" + css + "\n</style>",
|
|
36
|
+
)
|
|
37
|
+
# i18n.js must be inlined before app.js (app.js calls its applyI18n/t).
|
|
38
|
+
embed = (
|
|
39
|
+
"<script>\n" + i18n + "\n</script>\n"
|
|
40
|
+
"<script>window.__BACKSPIN_EMBED__ = " + _safe_json(data) + ";</script>\n"
|
|
41
|
+
"<script>\n" + js + "\n</script>"
|
|
42
|
+
)
|
|
43
|
+
html = html.replace('<script src="i18n.js"></script>', "")
|
|
44
|
+
html = html.replace('<script src="app.js"></script>', embed)
|
|
45
|
+
if title:
|
|
46
|
+
html = html.replace("<title>backspin — agent 运行查看器</title>",
|
|
47
|
+
"<title>" + title + "</title>")
|
|
48
|
+
return html
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_share_html(run_path: str, out_path: Optional[str] = None) -> str:
|
|
52
|
+
"""Build the share file; returns the written path."""
|
|
53
|
+
run = load_run(run_path)
|
|
54
|
+
if out_path is None:
|
|
55
|
+
out_path = str(Path(run_path).with_suffix(".share.html"))
|
|
56
|
+
html = build_share_html(run_path, title=f"backspin — {run.agent}")
|
|
57
|
+
Path(out_path).write_text(html, encoding="utf-8")
|
|
58
|
+
return out_path
|