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.
Files changed (121) hide show
  1. touchstone/__init__.py +106 -0
  2. touchstone/capture/__init__.py +21 -0
  3. touchstone/capture/context.py +241 -0
  4. touchstone/capture/litellm.py +95 -0
  5. touchstone/capture/openinference.py +169 -0
  6. touchstone/capture/patch_anthropic.py +215 -0
  7. touchstone/capture/patch_openai.py +232 -0
  8. touchstone/capture/pricing.py +40 -0
  9. touchstone/capture/spans.py +223 -0
  10. touchstone/cli/__init__.py +199 -0
  11. touchstone/cli/_common.py +52 -0
  12. touchstone/cli/bench.py +81 -0
  13. touchstone/cli/review.py +52 -0
  14. touchstone/cli/survey.py +47 -0
  15. touchstone/cli/train.py +87 -0
  16. touchstone/config.py +146 -0
  17. touchstone/demo.py +115 -0
  18. touchstone/harbor/__init__.py +2 -0
  19. touchstone/harbor/acp_server.py +160 -0
  20. touchstone/harbor/agent.py +288 -0
  21. touchstone/harbor/atif.py +308 -0
  22. touchstone/harbor/atif_import.py +77 -0
  23. touchstone/harbor/conversation.py +113 -0
  24. touchstone/harbor/dataset.py +69 -0
  25. touchstone/harbor/jobs.py +130 -0
  26. touchstone/harbor/keys.py +21 -0
  27. touchstone/harbor/remote.py +73 -0
  28. touchstone/harbor/rewardkit.py +116 -0
  29. touchstone/harbor/run.py +295 -0
  30. touchstone/ids.py +31 -0
  31. touchstone/interview/__init__.py +17 -0
  32. touchstone/interview/realtime.py +301 -0
  33. touchstone/interview/rooms.py +73 -0
  34. touchstone/interview/speech.py +304 -0
  35. touchstone/llm/__init__.py +19 -0
  36. touchstone/llm/_cli.py +90 -0
  37. touchstone/llm/_http.py +135 -0
  38. touchstone/llm/anthropic.py +139 -0
  39. touchstone/llm/base.py +33 -0
  40. touchstone/llm/claude_cli.py +71 -0
  41. touchstone/llm/codex_cli.py +65 -0
  42. touchstone/llm/keychain.py +43 -0
  43. touchstone/llm/nop.py +23 -0
  44. touchstone/llm/openai_compat.py +112 -0
  45. touchstone/llm/prompt.py +181 -0
  46. touchstone/llm/reference.py +31 -0
  47. touchstone/llm/registry.py +177 -0
  48. touchstone/llm/scripted.py +74 -0
  49. touchstone/messages.py +319 -0
  50. touchstone/messages_wire.py +139 -0
  51. touchstone/overview.py +24 -0
  52. touchstone/review/__init__.py +7 -0
  53. touchstone/review/agent.py +316 -0
  54. touchstone/review/changes.py +291 -0
  55. touchstone/review/facts.py +122 -0
  56. touchstone/review/prompt.py +84 -0
  57. touchstone/review/readback.py +51 -0
  58. touchstone/review/regrade.py +71 -0
  59. touchstone/review/replies.py +107 -0
  60. touchstone/review/scratch.py +20 -0
  61. touchstone/review/snapshot.py +20 -0
  62. touchstone/review/trials.py +316 -0
  63. touchstone/server/__init__.py +3 -0
  64. touchstone/server/app.py +108 -0
  65. touchstone/server/pagedata.py +68 -0
  66. touchstone/server/pages.py +304 -0
  67. touchstone/server/routes/__init__.py +12 -0
  68. touchstone/server/routes/_deps.py +31 -0
  69. touchstone/server/routes/episodes.py +33 -0
  70. touchstone/server/routes/overview.py +27 -0
  71. touchstone/server/routes/pages.py +57 -0
  72. touchstone/server/routes/rooms.py +309 -0
  73. touchstone/server/static/app.css +345 -0
  74. touchstone/server/static/app.js +377 -0
  75. touchstone/server/static/index.html +24 -0
  76. touchstone/server/static/room.html +62 -0
  77. touchstone/server/static/room.js +342 -0
  78. touchstone/store.py +294 -0
  79. touchstone/store_models.py +78 -0
  80. touchstone/survey/__init__.py +5 -0
  81. touchstone/survey/baseline.py +93 -0
  82. touchstone/survey/benchmark.py +161 -0
  83. touchstone/survey/criteria.py +251 -0
  84. touchstone/survey/descriptions.py +118 -0
  85. touchstone/survey/environment.py +255 -0
  86. touchstone/survey/envs.py +35 -0
  87. touchstone/survey/fidelity.py +294 -0
  88. touchstone/survey/fidelity_db.py +42 -0
  89. touchstone/survey/fidelity_mask.py +48 -0
  90. touchstone/survey/gate.py +138 -0
  91. touchstone/survey/group.py +199 -0
  92. touchstone/survey/invoke.py +139 -0
  93. touchstone/survey/map.py +141 -0
  94. touchstone/survey/minted_ids.py +73 -0
  95. touchstone/survey/netshim.py +114 -0
  96. touchstone/survey/package.py +145 -0
  97. touchstone/survey/package_entry.py +195 -0
  98. touchstone/survey/package_spans.py +49 -0
  99. touchstone/survey/provider.py +98 -0
  100. touchstone/survey/recordings.py +114 -0
  101. touchstone/survey/replay.py +151 -0
  102. touchstone/survey/report.py +230 -0
  103. touchstone/survey/scrub.py +64 -0
  104. touchstone/survey/simulate.py +308 -0
  105. touchstone/survey/sort.py +24 -0
  106. touchstone/survey/subproc.py +52 -0
  107. touchstone/survey/survey.py +180 -0
  108. touchstone/survey/task_files.py +154 -0
  109. touchstone/survey/task_text.py +146 -0
  110. touchstone/survey/tasks.py +271 -0
  111. touchstone/survey/tool_reads.py +55 -0
  112. touchstone/survey/writes.py +25 -0
  113. touchstone/train/__init__.py +5 -0
  114. touchstone/train/datasets.py +121 -0
  115. touchstone/train/plugin.py +52 -0
  116. touchstone/train/write.py +113 -0
  117. touchstone_bench-0.1.0.dist-info/METADATA +170 -0
  118. touchstone_bench-0.1.0.dist-info/RECORD +121 -0
  119. touchstone_bench-0.1.0.dist-info/WHEEL +4 -0
  120. touchstone_bench-0.1.0.dist-info/entry_points.txt +2 -0
  121. 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