overturn 0.1.0__tar.gz

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,17 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ coverage/
6
+ *.log
7
+ .env
8
+ .env.*
9
+ !.env.example
10
+ .venv/
11
+ __pycache__/
12
+ *.pyc
13
+ *.egg-info/
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .DS_Store
17
+ overturn-report.*
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: overturn
3
+ Version: 0.1.0
4
+ Summary: Overturn SDK: record agent runs, turn human corrections into regression cases, replay safely
5
+ Project-URL: Homepage, https://github.com/overrule-ai/overturn
6
+ Project-URL: Repository, https://github.com/overrule-ai/overturn
7
+ Project-URL: Documentation, https://github.com/overrule-ai/overturn/blob/main/docs/sdk.md
8
+ Author: Overturn contributors
9
+ License-Expression: Apache-2.0
10
+ Keywords: agents,evals,llm,regression-testing,replay
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: pydantic>=2.5
17
+ Requires-Dist: uuid-utils>=0.9
18
+ Provides-Extra: anthropic
19
+ Requires-Dist: anthropic>=0.30; extra == 'anthropic'
20
+ Provides-Extra: openai
21
+ Requires-Dist: openai>=1.40; extra == 'openai'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # overturn
25
+
26
+ Python SDK for Overturn. See the repository README.
@@ -0,0 +1,3 @@
1
+ # overturn
2
+
3
+ Python SDK for Overturn. See the repository README.
@@ -0,0 +1,67 @@
1
+ """Overturn Python SDK.
2
+
3
+ from overturn import Overturn, tool
4
+
5
+ ov = Overturn(api_key=os.environ["OVERTURN_API_KEY"])
6
+ client = ov.wrap_openai(OpenAI())
7
+
8
+ @tool(side_effect=False, match_on=["email"])
9
+ def lookup_customer(email: str) -> dict: ...
10
+
11
+ with ov.run(agent="ticket-triage", version="1.4.0", input=ticket) as run:
12
+ ...
13
+ run.end(output=decision)
14
+ """
15
+
16
+ from .api import OverturnApi, OverturnApiError
17
+ from .client import Overturn
18
+ from .context import current_run
19
+ from .fingerprint import FINGERPRINT_VERSION, canonical_json, fingerprint, hash_json, tool_fingerprint
20
+ from .pricing import estimate_cost_usd
21
+ from .replay import (
22
+ MaxStepsExceededError,
23
+ ReplayModelCall,
24
+ ReplayModelOutput,
25
+ ReplayState,
26
+ SideEffectRefusedError,
27
+ ToolRecording,
28
+ ToolRecordings,
29
+ UnreplayableError,
30
+ )
31
+ from .run import Run
32
+ from .tools import Tool, tool
33
+ from .transport import Transport, TransportError
34
+ from .types import IngestPayload, RunUpsert, StepUpsert, Usage
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "FINGERPRINT_VERSION",
40
+ "IngestPayload",
41
+ "MaxStepsExceededError",
42
+ "ReplayModelCall",
43
+ "ReplayModelOutput",
44
+ "Overturn",
45
+ "OverturnApi",
46
+ "OverturnApiError",
47
+ "ReplayState",
48
+ "Run",
49
+ "RunUpsert",
50
+ "SideEffectRefusedError",
51
+ "StepUpsert",
52
+ "Tool",
53
+ "ToolRecording",
54
+ "ToolRecordings",
55
+ "Transport",
56
+ "TransportError",
57
+ "UnreplayableError",
58
+ "Usage",
59
+ "__version__",
60
+ "canonical_json",
61
+ "current_run",
62
+ "estimate_cost_usd",
63
+ "fingerprint",
64
+ "hash_json",
65
+ "tool",
66
+ "tool_fingerprint",
67
+ ]
@@ -0,0 +1,35 @@
1
+ """Small typed accessors for loosely-typed JSON-like data (SDK response dumps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+
8
+ def as_dict(v: Any) -> dict[str, Any]:
9
+ return cast(dict[str, Any], v) if isinstance(v, dict) else {}
10
+
11
+
12
+ def as_list(v: Any) -> list[Any]:
13
+ return cast(list[Any], v) if isinstance(v, list) else []
14
+
15
+
16
+ def get_dict(d: dict[str, Any], key: str) -> dict[str, Any]:
17
+ return as_dict(d.get(key))
18
+
19
+
20
+ def get_list(d: dict[str, Any], key: str) -> list[Any]:
21
+ return as_list(d.get(key))
22
+
23
+
24
+ def get_str(d: dict[str, Any], key: str) -> str | None:
25
+ v = d.get(key)
26
+ return v if isinstance(v, str) else None
27
+
28
+
29
+ def get_int(d: dict[str, Any], key: str) -> int | None:
30
+ v = d.get(key)
31
+ return v if isinstance(v, int) and not isinstance(v, bool) else None
32
+
33
+
34
+ def is_jsonable(v: Any) -> bool:
35
+ return isinstance(v, (str, int, float, bool, list, dict))
@@ -0,0 +1,229 @@
1
+ """wrap_anthropic: record messages.create (sync/async, streaming or not) as llm_call steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from datetime import datetime, timezone
8
+ from typing import Any, TypeVar, cast
9
+
10
+ from . import context
11
+ from ._json import get_dict, get_int, get_str, is_jsonable
12
+ from .pricing import estimate_cost_usd
13
+ from .types import Usage
14
+
15
+ T = TypeVar("T")
16
+
17
+ _INPUT_KEYS = {"messages", "system", "tools", "tool_choice"}
18
+ _SKIP_KEYS = {"model", "stream", *_INPUT_KEYS}
19
+
20
+
21
+ def split_message_params(params: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], str | None]:
22
+ inp = {k: v for k, v in params.items() if k in _INPUT_KEYS}
23
+ rest: dict[str, Any] = {k: v for k, v in params.items() if k not in _SKIP_KEYS and is_jsonable(v)}
24
+ model = params.get("model")
25
+ return inp, rest, model if isinstance(model, str) else None
26
+
27
+
28
+ def _dump(obj: Any) -> dict[str, Any]:
29
+ dump = getattr(obj, "model_dump", None)
30
+ if callable(dump):
31
+ return cast(dict[str, Any], dump(mode="json", exclude_none=True))
32
+ if isinstance(obj, dict):
33
+ return cast(dict[str, Any], obj)
34
+ return {}
35
+
36
+
37
+ def _usage_from(d: dict[str, Any]) -> Usage | None:
38
+ if not isinstance(d.get("usage"), dict):
39
+ return None
40
+ ud = get_dict(d, "usage")
41
+ inp = get_int(ud, "input_tokens")
42
+ out = get_int(ud, "output_tokens")
43
+ return Usage(
44
+ input_tokens=inp,
45
+ output_tokens=out,
46
+ total_tokens=(inp or 0) + (out or 0) if inp is not None or out is not None else None,
47
+ cached_input_tokens=get_int(ud, "cache_read_input_tokens"),
48
+ )
49
+
50
+
51
+ def _record(
52
+ run: Any,
53
+ model: str | None,
54
+ inp: Any,
55
+ params: Any,
56
+ message: Any,
57
+ started: datetime,
58
+ first: float | None,
59
+ error: str | None = None,
60
+ ) -> None:
61
+ if run is None:
62
+ return
63
+ d = _dump(message) if message is not None else {}
64
+ usage = _usage_from(d) if message is not None else None
65
+ if usage is not None and first is not None:
66
+ usage.first_token_ms = first
67
+ resolved = get_str(d, "model") or model
68
+ output: dict[str, Any] | None = None
69
+ if message is not None:
70
+ output = {
71
+ "message": {"role": get_str(d, "role") or "assistant", "content": d.get("content")},
72
+ "finish_reason": get_str(d, "stop_reason"),
73
+ }
74
+ run.step(
75
+ kind="llm_call",
76
+ name="messages",
77
+ provider="anthropic",
78
+ model=resolved,
79
+ input=inp,
80
+ params=params,
81
+ output=output,
82
+ usage=usage,
83
+ cost_usd=estimate_cost_usd(resolved, usage),
84
+ latency_ms=(datetime.now(timezone.utc) - started).total_seconds() * 1000,
85
+ error=error,
86
+ started_at=started,
87
+ )
88
+
89
+
90
+ def assemble_message_stream(events: list[Any]) -> dict[str, Any]:
91
+ """Rebuilds a Message from raw stream events (message_start, content_block_*, message_delta)."""
92
+ message: dict[str, Any] = {
93
+ "role": "assistant",
94
+ "content": [],
95
+ "model": None,
96
+ "stop_reason": None,
97
+ "usage": {},
98
+ }
99
+ blocks: dict[int, dict[str, Any]] = {}
100
+ json_parts: dict[int, list[str]] = {}
101
+ usage: dict[str, Any] = {}
102
+ for ev in events:
103
+ e = _dump(ev)
104
+ t = get_str(e, "type")
105
+ if t == "message_start":
106
+ m = get_dict(e, "message")
107
+ message["model"] = get_str(m, "model")
108
+ usage.update(get_dict(m, "usage"))
109
+ elif t == "content_block_start":
110
+ idx = get_int(e, "index") or 0
111
+ block = dict(get_dict(e, "content_block"))
112
+ blocks[idx] = block
113
+ if get_str(block, "type") == "tool_use":
114
+ json_parts[idx] = []
115
+ block["input"] = {}
116
+ elif get_str(block, "type") == "text":
117
+ block["text"] = get_str(block, "text") or ""
118
+ elif t == "content_block_delta":
119
+ idx = get_int(e, "index") or 0
120
+ delta = get_dict(e, "delta")
121
+ block = blocks.setdefault(idx, {})
122
+ if get_str(delta, "type") == "text_delta":
123
+ block["text"] = (get_str(block, "text") or "") + (get_str(delta, "text") or "")
124
+ elif get_str(delta, "type") == "input_json_delta":
125
+ json_parts.setdefault(idx, []).append(get_str(delta, "partial_json") or "")
126
+ elif t == "message_delta":
127
+ delta = get_dict(e, "delta")
128
+ if get_str(delta, "stop_reason"):
129
+ message["stop_reason"] = get_str(delta, "stop_reason")
130
+ usage.update({k: v for k, v in get_dict(e, "usage").items() if v is not None})
131
+ message["usage"] = usage
132
+ import json as _json
133
+
134
+ for idx, parts in json_parts.items():
135
+ raw = "".join(parts)
136
+ try:
137
+ blocks[idx]["input"] = _json.loads(raw) if raw else {}
138
+ except ValueError:
139
+ blocks[idx]["input"] = {"_raw": raw}
140
+ message["content"] = [blocks[i] for i in sorted(blocks)]
141
+ return message
142
+
143
+
144
+ class _TappedStream:
145
+ def __init__(self, stream: Any, on_done: Any) -> None:
146
+ self._stream = stream
147
+ self._on_done = on_done
148
+ self._events: list[Any] = []
149
+ self._t0 = time.perf_counter()
150
+ self._first: float | None = None
151
+
152
+ def __iter__(self) -> Iterator[Any]:
153
+ try:
154
+ for ev in self._stream:
155
+ if self._first is None:
156
+ self._first = (time.perf_counter() - self._t0) * 1000
157
+ self._events.append(ev)
158
+ yield ev
159
+ finally:
160
+ self._on_done(self._events, self._first)
161
+
162
+ def __aiter__(self) -> AsyncIterator[Any]:
163
+ return self._aiter()
164
+
165
+ async def _aiter(self) -> AsyncIterator[Any]:
166
+ try:
167
+ async for ev in self._stream:
168
+ if self._first is None:
169
+ self._first = (time.perf_counter() - self._t0) * 1000
170
+ self._events.append(ev)
171
+ yield ev
172
+ finally:
173
+ self._on_done(self._events, self._first)
174
+
175
+ def __getattr__(self, item: str) -> Any:
176
+ return getattr(self._stream, item)
177
+
178
+
179
+ def wrap_anthropic(client: T) -> T:
180
+ """Patches client.messages.create in place (Anthropic and AsyncAnthropic)."""
181
+ messages = cast(Any, client).messages
182
+ original = messages.create
183
+ is_async = type(client).__name__.startswith("Async")
184
+
185
+ def on_done_for(run: Any, model: str | None, inp: Any, params: Any, started: datetime) -> Any:
186
+ def on_done(events: list[Any], first: float | None) -> None:
187
+ _record(run, model, inp, params, assemble_message_stream(events), started, first)
188
+
189
+ return on_done
190
+
191
+ if is_async:
192
+
193
+ async def create_async(*args: Any, **kwargs: Any) -> Any:
194
+ run = context.current_run()
195
+ if run is None:
196
+ return await original(*args, **kwargs)
197
+ started = datetime.now(timezone.utc)
198
+ inp, params, model = split_message_params(kwargs)
199
+ try:
200
+ result = await original(*args, **kwargs)
201
+ except Exception as err:
202
+ _record(run, model, inp, params, None, started, None, error=f"{type(err).__name__}: {err}")
203
+ raise
204
+ if kwargs.get("stream"):
205
+ return _TappedStream(result, on_done_for(run, model, inp, params, started))
206
+ _record(run, model, inp, params, result, started, None)
207
+ return result
208
+
209
+ messages.create = create_async
210
+ else:
211
+
212
+ def create_sync(*args: Any, **kwargs: Any) -> Any:
213
+ run = context.current_run()
214
+ if run is None:
215
+ return original(*args, **kwargs)
216
+ started = datetime.now(timezone.utc)
217
+ inp, params, model = split_message_params(kwargs)
218
+ try:
219
+ result = original(*args, **kwargs)
220
+ except Exception as err:
221
+ _record(run, model, inp, params, None, started, None, error=f"{type(err).__name__}: {err}")
222
+ raise
223
+ if kwargs.get("stream"):
224
+ return _TappedStream(result, on_done_for(run, model, inp, params, started))
225
+ _record(run, model, inp, params, result, started, None)
226
+ return result
227
+
228
+ messages.create = create_sync
229
+ return client
@@ -0,0 +1,60 @@
1
+ """Thin typed-ish client for the Overturn REST API (used by correct())."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ import httpx
8
+
9
+ from ._json import as_dict
10
+
11
+ CorrectionKind = Literal["approve", "edit", "reject", "escalate"]
12
+
13
+
14
+ class OverturnApiError(Exception):
15
+ def __init__(self, status: int, body: Any, message: str) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.body = body
19
+
20
+
21
+ class OverturnApi:
22
+ def __init__(self, api_key: str, base_url: str, http_client: httpx.Client | None = None) -> None:
23
+ self.api_key = api_key
24
+ self.base_url = base_url.rstrip("/")
25
+ self._client = http_client or httpx.Client(timeout=30.0)
26
+
27
+ def request(self, method: str, path: str, body: Any = None, params: dict[str, Any] | None = None) -> Any:
28
+ res = self._client.request(
29
+ method,
30
+ f"{self.base_url}{path}",
31
+ json=body,
32
+ params={k: v for k, v in (params or {}).items() if v is not None},
33
+ headers={"authorization": f"Bearer {self.api_key}", "user-agent": "overturn-sdk-python/0.1.0"},
34
+ )
35
+ parsed: Any = res.json() if res.content else None
36
+ if not res.is_success:
37
+ msg = as_dict(parsed).get("message", res.text[:200])
38
+ raise OverturnApiError(
39
+ res.status_code, parsed, f"{method} {path} failed: {res.status_code} {msg}"
40
+ )
41
+ return parsed
42
+
43
+ # ---- endpoints ----
44
+ def me(self) -> dict[str, Any]:
45
+ return self.request("GET", "/v1/me")
46
+
47
+ def list_runs(self, **params: Any) -> dict[str, Any]:
48
+ return self.request("GET", "/v1/runs", params=params)
49
+
50
+ def get_run(self, run_id: str) -> dict[str, Any]:
51
+ return self.request("GET", f"/v1/runs/{run_id}")
52
+
53
+ def create_correction(self, body: dict[str, Any]) -> dict[str, Any]:
54
+ return self.request("POST", "/v1/corrections", body=body)
55
+
56
+ def list_cases(self, **params: Any) -> dict[str, Any]:
57
+ return self.request("GET", "/v1/cases", params=params)
58
+
59
+ def get_case(self, case_id: str) -> dict[str, Any]:
60
+ return self.request("GET", f"/v1/cases/{case_id}")
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Callable, Sequence
5
+ from typing import Any, ParamSpec, TypeVar
6
+
7
+ import httpx
8
+
9
+ from . import context
10
+ from . import tools as _tools
11
+ from .anthropic_wrap import wrap_anthropic
12
+ from .api import CorrectionKind, OverturnApi
13
+ from .openai_wrap import wrap_openai
14
+ from .replay import ReplayModelCall, ReplayState, ToolRecordings
15
+ from .run import Run
16
+ from .tools import Tool
17
+ from .transport import OnError, Transport
18
+ from .types import RunUpsert, StepUpsert
19
+
20
+ P = ParamSpec("P")
21
+ R = TypeVar("R")
22
+ T = TypeVar("T")
23
+
24
+ Redact = Callable[[StepUpsert], StepUpsert | None]
25
+
26
+
27
+ class Overturn:
28
+ def __init__(
29
+ self,
30
+ api_key: str | None = None,
31
+ base_url: str | None = None,
32
+ *,
33
+ redact: Redact | None = None,
34
+ enabled: bool = True,
35
+ debug: bool | None = None,
36
+ on_error: OnError | None = None,
37
+ flush_interval_s: float = 1.0,
38
+ max_batch_size: int = 100,
39
+ http_client: httpx.Client | None = None,
40
+ background: bool = True,
41
+ ) -> None:
42
+ self.api_key = api_key or os.environ.get("OVERTURN_API_KEY", "")
43
+ self.base_url = base_url or os.environ.get("OVERTURN_BASE_URL", "https://api.overturn.run")
44
+ self.debug = debug if debug is not None else os.environ.get("OVERTURN_DEBUG") == "1"
45
+ self.redact = redact
46
+ self.replay: ReplayState | None = None
47
+ self.transport: Transport | None = None
48
+ self.api = OverturnApi(self.api_key, self.base_url, http_client=http_client)
49
+ if enabled and self.api_key:
50
+ self.transport = Transport(
51
+ self.api_key,
52
+ self.base_url,
53
+ flush_interval_s=flush_interval_s,
54
+ max_batch_size=max_batch_size,
55
+ on_error=on_error,
56
+ debug=self.debug,
57
+ http_client=http_client,
58
+ background=background,
59
+ )
60
+ elif enabled and self.debug:
61
+ print("[overturn] no API key set; nothing will be recorded")
62
+
63
+ # ---- sink ----
64
+ def emit_run(self, run: RunUpsert) -> None:
65
+ if _tools.get_replay_state() is not None:
66
+ return # replay runs are never recorded as production runs
67
+ if self.transport:
68
+ self.transport.enqueue_run(run)
69
+
70
+ def emit_step(self, step: StepUpsert) -> None:
71
+ replay = _tools.get_replay_state()
72
+ if replay is not None:
73
+ replay.record(step)
74
+ return
75
+ if not self.transport:
76
+ return
77
+ out = self.redact(step) if self.redact else step
78
+ if out is not None:
79
+ self.transport.enqueue_step(out)
80
+
81
+ # ---- API ----
82
+ def run(
83
+ self,
84
+ agent: str,
85
+ *,
86
+ version: str | None = None,
87
+ input: Any = None,
88
+ tags: list[str] | None = None,
89
+ metadata: dict[str, Any] | None = None,
90
+ external_id: str | None = None,
91
+ id: str | None = None,
92
+ ) -> Run:
93
+ """Start a run. Use as a context manager: `with ov.run(agent="x") as run: ...`"""
94
+ return Run(
95
+ self,
96
+ agent=agent,
97
+ version=version,
98
+ input=input,
99
+ tags=tags,
100
+ metadata=metadata,
101
+ external_id=external_id,
102
+ id=id,
103
+ )
104
+
105
+ start_run = run
106
+
107
+ def current_run(self) -> Run | None:
108
+ return context.current_run()
109
+
110
+ def tool(
111
+ self,
112
+ name: str,
113
+ fn: Callable[P, R],
114
+ *,
115
+ side_effect: bool = False,
116
+ match_on: Sequence[str] | None = None,
117
+ ignore_args: Sequence[str] | None = None,
118
+ description: str | None = None,
119
+ ) -> Tool[P, R]:
120
+ return Tool(
121
+ fn,
122
+ name=name,
123
+ side_effect=side_effect,
124
+ match_on=match_on,
125
+ ignore_args=ignore_args,
126
+ description=description,
127
+ )
128
+
129
+ def wrap_openai(self, client: T) -> T:
130
+ return wrap_openai(client)
131
+
132
+ def wrap_anthropic(self, client: T) -> T:
133
+ return wrap_anthropic(client)
134
+
135
+ def correct(
136
+ self,
137
+ *,
138
+ run_id: str,
139
+ kind: CorrectionKind,
140
+ step_id: str | None = None,
141
+ corrected_output: Any = None,
142
+ note: str | None = None,
143
+ actor: str | None = None,
144
+ ) -> dict[str, Any]:
145
+ """Record a human correction and freeze a regression case. Sent immediately; returns
146
+ {"correction": ..., "case": ...}."""
147
+ body: dict[str, Any] = {"runId": run_id, "kind": kind, "source": "sdk"}
148
+ if step_id is not None:
149
+ body["stepId"] = step_id
150
+ if corrected_output is not None:
151
+ body["correctedOutput"] = corrected_output
152
+ if note is not None:
153
+ body["note"] = note
154
+ if actor is not None:
155
+ body["actor"] = actor
156
+ return self.api.create_correction(body)
157
+
158
+ def replay_mode(
159
+ self,
160
+ recordings: ToolRecordings,
161
+ *,
162
+ allow_side_effects: bool = False,
163
+ model_call: ReplayModelCall | None = None,
164
+ max_steps: int = 50,
165
+ ) -> ReplayState:
166
+ """Every @tool is answered from recordings and never executed, until exit_replay_mode().
167
+ With model_call, wrapped model clients answer from the target model instead of the provider."""
168
+ self.replay = ReplayState(
169
+ recordings, allow_side_effects=allow_side_effects, model_call=model_call, max_steps=max_steps
170
+ )
171
+ _tools.set_replay_state(self.replay)
172
+ return self.replay
173
+
174
+ def exit_replay_mode(self) -> None:
175
+ self.replay = None
176
+ _tools.set_replay_state(None)
177
+
178
+ def flush(self) -> None:
179
+ if self.transport:
180
+ self.transport.flush()
181
+
182
+ def shutdown(self) -> None:
183
+ if self.transport:
184
+ self.transport.close()
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .run import Run
8
+
9
+ _current_run: ContextVar[Run | None] = ContextVar("overturn_current_run", default=None)
10
+ _parent_step: ContextVar[str | None] = ContextVar("overturn_parent_step", default=None)
11
+
12
+
13
+ def current_run() -> Run | None:
14
+ return _current_run.get()
15
+
16
+
17
+ def parent_step_id() -> str | None:
18
+ return _parent_step.get()