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.
- overturn-0.1.0/.gitignore +17 -0
- overturn-0.1.0/PKG-INFO +26 -0
- overturn-0.1.0/README.md +3 -0
- overturn-0.1.0/overturn/__init__.py +67 -0
- overturn-0.1.0/overturn/_json.py +35 -0
- overturn-0.1.0/overturn/anthropic_wrap.py +229 -0
- overturn-0.1.0/overturn/api.py +60 -0
- overturn-0.1.0/overturn/client.py +184 -0
- overturn-0.1.0/overturn/context.py +18 -0
- overturn-0.1.0/overturn/fingerprint.py +129 -0
- overturn-0.1.0/overturn/openai_wrap.py +297 -0
- overturn-0.1.0/overturn/pricing.py +56 -0
- overturn-0.1.0/overturn/py.typed +0 -0
- overturn-0.1.0/overturn/replay.py +98 -0
- overturn-0.1.0/overturn/run.py +157 -0
- overturn-0.1.0/overturn/tools.py +196 -0
- overturn-0.1.0/overturn/transport.py +165 -0
- overturn-0.1.0/overturn/types.py +69 -0
- overturn-0.1.0/package.json +12 -0
- overturn-0.1.0/pyproject.toml +54 -0
- overturn-0.1.0/tests/__init__.py +0 -0
- overturn-0.1.0/tests/conftest.py +43 -0
- overturn-0.1.0/tests/test_anthropic.py +113 -0
- overturn-0.1.0/tests/test_fingerprint.py +55 -0
- overturn-0.1.0/tests/test_recording.py +246 -0
- overturn-0.1.0/tests/test_replay.py +131 -0
- overturn-0.1.0/tests/test_transport.py +119 -0
- overturn-0.1.0/uv.lock +874 -0
overturn-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
overturn-0.1.0/README.md
ADDED
|
@@ -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()
|