touchstone-bench 0.1.0__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.
- touchstone/__init__.py +106 -0
- touchstone/capture/__init__.py +21 -0
- touchstone/capture/context.py +241 -0
- touchstone/capture/litellm.py +95 -0
- touchstone/capture/openinference.py +169 -0
- touchstone/capture/patch_anthropic.py +215 -0
- touchstone/capture/patch_openai.py +232 -0
- touchstone/capture/pricing.py +40 -0
- touchstone/capture/spans.py +223 -0
- touchstone/cli/__init__.py +199 -0
- touchstone/cli/_common.py +52 -0
- touchstone/cli/bench.py +81 -0
- touchstone/cli/review.py +52 -0
- touchstone/cli/survey.py +47 -0
- touchstone/cli/train.py +87 -0
- touchstone/config.py +146 -0
- touchstone/demo.py +115 -0
- touchstone/harbor/__init__.py +2 -0
- touchstone/harbor/acp_server.py +160 -0
- touchstone/harbor/agent.py +288 -0
- touchstone/harbor/atif.py +308 -0
- touchstone/harbor/atif_import.py +77 -0
- touchstone/harbor/conversation.py +113 -0
- touchstone/harbor/dataset.py +69 -0
- touchstone/harbor/jobs.py +130 -0
- touchstone/harbor/keys.py +21 -0
- touchstone/harbor/remote.py +73 -0
- touchstone/harbor/rewardkit.py +116 -0
- touchstone/harbor/run.py +295 -0
- touchstone/ids.py +31 -0
- touchstone/interview/__init__.py +17 -0
- touchstone/interview/realtime.py +301 -0
- touchstone/interview/rooms.py +73 -0
- touchstone/interview/speech.py +304 -0
- touchstone/llm/__init__.py +19 -0
- touchstone/llm/_cli.py +90 -0
- touchstone/llm/_http.py +135 -0
- touchstone/llm/anthropic.py +139 -0
- touchstone/llm/base.py +33 -0
- touchstone/llm/claude_cli.py +71 -0
- touchstone/llm/codex_cli.py +65 -0
- touchstone/llm/keychain.py +43 -0
- touchstone/llm/nop.py +23 -0
- touchstone/llm/openai_compat.py +112 -0
- touchstone/llm/prompt.py +181 -0
- touchstone/llm/reference.py +31 -0
- touchstone/llm/registry.py +177 -0
- touchstone/llm/scripted.py +74 -0
- touchstone/messages.py +319 -0
- touchstone/messages_wire.py +139 -0
- touchstone/overview.py +24 -0
- touchstone/review/__init__.py +7 -0
- touchstone/review/agent.py +316 -0
- touchstone/review/changes.py +291 -0
- touchstone/review/facts.py +122 -0
- touchstone/review/prompt.py +84 -0
- touchstone/review/readback.py +51 -0
- touchstone/review/regrade.py +71 -0
- touchstone/review/replies.py +107 -0
- touchstone/review/scratch.py +20 -0
- touchstone/review/snapshot.py +20 -0
- touchstone/review/trials.py +316 -0
- touchstone/server/__init__.py +3 -0
- touchstone/server/app.py +108 -0
- touchstone/server/pagedata.py +68 -0
- touchstone/server/pages.py +304 -0
- touchstone/server/routes/__init__.py +12 -0
- touchstone/server/routes/_deps.py +31 -0
- touchstone/server/routes/episodes.py +33 -0
- touchstone/server/routes/overview.py +27 -0
- touchstone/server/routes/pages.py +57 -0
- touchstone/server/routes/rooms.py +309 -0
- touchstone/server/static/app.css +345 -0
- touchstone/server/static/app.js +377 -0
- touchstone/server/static/index.html +24 -0
- touchstone/server/static/room.html +62 -0
- touchstone/server/static/room.js +342 -0
- touchstone/store.py +294 -0
- touchstone/store_models.py +78 -0
- touchstone/survey/__init__.py +5 -0
- touchstone/survey/baseline.py +93 -0
- touchstone/survey/benchmark.py +161 -0
- touchstone/survey/criteria.py +251 -0
- touchstone/survey/descriptions.py +118 -0
- touchstone/survey/environment.py +255 -0
- touchstone/survey/envs.py +35 -0
- touchstone/survey/fidelity.py +294 -0
- touchstone/survey/fidelity_db.py +42 -0
- touchstone/survey/fidelity_mask.py +48 -0
- touchstone/survey/gate.py +138 -0
- touchstone/survey/group.py +199 -0
- touchstone/survey/invoke.py +139 -0
- touchstone/survey/map.py +141 -0
- touchstone/survey/minted_ids.py +73 -0
- touchstone/survey/netshim.py +114 -0
- touchstone/survey/package.py +145 -0
- touchstone/survey/package_entry.py +195 -0
- touchstone/survey/package_spans.py +49 -0
- touchstone/survey/provider.py +98 -0
- touchstone/survey/recordings.py +114 -0
- touchstone/survey/replay.py +151 -0
- touchstone/survey/report.py +230 -0
- touchstone/survey/scrub.py +64 -0
- touchstone/survey/simulate.py +308 -0
- touchstone/survey/sort.py +24 -0
- touchstone/survey/subproc.py +52 -0
- touchstone/survey/survey.py +180 -0
- touchstone/survey/task_files.py +154 -0
- touchstone/survey/task_text.py +146 -0
- touchstone/survey/tasks.py +271 -0
- touchstone/survey/tool_reads.py +55 -0
- touchstone/survey/writes.py +25 -0
- touchstone/train/__init__.py +5 -0
- touchstone/train/datasets.py +121 -0
- touchstone/train/plugin.py +52 -0
- touchstone/train/write.py +113 -0
- touchstone_bench-0.1.0.dist-info/METADATA +170 -0
- touchstone_bench-0.1.0.dist-info/RECORD +121 -0
- touchstone_bench-0.1.0.dist-info/WHEEL +4 -0
- touchstone_bench-0.1.0.dist-info/entry_points.txt +2 -0
- touchstone_bench-0.1.0.dist-info/licenses/LICENSE +21 -0
touchstone/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Touchstone public API — the one-liners an instrumented app imports.
|
|
2
|
+
|
|
3
|
+
import touchstone; touchstone.trace()
|
|
4
|
+
|
|
5
|
+
`trace()` wires capture to the SQLite db and patches whichever of openai/anthropic is importable.
|
|
6
|
+
It never raises if neither is installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from . import capture
|
|
15
|
+
from .capture import episode, tool
|
|
16
|
+
from .capture.patch_anthropic import patch as _patch_anthropic
|
|
17
|
+
from .capture.patch_openai import patch as _patch_openai
|
|
18
|
+
from .config import load_settings
|
|
19
|
+
from .messages import canonical
|
|
20
|
+
|
|
21
|
+
__all__ = ["trace", "episode", "outcome", "tool", "record_llm_call"]
|
|
22
|
+
|
|
23
|
+
_log = logging.getLogger("touchstone")
|
|
24
|
+
_traced = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def trace(db: str | None = None, otel: bool = False) -> dict:
|
|
28
|
+
"""Idempotent. Point capture at `db` (or configured/default) and patch installed SDKs.
|
|
29
|
+
|
|
30
|
+
With `otel=True`, also register the OpenInference OTel span exporter (needs the
|
|
31
|
+
`touchstone[otel]` extra) so existing OpenInference spans land in the store.
|
|
32
|
+
"""
|
|
33
|
+
global _traced
|
|
34
|
+
settings = load_settings()
|
|
35
|
+
db_path = db or settings.db_path
|
|
36
|
+
capture.configure(db_path)
|
|
37
|
+
from .survey.netshim import install_from_env
|
|
38
|
+
|
|
39
|
+
install_from_env() # no-op in prod; rewrites a hardcoded host if TOUCHSTONE_SIMULATORS is set
|
|
40
|
+
patched = []
|
|
41
|
+
if _patch_openai():
|
|
42
|
+
patched.append("openai")
|
|
43
|
+
if _patch_anthropic():
|
|
44
|
+
patched.append("anthropic")
|
|
45
|
+
if otel:
|
|
46
|
+
from .capture.openinference import register
|
|
47
|
+
|
|
48
|
+
if register():
|
|
49
|
+
patched.append("openinference")
|
|
50
|
+
_traced = True
|
|
51
|
+
return {"db": db_path, "patched": patched}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def outcome(score: float | None, label: str | None) -> None:
|
|
55
|
+
try: # resolving the current/untracked episode can touch the db; never raise into the app
|
|
56
|
+
capture.current_episode().outcome(score, label)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
_log.warning("touchstone outcome failed: %r", exc)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_calls(items) -> list[dict]:
|
|
62
|
+
calls = []
|
|
63
|
+
for tc in items:
|
|
64
|
+
args = tc.get("arguments")
|
|
65
|
+
if not isinstance(args, str):
|
|
66
|
+
args = json.dumps(args, ensure_ascii=False)
|
|
67
|
+
calls.append({"id": tc.get("id"), "name": tc.get("name"), "arguments": args})
|
|
68
|
+
return calls
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_reply(reply) -> tuple[str, list[dict], dict | None]:
|
|
72
|
+
from .llm.base import Reply
|
|
73
|
+
|
|
74
|
+
if isinstance(reply, Reply):
|
|
75
|
+
reply = {"content": reply.content, "tool_calls": reply.tool_calls, "usage": reply.usage}
|
|
76
|
+
if isinstance(reply, str):
|
|
77
|
+
return reply, [], None
|
|
78
|
+
if isinstance(reply, dict):
|
|
79
|
+
calls = _normalize_calls(reply.get("tool_calls") or [])
|
|
80
|
+
return reply.get("content", "") or "", calls, reply.get("usage")
|
|
81
|
+
return str(reply), [], None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def record_llm_call(model, messages, reply, tools=None, usage=None) -> None:
|
|
85
|
+
"""Record an llm call made outside a patched SDK (e.g. via a Provider)."""
|
|
86
|
+
try: # capture must never raise into the app (db unwritable, bad reply shape, …)
|
|
87
|
+
_record_llm_call(model, messages, reply, tools, usage)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
_log.warning("touchstone record_llm_call failed: %r", exc)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _record_llm_call(model, messages, reply, tools, usage) -> None:
|
|
93
|
+
content, tool_calls, reply_usage = _normalize_reply(reply)
|
|
94
|
+
u = usage or reply_usage or {}
|
|
95
|
+
convo = canonical(
|
|
96
|
+
list(messages) + [{"role": "assistant", "content": content, "tool_calls": tool_calls}]
|
|
97
|
+
)
|
|
98
|
+
capture.add_span(
|
|
99
|
+
"model",
|
|
100
|
+
model or "model",
|
|
101
|
+
model=model,
|
|
102
|
+
input={"messages": convo[:-1], "tools": tools or [], "params": {}},
|
|
103
|
+
output={"message": convo[-1]},
|
|
104
|
+
tokens_in=u.get("tokens_in"),
|
|
105
|
+
tokens_out=u.get("tokens_out"),
|
|
106
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .context import (
|
|
2
|
+
EpisodeHandle,
|
|
3
|
+
add_span,
|
|
4
|
+
configure,
|
|
5
|
+
current_episode,
|
|
6
|
+
episode,
|
|
7
|
+
get_conn,
|
|
8
|
+
is_configured,
|
|
9
|
+
tool,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"episode",
|
|
14
|
+
"tool",
|
|
15
|
+
"add_span",
|
|
16
|
+
"configure",
|
|
17
|
+
"current_episode",
|
|
18
|
+
"get_conn",
|
|
19
|
+
"is_configured",
|
|
20
|
+
"EpisodeHandle",
|
|
21
|
+
]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Episode context + span recording. The instrumented app, the SDK patches, the litellm callback,
|
|
2
|
+
and the public helpers all funnel through `add_span` here.
|
|
3
|
+
|
|
4
|
+
A connection is thread-local and keyed to the configured db path, so concurrent writers (server +
|
|
5
|
+
app) each hold their own connection and rely on WAL + busy_timeout for safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from functools import wraps
|
|
19
|
+
|
|
20
|
+
from .. import store
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger("touchstone")
|
|
23
|
+
_db_path: str | None = None
|
|
24
|
+
_local = threading.local()
|
|
25
|
+
_current: ContextVar[EpisodeHandle | None] = ContextVar("touchstone_episode", default=None)
|
|
26
|
+
_untracked_lock = threading.Lock()
|
|
27
|
+
_untracked_ids: dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure(db_path: str) -> None:
|
|
31
|
+
global _db_path
|
|
32
|
+
_db_path = db_path
|
|
33
|
+
_local.__dict__.pop("conn", None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_configured() -> bool:
|
|
37
|
+
return _db_path is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe(action: str, fn, default=None):
|
|
41
|
+
"""Run `fn`; if recording fails (e.g. a non-writable db), log one warning and return `default`.
|
|
42
|
+
Capture must never raise into the user's application — the explicit helpers (`episode`, `tool`,
|
|
43
|
+
`outcome`) funnel through here just as the SDK patches funnel through `spans._recorder`."""
|
|
44
|
+
try:
|
|
45
|
+
return fn()
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
_log.warning("touchstone %s failed: %r", action, exc)
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_conn():
|
|
52
|
+
if _db_path is None:
|
|
53
|
+
raise RuntimeError("touchstone.trace() has not been called")
|
|
54
|
+
conn = getattr(_local, "conn", None)
|
|
55
|
+
if conn is None or getattr(_local, "path", None) != _db_path:
|
|
56
|
+
conn = store.connect(_db_path)
|
|
57
|
+
_local.conn = conn
|
|
58
|
+
_local.path = _db_path
|
|
59
|
+
return conn
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class EpisodeHandle:
|
|
64
|
+
id: str
|
|
65
|
+
|
|
66
|
+
def outcome(self, score: float | None, label: str | None) -> None:
|
|
67
|
+
if not self.id: # detached handle from a failed episode start; nothing to record
|
|
68
|
+
return
|
|
69
|
+
_safe("outcome", lambda: store.outcome(get_conn(), self.id, score, label))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _start_episode(name: str, meta: dict | None, source: str) -> EpisodeHandle:
|
|
73
|
+
conn = get_conn()
|
|
74
|
+
ep = store.insert_episode(conn, store.Episode(name=name, source=source, meta=meta or {}))
|
|
75
|
+
return EpisodeHandle(ep.id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def episode(name: str, meta: dict | None = None, source: str = "app"):
|
|
80
|
+
handle = _safe("episode start", lambda: _start_episode(name, meta, source))
|
|
81
|
+
if handle is None: # capture unavailable; the app must still run its `with` body
|
|
82
|
+
yield EpisodeHandle("")
|
|
83
|
+
return
|
|
84
|
+
token = _current.set(handle)
|
|
85
|
+
try:
|
|
86
|
+
yield handle
|
|
87
|
+
finally:
|
|
88
|
+
_safe("episode end", lambda: store.end_episode(get_conn(), handle.id))
|
|
89
|
+
_current.reset(token)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _find_untracked(conn, name: str) -> str | None:
|
|
93
|
+
for e in store.list_episodes(conn):
|
|
94
|
+
if e.name == name and e.source == "untracked":
|
|
95
|
+
return e.id
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _untracked() -> EpisodeHandle:
|
|
100
|
+
conn = get_conn()
|
|
101
|
+
date = datetime.now(UTC).date().isoformat()
|
|
102
|
+
name = f"untracked-{date}"
|
|
103
|
+
with _untracked_lock:
|
|
104
|
+
eid = _untracked_ids.get(date)
|
|
105
|
+
if eid and store.get_episode(conn, eid):
|
|
106
|
+
return EpisodeHandle(eid)
|
|
107
|
+
found = _find_untracked(conn, name)
|
|
108
|
+
if found is None:
|
|
109
|
+
found = store.insert_episode(conn, store.Episode(name=name, source="untracked")).id
|
|
110
|
+
_untracked_ids[date] = found
|
|
111
|
+
return EpisodeHandle(found)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def current_episode() -> EpisodeHandle:
|
|
115
|
+
return _current.get() or _untracked()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def add_span(
|
|
119
|
+
kind: str,
|
|
120
|
+
name: str,
|
|
121
|
+
*,
|
|
122
|
+
model: str | None = None,
|
|
123
|
+
input: dict | None = None,
|
|
124
|
+
output: dict | None = None,
|
|
125
|
+
tokens_in: int | None = None,
|
|
126
|
+
tokens_out: int | None = None,
|
|
127
|
+
cost_usd: float | None = None,
|
|
128
|
+
error: str | None = None,
|
|
129
|
+
started_at: str | None = None,
|
|
130
|
+
parent_id: str | None = None,
|
|
131
|
+
tool_call_id: str | None = None,
|
|
132
|
+
) -> store.Span:
|
|
133
|
+
conn = get_conn()
|
|
134
|
+
ep = current_episode()
|
|
135
|
+
span = store.Span(
|
|
136
|
+
episode_id=ep.id,
|
|
137
|
+
kind=kind,
|
|
138
|
+
name=name,
|
|
139
|
+
model=model,
|
|
140
|
+
parent_id=parent_id,
|
|
141
|
+
input=input or {},
|
|
142
|
+
output=output or {},
|
|
143
|
+
tokens_in=tokens_in,
|
|
144
|
+
tokens_out=tokens_out,
|
|
145
|
+
cost_usd=cost_usd,
|
|
146
|
+
error=error,
|
|
147
|
+
started_at=started_at or store.now(),
|
|
148
|
+
tool_call_id=tool_call_id,
|
|
149
|
+
)
|
|
150
|
+
span.ended_at = store.now()
|
|
151
|
+
return store.insert_span(conn, span)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def jsonable(value):
|
|
155
|
+
try:
|
|
156
|
+
json.dumps(value, ensure_ascii=False)
|
|
157
|
+
return value
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
return repr(value)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _bound_args(fn, args, kwargs) -> dict:
|
|
163
|
+
try:
|
|
164
|
+
bound = inspect.signature(fn).bind_partial(*args, **kwargs)
|
|
165
|
+
return {k: jsonable(v) for k, v in bound.arguments.items()}
|
|
166
|
+
except TypeError:
|
|
167
|
+
return {
|
|
168
|
+
"args": [jsonable(a) for a in args],
|
|
169
|
+
"kwargs": {k: jsonable(v) for k, v in kwargs.items()},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _resolve_tool_call_id(tname: str) -> str | None:
|
|
174
|
+
"""The most recent model span's unresolved tool_call of this name, if any — so a dispatched
|
|
175
|
+
tool span links back to the call that requested it without the caller passing the id."""
|
|
176
|
+
try:
|
|
177
|
+
conn = get_conn()
|
|
178
|
+
spans = store.list_spans(conn, current_episode().id)
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
model_spans = [s for s in spans if s.kind == "model"]
|
|
182
|
+
if not model_spans:
|
|
183
|
+
return None
|
|
184
|
+
used = {s.tool_call_id for s in spans if s.kind == "tool" and s.tool_call_id}
|
|
185
|
+
calls = (model_spans[-1].output or {}).get("message", {}).get("tool_calls") or []
|
|
186
|
+
for tc in calls:
|
|
187
|
+
if tc.get("name") == tname and tc.get("id") and tc.get("id") not in used:
|
|
188
|
+
return tc["id"]
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _record_tool(tname, bound, tool_call_id, started, *, result=None, error=None) -> None:
|
|
193
|
+
def _do():
|
|
194
|
+
call_id = tool_call_id if tool_call_id is not None else _resolve_tool_call_id(tname)
|
|
195
|
+
output = None if error else {"result": jsonable(result)}
|
|
196
|
+
add_span("tool", tname, input={"name": tname, "arguments": bound}, output=output,
|
|
197
|
+
error=error, started_at=started, tool_call_id=call_id)
|
|
198
|
+
|
|
199
|
+
_safe("tool span", _do) # a recording failure must never lose the tool's own result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def tool(fn=None, *, name: str | None = None):
|
|
203
|
+
"""Decorator recording a tool span (name/arguments/result/error/tool_call_id). Works sync and
|
|
204
|
+
async. A caller may pass `tool_call_id=` to link the span to a specific model tool_call;
|
|
205
|
+
otherwise it is auto-linked to the latest unresolved call of the same name."""
|
|
206
|
+
|
|
207
|
+
def decorate(f):
|
|
208
|
+
tname = name or f.__name__
|
|
209
|
+
|
|
210
|
+
if inspect.iscoroutinefunction(f):
|
|
211
|
+
@wraps(f)
|
|
212
|
+
async def awrapper(*args, **kwargs):
|
|
213
|
+
tcid = kwargs.pop("tool_call_id", None)
|
|
214
|
+
started = store.now()
|
|
215
|
+
bound = _bound_args(f, args, kwargs)
|
|
216
|
+
try:
|
|
217
|
+
result = await f(*args, **kwargs)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
_record_tool(tname, bound, tcid, started, error=repr(exc))
|
|
220
|
+
raise
|
|
221
|
+
_record_tool(tname, bound, tcid, started, result=result)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
return awrapper
|
|
225
|
+
|
|
226
|
+
@wraps(f)
|
|
227
|
+
def wrapper(*args, **kwargs):
|
|
228
|
+
tcid = kwargs.pop("tool_call_id", None)
|
|
229
|
+
started = store.now()
|
|
230
|
+
bound = _bound_args(f, args, kwargs)
|
|
231
|
+
try:
|
|
232
|
+
result = f(*args, **kwargs)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
_record_tool(tname, bound, tcid, started, error=repr(exc))
|
|
235
|
+
raise
|
|
236
|
+
_record_tool(tname, bound, tcid, started, result=result)
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
return wrapper
|
|
240
|
+
|
|
241
|
+
return decorate(fn) if fn else decorate
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""litellm CustomLogger callback that records an llm span. litellm is imported lazily and never
|
|
2
|
+
required; the logger works even when litellm is absent (base class falls back to object)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from . import spans
|
|
10
|
+
from .patch_openai import _extract # litellm ModelResponse is OpenAI-shaped
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _base():
|
|
14
|
+
try:
|
|
15
|
+
from litellm.integrations.custom_logger import CustomLogger
|
|
16
|
+
|
|
17
|
+
return CustomLogger
|
|
18
|
+
except Exception:
|
|
19
|
+
return object
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _iso(t) -> str:
|
|
23
|
+
if isinstance(t, datetime):
|
|
24
|
+
return t.isoformat()
|
|
25
|
+
from .. import store
|
|
26
|
+
|
|
27
|
+
return store.now()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TouchstoneLogger(_base()):
|
|
31
|
+
def _record(self, kwargs, response_obj, start_time, error):
|
|
32
|
+
model = kwargs.get("model")
|
|
33
|
+
messages = kwargs.get("messages") or []
|
|
34
|
+
opt = kwargs.get("optional_params") or {}
|
|
35
|
+
tools = opt.get("tools") or kwargs.get("tools")
|
|
36
|
+
result = _extract(response_obj) if response_obj is not None else spans._ERROR
|
|
37
|
+
err = error or (repr(kwargs["exception"]) if kwargs.get("exception") else None)
|
|
38
|
+
try:
|
|
39
|
+
spans.record("litellm", model, messages, tools, {}, result, err, _iso(start_time))
|
|
40
|
+
except Exception as exc: # capture must never break the caller
|
|
41
|
+
spans._log.warning("touchstone capture failed: %r", exc)
|
|
42
|
+
|
|
43
|
+
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
44
|
+
self._record(kwargs, response_obj, start_time, None)
|
|
45
|
+
|
|
46
|
+
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
47
|
+
self._record(kwargs, response_obj, start_time, "failure")
|
|
48
|
+
|
|
49
|
+
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
50
|
+
self._record(kwargs, response_obj, start_time, None)
|
|
51
|
+
|
|
52
|
+
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
53
|
+
self._record(kwargs, response_obj, start_time, "failure")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _override(orig):
|
|
57
|
+
"""Wrap a litellm entrypoint so TOUCHSTONE_MODEL rewrites `model` before the call runs."""
|
|
58
|
+
@functools.wraps(orig)
|
|
59
|
+
def wrapper(*args, **kwargs):
|
|
60
|
+
spans.override_model(kwargs)
|
|
61
|
+
return orig(*args, **kwargs)
|
|
62
|
+
wrapper._touchstone = True
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _override_async(orig):
|
|
67
|
+
@functools.wraps(orig)
|
|
68
|
+
async def wrapper(*args, **kwargs):
|
|
69
|
+
spans.override_model(kwargs)
|
|
70
|
+
return await orig(*args, **kwargs)
|
|
71
|
+
wrapper._touchstone = True
|
|
72
|
+
return wrapper
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _patch_model_override(litellm) -> None:
|
|
76
|
+
"""Rewrite the model on `litellm.completion`/`acompletion` (attribute callers only; `from
|
|
77
|
+
litellm import completion` binds before this runs, so those callers keep the recorded model)."""
|
|
78
|
+
fn = getattr(litellm, "completion", None)
|
|
79
|
+
if fn is not None and not getattr(fn, "_touchstone", False):
|
|
80
|
+
litellm.completion = _override(fn)
|
|
81
|
+
afn = getattr(litellm, "acompletion", None)
|
|
82
|
+
if afn is not None and not getattr(afn, "_touchstone", False):
|
|
83
|
+
litellm.acompletion = _override_async(afn)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install() -> bool:
|
|
87
|
+
"""Register the logger with litellm and rewire the model setting. False if litellm is absent."""
|
|
88
|
+
try:
|
|
89
|
+
import litellm
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
logger = TouchstoneLogger()
|
|
93
|
+
litellm.callbacks = list(getattr(litellm, "callbacks", []) or []) + [logger]
|
|
94
|
+
_patch_model_override(litellm)
|
|
95
|
+
return True
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Optional ingest of OpenInference-attributed OpenTelemetry spans into the same tables.
|
|
2
|
+
|
|
3
|
+
A team already instrumented with OpenInference (Arize) auto-instrumentors can point Touchstone at
|
|
4
|
+
their traces with `touchstone.trace(otel=True)`: this registers an in-process OTel `SpanExporter`
|
|
5
|
+
that normalizes each span's flattened `openinference.*` attributes into canonical messages and
|
|
6
|
+
writes model / tool spans under one episode per trace. No collector process is involved. Requires
|
|
7
|
+
the `touchstone[otel]` extra (opentelemetry-sdk); the module imports without it for testing against
|
|
8
|
+
hand-built ReadableSpan-like objects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
from .. import store
|
|
16
|
+
from ..messages import _args_str, canonical
|
|
17
|
+
from . import context
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _base_exporter():
|
|
21
|
+
try:
|
|
22
|
+
from opentelemetry.sdk.trace.export import SpanExporter
|
|
23
|
+
|
|
24
|
+
return SpanExporter
|
|
25
|
+
except Exception:
|
|
26
|
+
return object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iso(ns) -> str:
|
|
30
|
+
return datetime.fromtimestamp(ns / 1e9, UTC).isoformat() if ns else store.now()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _indexed(attrs: dict, prefix: str) -> dict[int, dict]:
|
|
34
|
+
"""Group `prefix.{i}.<tail>` attributes by index i."""
|
|
35
|
+
out: dict[int, dict] = {}
|
|
36
|
+
for key, val in attrs.items():
|
|
37
|
+
if not key.startswith(prefix + "."):
|
|
38
|
+
continue
|
|
39
|
+
i, _, tail = key[len(prefix) + 1:].partition(".")
|
|
40
|
+
if i.isdigit():
|
|
41
|
+
out.setdefault(int(i), {})[tail] = val
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tool_calls(fields: dict) -> list[dict]:
|
|
46
|
+
by_j = _indexed(fields, "message.tool_calls")
|
|
47
|
+
calls = []
|
|
48
|
+
for j in sorted(by_j):
|
|
49
|
+
f = by_j[j]
|
|
50
|
+
calls.append({"id": f.get("tool_call.id"),
|
|
51
|
+
"name": f.get("tool_call.function.name"),
|
|
52
|
+
"arguments": _args_str(f.get("tool_call.function.arguments"))})
|
|
53
|
+
return calls
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _message(fields: dict) -> dict:
|
|
57
|
+
msg = {"role": fields.get("message.role", "user"),
|
|
58
|
+
"content": fields.get("message.content", "") or ""}
|
|
59
|
+
calls = _tool_calls(fields)
|
|
60
|
+
if calls:
|
|
61
|
+
msg["tool_calls"] = calls
|
|
62
|
+
if fields.get("message.tool_call_id"):
|
|
63
|
+
msg["tool_call_id"] = fields["message.tool_call_id"]
|
|
64
|
+
return msg
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _messages(attrs: dict, prefix: str) -> list[dict]:
|
|
68
|
+
by_i = _indexed(attrs, prefix)
|
|
69
|
+
return canonical([_message(by_i[i]) for i in sorted(by_i)])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OpenInferenceSpanExporter(_base_exporter()):
|
|
73
|
+
"""Writes OpenInference OTel spans into Touchstone's SQLite, one episode per trace."""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._episodes: dict[int, str] = {}
|
|
77
|
+
self._span_ids: dict[int, str] = {}
|
|
78
|
+
|
|
79
|
+
def export(self, spans):
|
|
80
|
+
for span in spans:
|
|
81
|
+
try:
|
|
82
|
+
self._ingest(span)
|
|
83
|
+
except Exception: # ingest must never break the app's tracer pipeline
|
|
84
|
+
pass
|
|
85
|
+
return _success()
|
|
86
|
+
|
|
87
|
+
def shutdown(self):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def _episode_for(self, conn, span) -> str:
|
|
94
|
+
trace_id = span.context.trace_id
|
|
95
|
+
ep_id = self._episodes.get(trace_id)
|
|
96
|
+
if ep_id is None:
|
|
97
|
+
name = span.name if span.parent is None else "openinference"
|
|
98
|
+
ep_id = store.insert_episode(
|
|
99
|
+
conn, store.Episode(name=name, source="openinference")).id
|
|
100
|
+
self._episodes[trace_id] = ep_id
|
|
101
|
+
return ep_id
|
|
102
|
+
|
|
103
|
+
def _parent_id(self, span) -> str | None:
|
|
104
|
+
return self._span_ids.get(span.parent.span_id) if span.parent else None
|
|
105
|
+
|
|
106
|
+
def _ingest(self, span) -> None:
|
|
107
|
+
attrs = dict(span.attributes or {})
|
|
108
|
+
kind = attrs.get("openinference.span.kind")
|
|
109
|
+
conn = context.get_conn()
|
|
110
|
+
ep_id = self._episode_for(conn, span)
|
|
111
|
+
if kind == "LLM":
|
|
112
|
+
record = self._llm(ep_id, span, attrs)
|
|
113
|
+
elif kind == "TOOL":
|
|
114
|
+
record = self._tool(ep_id, span, attrs)
|
|
115
|
+
else:
|
|
116
|
+
return # AGENT / CHAIN / RETRIEVER only seed the episode
|
|
117
|
+
self._span_ids[span.context.span_id] = store.insert_span(conn, record).id
|
|
118
|
+
|
|
119
|
+
def _llm(self, ep_id, span, attrs) -> store.Span:
|
|
120
|
+
outputs = _messages(attrs, "llm.output_messages")
|
|
121
|
+
reply = outputs[-1] if outputs else {"role": "assistant", "content": ""}
|
|
122
|
+
usage = {}
|
|
123
|
+
if attrs.get("llm.token_count.prompt_details.cache_read") is not None:
|
|
124
|
+
usage["cached_tokens"] = attrs["llm.token_count.prompt_details.cache_read"]
|
|
125
|
+
output = {"message": reply}
|
|
126
|
+
if usage:
|
|
127
|
+
output["usage"] = usage
|
|
128
|
+
return store.Span(
|
|
129
|
+
episode_id=ep_id, kind="model", name=span.name, parent_id=self._parent_id(span),
|
|
130
|
+
model=attrs.get("llm.model_name"),
|
|
131
|
+
input={"messages": _messages(attrs, "llm.input_messages"), "tools": [], "params": {}},
|
|
132
|
+
output=output,
|
|
133
|
+
tokens_in=attrs.get("llm.token_count.prompt"),
|
|
134
|
+
tokens_out=attrs.get("llm.token_count.completion"),
|
|
135
|
+
started_at=_iso(span.start_time), ended_at=_iso(span.end_time))
|
|
136
|
+
|
|
137
|
+
def _tool(self, ep_id, span, attrs) -> store.Span:
|
|
138
|
+
name = attrs.get("tool.name") or span.name
|
|
139
|
+
return store.Span(
|
|
140
|
+
episode_id=ep_id, kind="tool", name=name, parent_id=self._parent_id(span),
|
|
141
|
+
input={"name": name, "arguments": attrs.get("tool.parameters")},
|
|
142
|
+
output={"result": attrs.get("output.value")},
|
|
143
|
+
tool_call_id=attrs.get("tool_call.id"),
|
|
144
|
+
started_at=_iso(span.start_time), ended_at=_iso(span.end_time))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _success():
|
|
148
|
+
try:
|
|
149
|
+
from opentelemetry.sdk.trace.export import SpanExportResult
|
|
150
|
+
|
|
151
|
+
return SpanExportResult.SUCCESS
|
|
152
|
+
except Exception:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def register() -> bool:
|
|
157
|
+
"""Register the exporter on the global TracerProvider. False if opentelemetry-sdk is absent."""
|
|
158
|
+
try:
|
|
159
|
+
from opentelemetry import trace as ot
|
|
160
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
161
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
162
|
+
except Exception:
|
|
163
|
+
return False
|
|
164
|
+
provider = ot.get_tracer_provider()
|
|
165
|
+
if not isinstance(provider, TracerProvider):
|
|
166
|
+
provider = TracerProvider()
|
|
167
|
+
ot.set_tracer_provider(provider)
|
|
168
|
+
provider.add_span_processor(SimpleSpanProcessor(OpenInferenceSpanExporter()))
|
|
169
|
+
return True
|