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/fakes.py ADDED
@@ -0,0 +1,151 @@
1
+ """Duck-typed fakes for the slice of the OpenAI SDK that backspin records.
2
+
3
+ Used by :mod:`backspin.testing` (scripted clients for demos and your own
4
+ tests) and by :mod:`backspin.replay` (replaying recordings without the SDK).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from types import SimpleNamespace
9
+ from typing import Any, Dict, Iterator, List, Optional
10
+
11
+
12
+ def _ns(obj: Any) -> Any:
13
+ if isinstance(obj, dict):
14
+ return SimpleNamespace(**{k: _ns(v) for k, v in obj.items()})
15
+ if isinstance(obj, list):
16
+ return [_ns(x) for x in obj]
17
+ return obj
18
+
19
+
20
+ def message_data(content: Optional[str], *, role: str = "assistant",
21
+ tool_calls: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
22
+ """Build a completion-shaped dict from just an assistant message."""
23
+ msg: Dict[str, Any] = {"role": role, "content": content}
24
+ if tool_calls:
25
+ msg["tool_calls"] = tool_calls
26
+ return {
27
+ "object": "chat.completion",
28
+ "model": "fake-model",
29
+ "choices": [{"index": 0, "message": msg, "finish_reason": "stop"}],
30
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
31
+ }
32
+
33
+
34
+ class FakeResponse:
35
+ """Stands in for ``openai``'s ChatCompletion."""
36
+
37
+ def __init__(self, data: Dict[str, Any]):
38
+ self._data = data
39
+ self.id = data.get("id", "chatcmpl-fake")
40
+ self.object = data.get("object", "chat.completion")
41
+ self.model = data.get("model", "fake-model")
42
+ self.choices = _ns(data.get("choices", []))
43
+ usage = data.get("usage")
44
+ self.usage = _ns(usage) if usage else None
45
+
46
+ def model_dump(self, **_kw: Any) -> Dict[str, Any]:
47
+ return self._data
48
+
49
+
50
+ class FakeChunk:
51
+ """Stands in for one ``openai`` ChatCompletionChunk in streaming mode."""
52
+
53
+ def __init__(self, *, model: str, delta: Optional[Dict[str, Any]] = None,
54
+ finish_reason: Optional[str] = None, usage: Optional[Dict] = None):
55
+ self.model = model
56
+ self.object = "chat.completion.chunk"
57
+ delta = dict(delta or {})
58
+ delta.setdefault("content", None)
59
+ self.choices = [
60
+ SimpleNamespace(index=0, delta=_ns(delta), finish_reason=finish_reason)
61
+ ]
62
+ self.usage = usage
63
+
64
+ def model_dump(self, **_kw: Any) -> Dict[str, Any]:
65
+ return {
66
+ "model": self.model,
67
+ "object": self.object,
68
+ "choices": [
69
+ {
70
+ "index": 0,
71
+ "delta": self.choices[0].delta.__dict__,
72
+ "finish_reason": self.choices[0].finish_reason,
73
+ }
74
+ ],
75
+ "usage": self.usage,
76
+ }
77
+
78
+
79
+ def stream_chunks(data: Dict[str, Any], pieces: int = 3) -> Iterator[FakeChunk]:
80
+ """Turn a completion-shaped dict into an iterable of chunk-like objects."""
81
+ model = data.get("model", "fake-model")
82
+ choice = (data.get("choices") or [{}])[0]
83
+ msg = choice.get("message", {})
84
+ content = msg.get("content") or ""
85
+ tool_calls = msg.get("tool_calls")
86
+ if tool_calls:
87
+ yield FakeChunk(model=model, delta={"role": "assistant", "tool_calls": tool_calls})
88
+ if content:
89
+ step = max(1, len(content) // pieces)
90
+ for i in range(0, len(content), step):
91
+ yield FakeChunk(model=model, delta={"content": content[i : i + step]})
92
+ yield FakeChunk(model=model, finish_reason="stop", usage=data.get("usage"))
93
+
94
+
95
+ # ---- Anthropic-shaped fakes -------------------------------------------------
96
+
97
+
98
+ class FakeAnthropicMessage:
99
+ """Stands in for anthropic's Message object."""
100
+
101
+ def __init__(self, data: Dict[str, Any]):
102
+ self._data = data
103
+ self.id = data.get("id", "msg_fake")
104
+ self.type = data.get("type", "message")
105
+ self.role = data.get("role", "assistant")
106
+ self.model = data.get("model", "fake-claude")
107
+ self.content = _ns(data.get("content", []))
108
+ self.stop_reason = data.get("stop_reason")
109
+ usage = data.get("usage")
110
+ self.usage = _ns(usage) if usage else None
111
+
112
+ def model_dump(self, **_kw: Any) -> Dict[str, Any]:
113
+ return self._data
114
+
115
+
116
+ class FakeAnthropicEvent:
117
+ """Stands in for one anthropic stream event."""
118
+
119
+ def __init__(self, data: Dict[str, Any]):
120
+ self._data = data
121
+
122
+ def model_dump(self, **_kw: Any) -> Dict[str, Any]:
123
+ return self._data
124
+
125
+
126
+ def anthropic_stream_events(data: Dict[str, Any]) -> List[Dict[str, Any]]:
127
+ """Synthesize an anthropic SSE event sequence for a message payload."""
128
+ model = data.get("model", "fake-claude")
129
+ usage = data.get("usage") or {}
130
+ events: List[Dict[str, Any]] = [
131
+ {"type": "message_start", "message": {
132
+ "id": data.get("id", "msg_fake"), "type": "message",
133
+ "role": "assistant", "model": model,
134
+ "usage": {"input_tokens": usage.get("input_tokens", 8)},
135
+ }}
136
+ ]
137
+ for index, block in enumerate(data.get("content") or []):
138
+ if block.get("type") == "text":
139
+ events.append({"type": "content_block_start", "index": index,
140
+ "content_block": {"type": "text", "text": ""}})
141
+ events.append({"type": "content_block_delta", "index": index,
142
+ "delta": {"type": "text_delta", "text": block.get("text", "")}})
143
+ else:
144
+ events.append({"type": "content_block_start", "index": index,
145
+ "content_block": dict(block)})
146
+ events.append({"type": "content_block_stop", "index": index})
147
+ events.append({"type": "message_delta",
148
+ "delta": {"stop_reason": data.get("stop_reason", "end_turn")},
149
+ "usage": {"output_tokens": usage.get("output_tokens", 4)}})
150
+ events.append({"type": "message_stop"})
151
+ return events
File without changes
@@ -0,0 +1,279 @@
1
+ """Capture Anthropic-SDK-shaped messages.create calls into a Recorder.
2
+
3
+ Duck-typed like the OpenAI integration: works with the real
4
+ ``anthropic.Anthropic`` / ``anthropic.AsyncAnthropic`` clients and anything
5
+ shaped the same (see :mod:`backspin.testing`).
6
+
7
+ Anthropic events are recorded as ``kind="llm"`` with ``provider="anthropic"``
8
+ and the native request/response payloads preserved; ``usage`` is normalized
9
+ to ``prompt_tokens``/``completion_tokens`` so cost estimation and diffs work
10
+ across providers.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import inspect
16
+ import time
17
+ from typing import Any, Callable, Dict, Optional
18
+
19
+ from ..runfile import jsonable
20
+
21
+ _KEEP = {
22
+ "model", "messages", "system", "max_tokens", "tools", "tool_choice",
23
+ "temperature", "top_p", "top_k", "stream", "metadata", "stop_sequences",
24
+ }
25
+
26
+
27
+ def _clean_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
28
+ return {k: jsonable(v) for k, v in kwargs.items() if k in _KEEP}
29
+
30
+
31
+ def _ms(t0: float) -> float:
32
+ return (time.perf_counter() - t0) * 1000
33
+
34
+
35
+ def _usage_of(usage: Any) -> Optional[Dict[str, Any]]:
36
+ """Normalize anthropic usage {input_tokens, output_tokens} for cost."""
37
+ if usage is None:
38
+ return None
39
+ get = usage.get if isinstance(usage, dict) else lambda k: getattr(usage, k, None)
40
+ prompt = get("input_tokens")
41
+ completion = get("output_tokens")
42
+ if prompt is None and completion is None:
43
+ return None
44
+ return {
45
+ "prompt_tokens": prompt or 0,
46
+ "completion_tokens": completion or 0,
47
+ "input_tokens": prompt or 0,
48
+ "output_tokens": completion or 0,
49
+ }
50
+
51
+
52
+ def _response_payload(resp: Any) -> Dict[str, Any]:
53
+ if hasattr(resp, "model_dump"):
54
+ try:
55
+ return resp.model_dump(exclude_none=True)
56
+ except TypeError:
57
+ return resp.model_dump()
58
+ return {"repr": repr(resp)}
59
+
60
+
61
+ class _Acc:
62
+ """Accumulated view of a streamed anthropic message."""
63
+
64
+ def __init__(self, fallback_model: Optional[str]) -> None:
65
+ self.model = fallback_model
66
+ self.blocks: Dict[int, Dict[str, Any]] = {}
67
+ self.input_tokens: Optional[int] = None
68
+ self.output_tokens: Optional[int] = None
69
+ self.stop_reason: Optional[str] = None
70
+
71
+ def absorb(self, event: Any) -> None:
72
+ data = event.model_dump() if hasattr(event, "model_dump") else event
73
+ if not isinstance(data, dict):
74
+ return
75
+ etype = data.get("type")
76
+ if etype == "message_start":
77
+ message = data.get("message") or {}
78
+ if message.get("model"):
79
+ self.model = message["model"]
80
+ usage = _usage_of(message.get("usage"))
81
+ if usage:
82
+ self.input_tokens = usage["prompt_tokens"]
83
+ elif etype == "content_block_start":
84
+ block = data.get("content_block") or {}
85
+ self.blocks[data.get("index", 0)] = dict(block)
86
+ elif etype == "content_block_delta":
87
+ block = self.blocks.setdefault(data.get("index", 0), {"type": "text", "text": ""})
88
+ delta = data.get("delta") or {}
89
+ if delta.get("type") == "text_delta":
90
+ block["type"] = "text"
91
+ block["text"] = block.get("text", "") + (delta.get("text") or "")
92
+ elif delta.get("type") == "input_json_delta":
93
+ block["type"] = "tool_use"
94
+ prior = block.get("partial_json", "")
95
+ block["partial_json"] = prior + (delta.get("partial_json") or "")
96
+ elif etype == "message_delta":
97
+ stop = (data.get("delta") or {}).get("stop_reason")
98
+ if stop:
99
+ self.stop_reason = stop
100
+ usage = _usage_of(data.get("usage"))
101
+ if usage:
102
+ self.output_tokens = usage["completion_tokens"]
103
+
104
+ def payload(self) -> Dict[str, Any]:
105
+ content = []
106
+ for idx in sorted(self.blocks):
107
+ block = dict(self.blocks[idx])
108
+ if block.get("type") == "tool_use" and block.get("partial_json"):
109
+ import json as _json
110
+
111
+ try:
112
+ block["input"] = _json.loads(block.pop("partial_json"))
113
+ except ValueError:
114
+ block["input"] = block.pop("partial_json")
115
+ content.append(block)
116
+ usage: Dict[str, Any] = {}
117
+ if self.input_tokens is not None:
118
+ usage["input_tokens"] = self.input_tokens
119
+ if self.output_tokens is not None:
120
+ usage["output_tokens"] = self.output_tokens
121
+ return {
122
+ "object": "anthropic.message",
123
+ "reconstructed_from_stream": True,
124
+ "model": self.model,
125
+ "role": "assistant",
126
+ "content": content,
127
+ "stop_reason": self.stop_reason,
128
+ "usage": usage or None,
129
+ }
130
+
131
+
132
+ def capture_anthropic(recorder: Any, client: Any) -> Any:
133
+ """Patch ``client.messages.create`` so every call is recorded."""
134
+ messages = client.messages
135
+ original = messages.create
136
+ is_async = inspect.iscoroutinefunction(original) or type(
137
+ messages
138
+ ).__name__.lower().startswith("async")
139
+ if is_async:
140
+ messages.create = _async_wrapper(recorder, original)
141
+ else:
142
+ messages.create = _sync_wrapper(recorder, original)
143
+ return client
144
+
145
+
146
+ def _record(recorder: Any, kwargs: Dict[str, Any], *, response: Any = None,
147
+ usage: Any = None, error: Any = None, duration_ms: float,
148
+ model: Any = None) -> None:
149
+ recorder.record_llm(
150
+ request=_clean_kwargs(kwargs),
151
+ response=response,
152
+ usage=usage,
153
+ error=error,
154
+ duration_ms=duration_ms,
155
+ model=model or kwargs.get("model"),
156
+ provider="anthropic",
157
+ )
158
+
159
+
160
+ def _sync_wrapper(recorder: Any, original: Callable) -> Callable:
161
+ @functools.wraps(original)
162
+ def create(*args: Any, **kwargs: Any):
163
+ t0 = time.perf_counter()
164
+ try:
165
+ resp = original(*args, **kwargs)
166
+ except Exception as exc:
167
+ _record(recorder, kwargs, error=exc, duration_ms=_ms(t0))
168
+ raise
169
+ if kwargs.get("stream"):
170
+ return _StreamRecorder(recorder, kwargs, resp, t0)
171
+ usage = _usage_of(getattr(resp, "usage", None))
172
+ _record(recorder, kwargs, response=_response_payload(resp), usage=usage,
173
+ duration_ms=_ms(t0), model=getattr(resp, "model", None))
174
+ return resp
175
+
176
+ return create
177
+
178
+
179
+ def _async_wrapper(recorder: Any, original: Callable) -> Callable:
180
+ @functools.wraps(original)
181
+ async def create(*args: Any, **kwargs: Any):
182
+ t0 = time.perf_counter()
183
+ try:
184
+ resp = await original(*args, **kwargs)
185
+ except Exception as exc:
186
+ _record(recorder, kwargs, error=exc, duration_ms=_ms(t0))
187
+ raise
188
+ if kwargs.get("stream"):
189
+ return _AsyncStreamRecorder(recorder, kwargs, resp, t0)
190
+ usage = _usage_of(getattr(resp, "usage", None))
191
+ _record(recorder, kwargs, response=_response_payload(resp), usage=usage,
192
+ duration_ms=_ms(t0), model=getattr(resp, "model", None))
193
+ return resp
194
+
195
+ return create
196
+
197
+
198
+ class _StreamRecorder:
199
+ """Transparent iterator over anthropic stream events, recorded at end."""
200
+
201
+ def __init__(self, recorder: Any, kwargs: Dict[str, Any], stream: Any, t0: float):
202
+ self._recorder = recorder
203
+ self._kwargs = kwargs
204
+ self._stream = stream
205
+ self._t0 = t0
206
+ self._acc = _Acc(kwargs.get("model"))
207
+ self._done = False
208
+
209
+ def __iter__(self) -> "_StreamRecorder":
210
+ return self
211
+
212
+ def __next__(self) -> Any:
213
+ try:
214
+ event = next(self._stream)
215
+ except StopIteration:
216
+ self._finalize()
217
+ raise
218
+ except Exception as exc:
219
+ self._finalize(error=exc)
220
+ raise
221
+ self._acc.absorb(event)
222
+ return event
223
+
224
+ def close(self) -> None:
225
+ closer = getattr(self._stream, "close", None)
226
+ if closer is not None:
227
+ closer()
228
+ self._finalize()
229
+
230
+ def __enter__(self) -> "_StreamRecorder":
231
+ enter = getattr(self._stream, "__enter__", None)
232
+ if enter is not None:
233
+ enter()
234
+ return self
235
+
236
+ def __exit__(self, *exc_info: Any):
237
+ self._finalize()
238
+ exit_ = getattr(self._stream, "__exit__", None)
239
+ if exit_ is not None:
240
+ return exit_(*exc_info)
241
+ return None
242
+
243
+ def _finalize(self, error: Optional[BaseException] = None) -> None:
244
+ if self._done:
245
+ return
246
+ self._done = True
247
+ acc = self._acc
248
+ usage = None
249
+ if acc.input_tokens is not None or acc.output_tokens is not None:
250
+ usage = {"prompt_tokens": acc.input_tokens or 0,
251
+ "completion_tokens": acc.output_tokens or 0}
252
+ _record(
253
+ self._recorder, self._kwargs,
254
+ response=acc.payload(), usage=usage, error=error,
255
+ duration_ms=_ms(self._t0), model=acc.model,
256
+ )
257
+
258
+
259
+ class _AsyncStreamRecorder(_StreamRecorder):
260
+ def __aiter__(self) -> "_AsyncStreamRecorder":
261
+ return self
262
+
263
+ async def __anext__(self) -> Any:
264
+ try:
265
+ event = await self._stream.__anext__()
266
+ except StopAsyncIteration:
267
+ self._finalize()
268
+ raise
269
+ except Exception as exc:
270
+ self._finalize(error=exc)
271
+ raise
272
+ self._acc.absorb(event)
273
+ return event
274
+
275
+ async def aclose(self) -> None:
276
+ closer = getattr(self._stream, "aclose", None)
277
+ if closer is not None:
278
+ await closer()
279
+ self._finalize()