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,298 @@
|
|
|
1
|
+
"""Capture OpenAI-SDK-shaped chat completion calls into a Recorder.
|
|
2
|
+
|
|
3
|
+
The wrapper duck-types: it works with the real ``openai.OpenAI`` /
|
|
4
|
+
``openai.AsyncOpenAI`` clients and with any object shaped like them (see
|
|
5
|
+
:mod:`backspin.testing`), sync or async, streaming or not.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import inspect
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from ..fakes import stream_chunks # noqa: F401 (re-exported for replay/tests)
|
|
15
|
+
from ..runfile import jsonable
|
|
16
|
+
|
|
17
|
+
# Request fields worth keeping in a recording. Everything else (clients,
|
|
18
|
+
# callables, ...) is dropped rather than repr-ed to keep run files clean.
|
|
19
|
+
_KEEP = {
|
|
20
|
+
"model",
|
|
21
|
+
"messages",
|
|
22
|
+
"tools",
|
|
23
|
+
"tool_choice",
|
|
24
|
+
"temperature",
|
|
25
|
+
"top_p",
|
|
26
|
+
"max_tokens",
|
|
27
|
+
"max_completion_tokens",
|
|
28
|
+
"stop",
|
|
29
|
+
"n",
|
|
30
|
+
"stream",
|
|
31
|
+
"stream_options",
|
|
32
|
+
"response_format",
|
|
33
|
+
"seed",
|
|
34
|
+
"user",
|
|
35
|
+
"metadata",
|
|
36
|
+
"frequency_penalty",
|
|
37
|
+
"presence_penalty",
|
|
38
|
+
"parallel_tool_calls",
|
|
39
|
+
"logprobs",
|
|
40
|
+
"top_logprobs",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clean_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
45
|
+
return {k: jsonable(v) for k, v in kwargs.items() if k in _KEEP}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _usage_of(usage: Any) -> Optional[Dict[str, Any]]:
|
|
49
|
+
"""Extract a dict from a usage object/dict of any shape."""
|
|
50
|
+
if usage is None:
|
|
51
|
+
return None
|
|
52
|
+
if isinstance(usage, dict):
|
|
53
|
+
return usage
|
|
54
|
+
if hasattr(usage, "model_dump"):
|
|
55
|
+
try:
|
|
56
|
+
return usage.model_dump(exclude_none=True)
|
|
57
|
+
except TypeError:
|
|
58
|
+
return usage.model_dump()
|
|
59
|
+
return {
|
|
60
|
+
k: getattr(usage, k)
|
|
61
|
+
for k in ("prompt_tokens", "completion_tokens", "total_tokens")
|
|
62
|
+
if getattr(usage, k, None) is not None
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _usage(resp: Any) -> Optional[Dict[str, Any]]:
|
|
67
|
+
return _usage_of(getattr(resp, "usage", None))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _response_payload(resp: Any) -> Dict[str, Any]:
|
|
71
|
+
if hasattr(resp, "model_dump"):
|
|
72
|
+
try:
|
|
73
|
+
return resp.model_dump(exclude_none=True)
|
|
74
|
+
except TypeError:
|
|
75
|
+
return resp.model_dump()
|
|
76
|
+
return {"repr": repr(resp)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ms(t0: float) -> float:
|
|
80
|
+
return (time.perf_counter() - t0) * 1000
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _StreamState:
|
|
84
|
+
"""Accumulated view of a streamed completion, built from its chunks."""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self.content: List[str] = []
|
|
88
|
+
self.tool_calls: Dict[int, Dict[str, Any]] = {}
|
|
89
|
+
self.model: Optional[str] = None
|
|
90
|
+
self.usage: Optional[Dict[str, Any]] = None
|
|
91
|
+
self.finish_reason: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
def absorb(self, chunk: Any) -> None:
|
|
94
|
+
model = getattr(chunk, "model", None)
|
|
95
|
+
if model:
|
|
96
|
+
self.model = model
|
|
97
|
+
usage = getattr(chunk, "usage", None)
|
|
98
|
+
if usage is not None:
|
|
99
|
+
self.usage = _usage_of(usage)
|
|
100
|
+
for choice in getattr(chunk, "choices", None) or []:
|
|
101
|
+
delta = getattr(choice, "delta", None)
|
|
102
|
+
if delta is None:
|
|
103
|
+
continue
|
|
104
|
+
content = getattr(delta, "content", None)
|
|
105
|
+
if content:
|
|
106
|
+
self.content.append(content)
|
|
107
|
+
for tc in getattr(delta, "tool_calls", None) or []:
|
|
108
|
+
idx = getattr(tc, "index", 0)
|
|
109
|
+
slot = self.tool_calls.setdefault(
|
|
110
|
+
idx,
|
|
111
|
+
{"id": "", "type": "function",
|
|
112
|
+
"function": {"name": "", "arguments": ""}},
|
|
113
|
+
)
|
|
114
|
+
if getattr(tc, "id", None):
|
|
115
|
+
slot["id"] = tc.id
|
|
116
|
+
fn = getattr(tc, "function", None)
|
|
117
|
+
if fn is not None:
|
|
118
|
+
if getattr(fn, "name", None):
|
|
119
|
+
slot["function"]["name"] = fn.name
|
|
120
|
+
if getattr(fn, "arguments", None):
|
|
121
|
+
slot["function"]["arguments"] += fn.arguments
|
|
122
|
+
finish = getattr(choice, "finish_reason", None)
|
|
123
|
+
if finish:
|
|
124
|
+
self.finish_reason = finish
|
|
125
|
+
|
|
126
|
+
def payload(self) -> Dict[str, Any]:
|
|
127
|
+
message: Dict[str, Any] = {"role": "assistant"}
|
|
128
|
+
if self.content:
|
|
129
|
+
message["content"] = "".join(self.content)
|
|
130
|
+
if self.tool_calls:
|
|
131
|
+
message["tool_calls"] = [self.tool_calls[i] for i in sorted(self.tool_calls)]
|
|
132
|
+
return {
|
|
133
|
+
"object": "chat.completion",
|
|
134
|
+
"model": self.model,
|
|
135
|
+
"reconstructed_from_stream": True,
|
|
136
|
+
"choices": [
|
|
137
|
+
{
|
|
138
|
+
"index": 0,
|
|
139
|
+
"finish_reason": self.finish_reason or "stop",
|
|
140
|
+
"message": message,
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
"usage": self.usage,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def capture_openai(recorder: Any, client: Any) -> Any:
|
|
148
|
+
"""Patch ``client.chat.completions.create`` to record every call."""
|
|
149
|
+
completions = client.chat.completions
|
|
150
|
+
original = completions.create
|
|
151
|
+
# Newer openai SDKs ship async methods that are not coroutine functions
|
|
152
|
+
# (they return awaitables), so the resource type name is the reliable tell.
|
|
153
|
+
is_async = inspect.iscoroutinefunction(original) or type(
|
|
154
|
+
completions
|
|
155
|
+
).__name__.lower().startswith("async")
|
|
156
|
+
if is_async:
|
|
157
|
+
completions.create = _async_wrapper(recorder, original)
|
|
158
|
+
else:
|
|
159
|
+
completions.create = _sync_wrapper(recorder, original)
|
|
160
|
+
return client
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sync_wrapper(recorder: Any, original: Callable) -> Callable:
|
|
164
|
+
@functools.wraps(original)
|
|
165
|
+
def create(*args: Any, **kwargs: Any):
|
|
166
|
+
t0 = time.perf_counter()
|
|
167
|
+
try:
|
|
168
|
+
resp = original(*args, **kwargs)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
recorder.record_llm(
|
|
171
|
+
request=_clean_kwargs(kwargs),
|
|
172
|
+
model=kwargs.get("model"),
|
|
173
|
+
error=exc,
|
|
174
|
+
duration_ms=_ms(t0),
|
|
175
|
+
)
|
|
176
|
+
raise
|
|
177
|
+
if kwargs.get("stream"):
|
|
178
|
+
return _StreamRecorder(recorder, kwargs, resp, t0)
|
|
179
|
+
recorder.record_llm(
|
|
180
|
+
request=_clean_kwargs(kwargs),
|
|
181
|
+
response=_response_payload(resp),
|
|
182
|
+
usage=_usage(resp),
|
|
183
|
+
model=kwargs.get("model") or getattr(resp, "model", None),
|
|
184
|
+
duration_ms=_ms(t0),
|
|
185
|
+
)
|
|
186
|
+
return resp
|
|
187
|
+
|
|
188
|
+
return create
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _async_wrapper(recorder: Any, original: Callable) -> Callable:
|
|
192
|
+
@functools.wraps(original)
|
|
193
|
+
async def create(*args: Any, **kwargs: Any):
|
|
194
|
+
t0 = time.perf_counter()
|
|
195
|
+
try:
|
|
196
|
+
resp = await original(*args, **kwargs)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
recorder.record_llm(
|
|
199
|
+
request=_clean_kwargs(kwargs),
|
|
200
|
+
model=kwargs.get("model"),
|
|
201
|
+
error=exc,
|
|
202
|
+
duration_ms=_ms(t0),
|
|
203
|
+
)
|
|
204
|
+
raise
|
|
205
|
+
if kwargs.get("stream"):
|
|
206
|
+
return _AsyncStreamRecorder(recorder, kwargs, resp, t0)
|
|
207
|
+
recorder.record_llm(
|
|
208
|
+
request=_clean_kwargs(kwargs),
|
|
209
|
+
response=_response_payload(resp),
|
|
210
|
+
usage=_usage(resp),
|
|
211
|
+
model=kwargs.get("model") or getattr(resp, "model", None),
|
|
212
|
+
duration_ms=_ms(t0),
|
|
213
|
+
)
|
|
214
|
+
return resp
|
|
215
|
+
|
|
216
|
+
return create
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _StreamRecorder:
|
|
220
|
+
"""Transparent iterator wrapper: passthrough chunks, record on end."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, recorder: Any, kwargs: Dict[str, Any], stream: Any, t0: float):
|
|
223
|
+
self._recorder = recorder
|
|
224
|
+
self._kwargs = kwargs
|
|
225
|
+
self._stream = stream
|
|
226
|
+
self._t0 = t0
|
|
227
|
+
self._state = _StreamState()
|
|
228
|
+
self._done = False
|
|
229
|
+
|
|
230
|
+
def __iter__(self) -> "_StreamRecorder":
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
def __next__(self) -> Any:
|
|
234
|
+
try:
|
|
235
|
+
chunk = next(self._stream)
|
|
236
|
+
except StopIteration:
|
|
237
|
+
self._finalize()
|
|
238
|
+
raise
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
self._finalize(error=exc)
|
|
241
|
+
raise
|
|
242
|
+
self._state.absorb(chunk)
|
|
243
|
+
return chunk
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
closer = getattr(self._stream, "close", None)
|
|
247
|
+
if closer is not None:
|
|
248
|
+
closer()
|
|
249
|
+
self._finalize()
|
|
250
|
+
|
|
251
|
+
def __enter__(self) -> "_StreamRecorder":
|
|
252
|
+
enter = getattr(self._stream, "__enter__", None)
|
|
253
|
+
if enter is not None:
|
|
254
|
+
enter()
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def __exit__(self, *exc_info: Any):
|
|
258
|
+
self._finalize()
|
|
259
|
+
exit_ = getattr(self._stream, "__exit__", None)
|
|
260
|
+
if exit_ is not None:
|
|
261
|
+
return exit_(*exc_info)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def _finalize(self, error: Optional[BaseException] = None) -> None:
|
|
265
|
+
if self._done:
|
|
266
|
+
return
|
|
267
|
+
self._done = True
|
|
268
|
+
self._recorder.record_llm(
|
|
269
|
+
request=_clean_kwargs(self._kwargs),
|
|
270
|
+
response=self._state.payload(),
|
|
271
|
+
usage=self._state.usage,
|
|
272
|
+
model=self._kwargs.get("model") or self._state.model,
|
|
273
|
+
duration_ms=_ms(self._t0),
|
|
274
|
+
error=error,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _AsyncStreamRecorder(_StreamRecorder):
|
|
279
|
+
def __aiter__(self) -> "_AsyncStreamRecorder":
|
|
280
|
+
return self
|
|
281
|
+
|
|
282
|
+
async def __anext__(self) -> Any:
|
|
283
|
+
try:
|
|
284
|
+
chunk = await self._stream.__anext__()
|
|
285
|
+
except StopAsyncIteration:
|
|
286
|
+
self._finalize()
|
|
287
|
+
raise
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
self._finalize(error=exc)
|
|
290
|
+
raise
|
|
291
|
+
self._state.absorb(chunk)
|
|
292
|
+
return chunk
|
|
293
|
+
|
|
294
|
+
async def aclose(self) -> None:
|
|
295
|
+
closer = getattr(self._stream, "aclose", None)
|
|
296
|
+
if closer is not None:
|
|
297
|
+
await closer()
|
|
298
|
+
self._finalize()
|
backspin/proxy.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""OpenAI-compatible local proxy: record or replay, framework-agnostic.
|
|
2
|
+
|
|
3
|
+
Record mode — point any agent at the proxy instead of the vendor endpoint;
|
|
4
|
+
every request is forwarded upstream and captured into a run file::
|
|
5
|
+
|
|
6
|
+
backspin proxy --upstream https://api.openai.com --port 8840
|
|
7
|
+
# client: base_url = http://127.0.0.1:8840/v1
|
|
8
|
+
|
|
9
|
+
Replay mode — serve a recorded run back as an API, no upstream and no
|
|
10
|
+
network, from any language that speaks the OpenAI protocol::
|
|
11
|
+
|
|
12
|
+
backspin proxy --replay runs/live.backspin.jsonl --port 8840
|
|
13
|
+
|
|
14
|
+
The proxy speaks streaming SSE on both paths: streamed upstream answers are
|
|
15
|
+
reconstructed into a recorded completion, and replays are streamed back as
|
|
16
|
+
chunks. Authorization headers are forwarded to upstream but never recorded.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
from contextlib import suppress
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
26
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
27
|
+
|
|
28
|
+
from .fakes import stream_chunks
|
|
29
|
+
from .integrations.anthropic import _Acc as _AnthropicAcc
|
|
30
|
+
from .recorder import Recorder
|
|
31
|
+
from .replay import Cassette
|
|
32
|
+
from .runfile import fingerprint_request
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _norm_anthropic_usage(payload: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
36
|
+
usage = (payload or {}).get("usage") or {}
|
|
37
|
+
if not usage:
|
|
38
|
+
return None
|
|
39
|
+
return {
|
|
40
|
+
"prompt_tokens": usage.get("input_tokens") or 0,
|
|
41
|
+
"completion_tokens": usage.get("output_tokens") or 0,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ms_since(t0: float) -> float:
|
|
46
|
+
return (time.perf_counter() - t0) * 1000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sse(obj: Any) -> str:
|
|
50
|
+
return "data: " + json.dumps(obj, ensure_ascii=False) + "\n\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _json_body(request: "Request") -> Dict[str, Any]:
|
|
54
|
+
"""Parse the request body as a JSON object; 400 on anything else."""
|
|
55
|
+
try:
|
|
56
|
+
body = await request.json()
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON body: {exc}") from exc
|
|
59
|
+
if not isinstance(body, dict):
|
|
60
|
+
raise HTTPException(status_code=400, detail="request body must be a JSON object")
|
|
61
|
+
return body
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _acc_payload(acc: Dict[str, Any]) -> Dict[str, Any]:
|
|
65
|
+
"""Rebuild a completion-shaped dict from absorbed stream chunks."""
|
|
66
|
+
message: Dict[str, Any] = {"role": "assistant"}
|
|
67
|
+
if acc["content"]:
|
|
68
|
+
message["content"] = "".join(acc["content"])
|
|
69
|
+
if acc["tools"]:
|
|
70
|
+
message["tool_calls"] = [acc["tools"][i] for i in sorted(acc["tools"])]
|
|
71
|
+
return {
|
|
72
|
+
"object": "chat.completion",
|
|
73
|
+
"model": acc["model"],
|
|
74
|
+
"reconstructed_from_stream": True,
|
|
75
|
+
"choices": [{
|
|
76
|
+
"index": 0,
|
|
77
|
+
"finish_reason": acc["finish"] or "stop",
|
|
78
|
+
"message": message,
|
|
79
|
+
}],
|
|
80
|
+
"usage": acc["usage"],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _absorb_chunk(acc: Dict[str, Any], chunk: Dict[str, Any]) -> None:
|
|
85
|
+
"""Accumulate one upstream SSE chunk (dict) into ``acc``."""
|
|
86
|
+
if chunk.get("model"):
|
|
87
|
+
acc["model"] = chunk["model"]
|
|
88
|
+
if chunk.get("usage"):
|
|
89
|
+
acc["usage"] = chunk["usage"]
|
|
90
|
+
for choice in chunk.get("choices") or []:
|
|
91
|
+
delta = choice.get("delta") or {}
|
|
92
|
+
if delta.get("content"):
|
|
93
|
+
acc["content"].append(delta["content"])
|
|
94
|
+
for tc in delta.get("tool_calls") or []:
|
|
95
|
+
idx = tc.get("index", 0)
|
|
96
|
+
slot = acc["tools"].setdefault(
|
|
97
|
+
idx, {"id": "", "type": "function",
|
|
98
|
+
"function": {"name": "", "arguments": ""}}
|
|
99
|
+
)
|
|
100
|
+
if tc.get("id"):
|
|
101
|
+
slot["id"] = tc["id"]
|
|
102
|
+
fn = tc.get("function") or {}
|
|
103
|
+
if fn.get("name"):
|
|
104
|
+
slot["function"]["name"] = fn["name"]
|
|
105
|
+
if fn.get("arguments"):
|
|
106
|
+
slot["function"]["arguments"] += fn["arguments"]
|
|
107
|
+
if choice.get("finish_reason"):
|
|
108
|
+
acc["finish"] = choice["finish_reason"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_proxy_app(
|
|
112
|
+
upstream: Optional[str] = None,
|
|
113
|
+
cassette: Optional[Cassette] = None,
|
|
114
|
+
runs_dir: str = "runs",
|
|
115
|
+
agent: str = "proxy",
|
|
116
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
117
|
+
) -> FastAPI:
|
|
118
|
+
"""Build the proxy app. Set ``upstream`` (record) or ``cassette``
|
|
119
|
+
(replay) — exactly one.
|
|
120
|
+
|
|
121
|
+
The recorder is available as ``app.state.recorder`` after creation.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
import httpx
|
|
125
|
+
except ImportError as exc: # pragma: no cover
|
|
126
|
+
raise ImportError(
|
|
127
|
+
"proxy mode requires httpx: pip install 'backspin[proxy]'"
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
if (upstream is None) == (cassette is None):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"create_proxy_app(): set exactly one of upstream= (record) or cassette= (replay)"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
app = FastAPI(title="backspin proxy", docs_url=None, redoc_url=None)
|
|
136
|
+
if upstream is not None:
|
|
137
|
+
recorder = Recorder(
|
|
138
|
+
dir=runs_dir, agent=agent,
|
|
139
|
+
metadata={**(metadata or {}), "mode": "record", "upstream": upstream},
|
|
140
|
+
)
|
|
141
|
+
client = httpx.AsyncClient(
|
|
142
|
+
base_url=upstream,
|
|
143
|
+
timeout=httpx.Timeout(connect=15.0, read=600.0, write=600.0, pool=15.0),
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
recorder = Recorder(
|
|
147
|
+
dir=runs_dir, agent=agent,
|
|
148
|
+
metadata={**(metadata or {}), "mode": "replay"},
|
|
149
|
+
)
|
|
150
|
+
client = None
|
|
151
|
+
app.state.recorder = recorder
|
|
152
|
+
|
|
153
|
+
@app.get("/v1/models")
|
|
154
|
+
async def models():
|
|
155
|
+
if client is None:
|
|
156
|
+
return JSONResponse({"object": "list", "data": []})
|
|
157
|
+
resp = await client.get("/v1/models")
|
|
158
|
+
return JSONResponse(resp.json(), status_code=resp.status_code)
|
|
159
|
+
|
|
160
|
+
@app.post("/v1/chat/completions")
|
|
161
|
+
async def chat(request: Request):
|
|
162
|
+
body = await _json_body(request)
|
|
163
|
+
stream = body.get("stream", False)
|
|
164
|
+
t0 = time.perf_counter()
|
|
165
|
+
|
|
166
|
+
# ---- replay mode: answer from the cassette, no upstream ----------
|
|
167
|
+
if client is None:
|
|
168
|
+
assert cassette is not None # exactly one of upstream/cassette is set
|
|
169
|
+
fp = fingerprint_request(body.get("model"), body.get("messages"))
|
|
170
|
+
entry, _exact = cassette.match(fp)
|
|
171
|
+
if entry is None:
|
|
172
|
+
raise HTTPException(
|
|
173
|
+
status_code=503,
|
|
174
|
+
detail="backspin replay proxy: cassette exhausted",
|
|
175
|
+
)
|
|
176
|
+
data = dict(entry["response"])
|
|
177
|
+
if stream:
|
|
178
|
+
async def replay_gen():
|
|
179
|
+
for chunk in stream_chunks(data):
|
|
180
|
+
yield _sse(chunk.model_dump())
|
|
181
|
+
yield "data: [DONE]\n\n"
|
|
182
|
+
|
|
183
|
+
return StreamingResponse(replay_gen(), media_type="text/event-stream")
|
|
184
|
+
return JSONResponse(data)
|
|
185
|
+
|
|
186
|
+
# ---- record mode: forward upstream, capture the exchange ---------
|
|
187
|
+
fwd_headers = {
|
|
188
|
+
k: v for k, v in request.headers.items()
|
|
189
|
+
if k.lower() in ("authorization", "content-type", "accept")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if not stream:
|
|
193
|
+
resp = await client.post("/v1/chat/completions", json=body, headers=fwd_headers)
|
|
194
|
+
try:
|
|
195
|
+
payload = resp.json()
|
|
196
|
+
except Exception:
|
|
197
|
+
payload = None
|
|
198
|
+
if resp.status_code >= 400:
|
|
199
|
+
recorder.record_llm(
|
|
200
|
+
request=body, model=body.get("model"), duration_ms=_ms_since(t0),
|
|
201
|
+
error=RuntimeError(f"upstream HTTP {resp.status_code}: {resp.text[:300]}"),
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
recorder.record_llm(
|
|
205
|
+
request=body, response=payload,
|
|
206
|
+
usage=payload.get("usage") if payload else None,
|
|
207
|
+
model=(payload or {}).get("model") or body.get("model"),
|
|
208
|
+
duration_ms=_ms_since(t0),
|
|
209
|
+
)
|
|
210
|
+
return JSONResponse(
|
|
211
|
+
payload if payload is not None else {}, status_code=resp.status_code
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
upstream_resp = await client.send(
|
|
215
|
+
client.build_request("POST", "/v1/chat/completions", json=body, headers=fwd_headers),
|
|
216
|
+
stream=True,
|
|
217
|
+
)
|
|
218
|
+
if upstream_resp.status_code >= 400:
|
|
219
|
+
raw = (await upstream_resp.aread()).decode("utf-8", "replace")
|
|
220
|
+
await upstream_resp.aclose()
|
|
221
|
+
recorder.record_llm(
|
|
222
|
+
request=body, model=body.get("model"), duration_ms=_ms_since(t0),
|
|
223
|
+
error=RuntimeError(f"upstream HTTP {upstream_resp.status_code}: {raw[:300]}"),
|
|
224
|
+
)
|
|
225
|
+
return JSONResponse(
|
|
226
|
+
{"error": {"message": raw[:500]}}, status_code=upstream_resp.status_code
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
acc: Dict[str, Any] = {
|
|
230
|
+
"content": [], "tools": {}, "usage": None,
|
|
231
|
+
"finish": None, "model": body.get("model"),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async def passthrough_gen():
|
|
235
|
+
try:
|
|
236
|
+
async for line in upstream_resp.aiter_lines():
|
|
237
|
+
yield line + "\n"
|
|
238
|
+
stripped = line.strip()
|
|
239
|
+
if stripped.startswith("data:"):
|
|
240
|
+
data = stripped[5:].strip()
|
|
241
|
+
if data and data != "[DONE]":
|
|
242
|
+
with suppress(json.JSONDecodeError):
|
|
243
|
+
_absorb_chunk(acc, json.loads(data))
|
|
244
|
+
finally:
|
|
245
|
+
await upstream_resp.aclose()
|
|
246
|
+
recorder.record_llm(
|
|
247
|
+
request=body, response=_acc_payload(acc), usage=acc["usage"],
|
|
248
|
+
model=acc["model"], duration_ms=_ms_since(t0),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return StreamingResponse(passthrough_gen(), media_type="text/event-stream")
|
|
252
|
+
|
|
253
|
+
@app.post("/v1/messages")
|
|
254
|
+
async def anthropic_messages(request: Request):
|
|
255
|
+
"""Anthropic Messages protocol: same record/replay behavior."""
|
|
256
|
+
body = await _json_body(request)
|
|
257
|
+
stream = body.get("stream", False)
|
|
258
|
+
t0 = time.perf_counter()
|
|
259
|
+
|
|
260
|
+
# ---- replay -------------------------------------------------------
|
|
261
|
+
if client is None:
|
|
262
|
+
assert cassette is not None # exactly one of upstream/cassette is set
|
|
263
|
+
fp = fingerprint_request(body.get("model"), body.get("messages"))
|
|
264
|
+
entry, _exact = cassette.match(fp)
|
|
265
|
+
if entry is None:
|
|
266
|
+
raise HTTPException(
|
|
267
|
+
status_code=503,
|
|
268
|
+
detail="backspin replay proxy: cassette exhausted",
|
|
269
|
+
)
|
|
270
|
+
data = dict(entry["response"])
|
|
271
|
+
if stream:
|
|
272
|
+
from .fakes import anthropic_stream_events
|
|
273
|
+
|
|
274
|
+
async def anthropic_replay_gen():
|
|
275
|
+
for event in anthropic_stream_events(data):
|
|
276
|
+
yield "event: " + event.get("type", "message") + "\n" + _sse(event)
|
|
277
|
+
yield "event: message_stop\n" + _sse({"type": "message_stop"})
|
|
278
|
+
|
|
279
|
+
return StreamingResponse(anthropic_replay_gen(), media_type="text/event-stream")
|
|
280
|
+
return JSONResponse(data)
|
|
281
|
+
|
|
282
|
+
# ---- record -------------------------------------------------------
|
|
283
|
+
fwd_headers = {
|
|
284
|
+
k: v for k, v in request.headers.items()
|
|
285
|
+
if k.lower() in ("x-api-key", "anthropic-version", "content-type", "accept")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if not stream:
|
|
289
|
+
resp = await client.post("/v1/messages", json=body, headers=fwd_headers)
|
|
290
|
+
try:
|
|
291
|
+
payload = resp.json()
|
|
292
|
+
except Exception:
|
|
293
|
+
payload = None
|
|
294
|
+
if resp.status_code >= 400:
|
|
295
|
+
recorder.record_llm(
|
|
296
|
+
request=body, model=body.get("model"), duration_ms=_ms_since(t0),
|
|
297
|
+
error=RuntimeError(f"upstream HTTP {resp.status_code}: {resp.text[:300]}"),
|
|
298
|
+
provider="anthropic",
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
recorder.record_llm(
|
|
302
|
+
request=body, response=payload,
|
|
303
|
+
usage=_norm_anthropic_usage(payload),
|
|
304
|
+
model=(payload or {}).get("model") or body.get("model"),
|
|
305
|
+
duration_ms=_ms_since(t0), provider="anthropic",
|
|
306
|
+
)
|
|
307
|
+
return JSONResponse(
|
|
308
|
+
payload if payload is not None else {}, status_code=resp.status_code
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
upstream_resp = await client.send(
|
|
312
|
+
client.build_request("POST", "/v1/messages", json=body, headers=fwd_headers),
|
|
313
|
+
stream=True,
|
|
314
|
+
)
|
|
315
|
+
if upstream_resp.status_code >= 400:
|
|
316
|
+
raw = (await upstream_resp.aread()).decode("utf-8", "replace")
|
|
317
|
+
await upstream_resp.aclose()
|
|
318
|
+
recorder.record_llm(
|
|
319
|
+
request=body, model=body.get("model"), duration_ms=_ms_since(t0),
|
|
320
|
+
error=RuntimeError(f"upstream HTTP {upstream_resp.status_code}: {raw[:300]}"),
|
|
321
|
+
provider="anthropic",
|
|
322
|
+
)
|
|
323
|
+
return JSONResponse(
|
|
324
|
+
{"error": {"message": raw[:500]}}, status_code=upstream_resp.status_code
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
acc = _AnthropicAcc(body.get("model"))
|
|
328
|
+
|
|
329
|
+
async def anthropic_passthrough_gen():
|
|
330
|
+
try:
|
|
331
|
+
async for line in upstream_resp.aiter_lines():
|
|
332
|
+
yield line + "\n"
|
|
333
|
+
stripped = line.strip()
|
|
334
|
+
if stripped.startswith("data:"):
|
|
335
|
+
data = stripped[5:].strip()
|
|
336
|
+
if data:
|
|
337
|
+
with suppress(json.JSONDecodeError):
|
|
338
|
+
acc.absorb(json.loads(data))
|
|
339
|
+
finally:
|
|
340
|
+
await upstream_resp.aclose()
|
|
341
|
+
usage = None
|
|
342
|
+
if acc.input_tokens is not None or acc.output_tokens is not None:
|
|
343
|
+
usage = {"prompt_tokens": acc.input_tokens or 0,
|
|
344
|
+
"completion_tokens": acc.output_tokens or 0}
|
|
345
|
+
recorder.record_llm(
|
|
346
|
+
request=body, response=acc.payload(), usage=usage,
|
|
347
|
+
model=acc.model, duration_ms=_ms_since(t0),
|
|
348
|
+
meta={"provider": "anthropic"},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return StreamingResponse(anthropic_passthrough_gen(), media_type="text/event-stream")
|
|
352
|
+
|
|
353
|
+
return app
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def serve_proxy(
|
|
357
|
+
upstream: Optional[str] = None,
|
|
358
|
+
cassette: Optional[Cassette] = None,
|
|
359
|
+
runs_dir: str = "runs",
|
|
360
|
+
host: str = "127.0.0.1",
|
|
361
|
+
port: int = 8840,
|
|
362
|
+
) -> None:
|
|
363
|
+
import uvicorn
|
|
364
|
+
|
|
365
|
+
app = create_proxy_app(upstream=upstream, cassette=cassette, runs_dir=runs_dir)
|
|
366
|
+
print(f"recording to: {app.state.recorder.path}")
|
|
367
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
backspin/py.typed
ADDED
|
File without changes
|