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.
@@ -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