spanlight 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.
- spanlight/__init__.py +59 -0
- spanlight/_client.py +459 -0
- spanlight/_config.py +138 -0
- spanlight/_context.py +79 -0
- spanlight/_decorator.py +345 -0
- spanlight/_experiments.py +256 -0
- spanlight/_experiments_api.py +182 -0
- spanlight/_experiments_parse.py +68 -0
- spanlight/_experiments_report.py +100 -0
- spanlight/_experiments_runs.py +122 -0
- spanlight/_experiments_setup.py +85 -0
- spanlight/_exporter.py +462 -0
- spanlight/_globals.py +198 -0
- spanlight/_ids.py +45 -0
- spanlight/_prompt_client.py +241 -0
- spanlight/_prompts.py +247 -0
- spanlight/_request_hash.py +58 -0
- spanlight/_scores.py +57 -0
- spanlight/_serialize.py +126 -0
- spanlight/_span.py +337 -0
- spanlight/_templates.py +238 -0
- spanlight/_version.py +3 -0
- spanlight/integrations/__init__.py +5 -0
- spanlight/integrations/_common.py +422 -0
- spanlight/integrations/anthropic.py +349 -0
- spanlight/integrations/openai.py +356 -0
- spanlight/py.typed +0 -0
- spanlight-0.1.0.dist-info/METADATA +447 -0
- spanlight-0.1.0.dist-info/RECORD +31 -0
- spanlight-0.1.0.dist-info/WHEEL +4 -0
- spanlight-0.1.0.dist-info/licenses/LICENSE +21 -0
spanlight/_serialize.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Best-effort conversion of arbitrary Python values into JSON-compatible data.
|
|
2
|
+
|
|
3
|
+
Captured inputs and outputs can be anything: pydantic models returned by
|
|
4
|
+
provider SDKs, dataclasses, custom objects, huge strings. This module turns
|
|
5
|
+
them into plain ``dict``/``list``/``str``/number values that ``json.dumps`` can
|
|
6
|
+
always encode, while bounding size and depth so a single span can never
|
|
7
|
+
produce an unreasonably large payload. It never raises.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import enum
|
|
15
|
+
import math
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
MAX_DEPTH = 12
|
|
21
|
+
MAX_STRING_LENGTH = 32_000
|
|
22
|
+
MAX_ITEMS = 1_000
|
|
23
|
+
|
|
24
|
+
_TRUNCATION_MARKER = "...[truncated]"
|
|
25
|
+
|
|
26
|
+
JsonValue = Any
|
|
27
|
+
"""Alias documenting that a value is JSON-compatible (recursive types are awkward)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_jsonable(value: Any) -> JsonValue:
|
|
31
|
+
"""Convert ``value`` into a JSON-compatible structure.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
value: Any Python object.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A value composed only of ``dict``, ``list``, ``str``, ``int``,
|
|
38
|
+
``float``, ``bool`` and ``None``. Unknown objects are rendered with
|
|
39
|
+
``repr``. Oversized strings and collections are truncated.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
return _convert(value, depth=0)
|
|
43
|
+
except Exception:
|
|
44
|
+
return _truncate_string(_safe_repr(value))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _convert(value: Any, depth: int) -> JsonValue:
|
|
48
|
+
if value is None or isinstance(value, bool | int):
|
|
49
|
+
return value
|
|
50
|
+
if isinstance(value, float):
|
|
51
|
+
return value if math.isfinite(value) else str(value)
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return _truncate_string(value)
|
|
54
|
+
if depth >= MAX_DEPTH:
|
|
55
|
+
return "[max depth reached]"
|
|
56
|
+
|
|
57
|
+
if isinstance(value, Mapping):
|
|
58
|
+
return _convert_mapping(value, depth)
|
|
59
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
60
|
+
return _convert_sequence(list(value), depth)
|
|
61
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
62
|
+
return f"<{len(value)} bytes>"
|
|
63
|
+
if isinstance(value, enum.Enum):
|
|
64
|
+
return _convert(value.value, depth + 1)
|
|
65
|
+
if isinstance(value, dt.datetime | dt.date | dt.time):
|
|
66
|
+
return value.isoformat()
|
|
67
|
+
if isinstance(value, uuid.UUID):
|
|
68
|
+
return str(value)
|
|
69
|
+
if isinstance(value, BaseException):
|
|
70
|
+
return {"type": type(value).__name__, "message": _truncate_string(str(value))}
|
|
71
|
+
|
|
72
|
+
dumped = _dump_model(value)
|
|
73
|
+
if dumped is not None:
|
|
74
|
+
return _convert(dumped, depth + 1)
|
|
75
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
76
|
+
fields = {field.name: getattr(value, field.name) for field in dataclasses.fields(value)}
|
|
77
|
+
return _convert_mapping(fields, depth)
|
|
78
|
+
|
|
79
|
+
return _truncate_string(_safe_repr(value))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _convert_mapping(value: Mapping[Any, Any], depth: int) -> dict[str, JsonValue]:
|
|
83
|
+
result: dict[str, JsonValue] = {}
|
|
84
|
+
for index, (key, item) in enumerate(value.items()):
|
|
85
|
+
if index >= MAX_ITEMS:
|
|
86
|
+
result["..."] = f"{len(value) - MAX_ITEMS} more keys truncated"
|
|
87
|
+
break
|
|
88
|
+
result[str(key)] = _convert(item, depth + 1)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _convert_sequence(items: list[Any], depth: int) -> list[JsonValue]:
|
|
93
|
+
converted = [_convert(item, depth + 1) for item in items[:MAX_ITEMS]]
|
|
94
|
+
if len(items) > MAX_ITEMS:
|
|
95
|
+
converted.append(f"... {len(items) - MAX_ITEMS} more items truncated")
|
|
96
|
+
return converted
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dump_model(value: Any) -> Any | None:
|
|
100
|
+
"""Return a plain representation of pydantic (v2 or v1) models, else ``None``."""
|
|
101
|
+
model_dump = getattr(value, "model_dump", None)
|
|
102
|
+
if callable(model_dump):
|
|
103
|
+
try:
|
|
104
|
+
return model_dump(mode="json", exclude_none=True)
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
legacy_dict = getattr(value, "dict", None)
|
|
108
|
+
if callable(legacy_dict) and hasattr(value, "__fields__"):
|
|
109
|
+
try:
|
|
110
|
+
return legacy_dict()
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _truncate_string(value: str) -> str:
|
|
117
|
+
if len(value) <= MAX_STRING_LENGTH:
|
|
118
|
+
return value
|
|
119
|
+
return value[:MAX_STRING_LENGTH] + _TRUNCATION_MARKER
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _safe_repr(value: Any) -> str:
|
|
123
|
+
try:
|
|
124
|
+
return repr(value)
|
|
125
|
+
except Exception:
|
|
126
|
+
return f"<unrepresentable {type(value).__name__}>"
|
spanlight/_span.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Span and trace data structures.
|
|
2
|
+
|
|
3
|
+
A :class:`Span` is a timed unit of work (an LLM call, a tool invocation, a
|
|
4
|
+
whole request). Spans that share a :class:`TraceState` form one trace; the
|
|
5
|
+
trace state carries trace-level fields such as ``user_id`` and ``session_id``
|
|
6
|
+
that are attached to every span when it is exported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
18
|
+
from typing import Any, Literal, get_args
|
|
19
|
+
|
|
20
|
+
from ._ids import new_span_id, new_trace_id
|
|
21
|
+
from ._serialize import JsonValue, to_jsonable
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("spanlight")
|
|
24
|
+
|
|
25
|
+
SpanKind = Literal["llm", "tool", "retrieval", "chain", "http", "other"]
|
|
26
|
+
SpanStatus = Literal["ok", "error", "unset"]
|
|
27
|
+
|
|
28
|
+
_VALID_KINDS: frozenset[str] = frozenset(get_args(SpanKind))
|
|
29
|
+
_MAX_STATUS_MESSAGE = 2_000
|
|
30
|
+
_MAX_FINISH_REASON = 64
|
|
31
|
+
_REQUEST_HASH_PATTERN = re.compile(r"[0-9a-f]{32}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_kind(kind: str) -> SpanKind:
|
|
35
|
+
"""Return ``kind`` if it is a known span kind, otherwise ``"other"``."""
|
|
36
|
+
if kind in _VALID_KINDS:
|
|
37
|
+
return kind # type: ignore[return-value]
|
|
38
|
+
logger.debug("Unknown span kind %r; using 'other'", kind)
|
|
39
|
+
return "other"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _isoformat_ns(epoch_ns: int) -> str:
|
|
43
|
+
seconds, nanos = divmod(epoch_ns, 1_000_000_000)
|
|
44
|
+
moment = dt.datetime.fromtimestamp(seconds, tz=dt.timezone.utc)
|
|
45
|
+
moment = moment.replace(microsecond=nanos // 1_000)
|
|
46
|
+
return moment.isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TraceState:
|
|
50
|
+
"""Mutable trace-level fields shared by every span of one trace.
|
|
51
|
+
|
|
52
|
+
Instances are shared by reference between parent and child spans
|
|
53
|
+
(including children running in other asyncio tasks), so an
|
|
54
|
+
:func:`~spanlight.update_trace` call anywhere in the trace is
|
|
55
|
+
visible to every span exported afterwards.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
trace_id: str | None = None,
|
|
62
|
+
name: str | None = None,
|
|
63
|
+
environment: str | None = None,
|
|
64
|
+
release: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.trace_id = trace_id or new_trace_id()
|
|
67
|
+
self.name = name
|
|
68
|
+
self.environment = environment
|
|
69
|
+
self.release = release
|
|
70
|
+
self.user_id: str | None = None
|
|
71
|
+
self.session_id: str | None = None
|
|
72
|
+
self.tags: list[str] = []
|
|
73
|
+
self._lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
def update(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
user_id: str | None = None,
|
|
80
|
+
session_id: str | None = None,
|
|
81
|
+
tags: Iterable[str] | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Set trace-level fields. ``None`` leaves a field unchanged; tags accumulate."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
if name is not None:
|
|
86
|
+
self.name = name
|
|
87
|
+
if user_id is not None:
|
|
88
|
+
self.user_id = user_id
|
|
89
|
+
if session_id is not None:
|
|
90
|
+
self.session_id = session_id
|
|
91
|
+
if tags is not None:
|
|
92
|
+
for tag in tags:
|
|
93
|
+
if tag not in self.tags:
|
|
94
|
+
self.tags.append(str(tag))
|
|
95
|
+
|
|
96
|
+
def to_payload(self) -> dict[str, JsonValue]:
|
|
97
|
+
"""Return the ``trace`` object of the ingestion payload."""
|
|
98
|
+
with self._lock:
|
|
99
|
+
return {
|
|
100
|
+
"name": self.name,
|
|
101
|
+
"environment": self.environment,
|
|
102
|
+
"release": self.release,
|
|
103
|
+
"user_id": self.user_id,
|
|
104
|
+
"session_id": self.session_id,
|
|
105
|
+
"tags": list(self.tags),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Span:
|
|
110
|
+
"""A single timed operation within a trace.
|
|
111
|
+
|
|
112
|
+
Spans are created by :meth:`Spanlight.span <spanlight.Spanlight.span>`,
|
|
113
|
+
the :func:`~spanlight.observe` decorator and the provider
|
|
114
|
+
wrappers; you normally receive one rather than constructing it. All
|
|
115
|
+
setters are safe to call at any time and never raise. Calls made after
|
|
116
|
+
the span has ended are ignored.
|
|
117
|
+
|
|
118
|
+
A span created while the SDK is disabled is *non-recording*: every method
|
|
119
|
+
is a cheap no-op.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
name: str,
|
|
126
|
+
kind: SpanKind,
|
|
127
|
+
trace: TraceState,
|
|
128
|
+
parent_span_id: str | None,
|
|
129
|
+
on_end: Callable[[Span], None] | None,
|
|
130
|
+
recording: bool = True,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.name = name
|
|
133
|
+
self.kind = kind
|
|
134
|
+
self.trace = trace
|
|
135
|
+
self.span_id = new_span_id()
|
|
136
|
+
self.parent_span_id = parent_span_id
|
|
137
|
+
self.recording = recording
|
|
138
|
+
|
|
139
|
+
self.status: SpanStatus = "unset"
|
|
140
|
+
self.status_message: str | None = None
|
|
141
|
+
self.provider: str | None = None
|
|
142
|
+
self.model: str | None = None
|
|
143
|
+
self.input_tokens: int | None = None
|
|
144
|
+
self.output_tokens: int | None = None
|
|
145
|
+
self.cached_tokens: int | None = None
|
|
146
|
+
self.time_to_first_token_ms: float | None = None
|
|
147
|
+
self.request_hash: str | None = None
|
|
148
|
+
self.finish_reason: str | None = None
|
|
149
|
+
self.prompt_version_id: str | None = None
|
|
150
|
+
self.input: JsonValue = None
|
|
151
|
+
self.output: JsonValue = None
|
|
152
|
+
self.attributes: dict[str, JsonValue] = {}
|
|
153
|
+
|
|
154
|
+
self._on_end = on_end
|
|
155
|
+
self._start_epoch_ns = time.time_ns()
|
|
156
|
+
self._start_perf_ns = time.perf_counter_ns()
|
|
157
|
+
self._end_epoch_ns: int | None = None
|
|
158
|
+
self._end_lock = threading.Lock()
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def trace_id(self) -> str:
|
|
162
|
+
"""The 32-hex-character id of the trace this span belongs to."""
|
|
163
|
+
return self.trace.trace_id
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def is_ended(self) -> bool:
|
|
167
|
+
"""Whether :meth:`end` has already been called."""
|
|
168
|
+
return self._end_epoch_ns is not None
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def _mutable(self) -> bool:
|
|
172
|
+
return self.recording and not self.is_ended
|
|
173
|
+
|
|
174
|
+
def set_input(self, value: Any) -> None:
|
|
175
|
+
"""Record the operation's input (any JSON-serializable-ish value)."""
|
|
176
|
+
if self._mutable:
|
|
177
|
+
self.input = to_jsonable(value)
|
|
178
|
+
|
|
179
|
+
def set_output(self, value: Any) -> None:
|
|
180
|
+
"""Record the operation's output (any JSON-serializable-ish value)."""
|
|
181
|
+
if self._mutable:
|
|
182
|
+
self.output = to_jsonable(value)
|
|
183
|
+
|
|
184
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
185
|
+
"""Attach a single custom attribute."""
|
|
186
|
+
if self._mutable:
|
|
187
|
+
self.attributes[str(key)] = to_jsonable(value)
|
|
188
|
+
|
|
189
|
+
def set_attributes(self, attributes: Mapping[str, Any]) -> None:
|
|
190
|
+
"""Attach several custom attributes at once."""
|
|
191
|
+
for key, value in attributes.items():
|
|
192
|
+
self.set_attribute(key, value)
|
|
193
|
+
|
|
194
|
+
def set_model(self, model: str | None, *, provider: str | None = None) -> None:
|
|
195
|
+
"""Record the model (and optionally the provider) that served the call."""
|
|
196
|
+
if not self._mutable:
|
|
197
|
+
return
|
|
198
|
+
if model:
|
|
199
|
+
self.model = str(model)
|
|
200
|
+
if provider:
|
|
201
|
+
self.provider = str(provider)
|
|
202
|
+
|
|
203
|
+
def set_usage(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
input_tokens: int | None = None,
|
|
207
|
+
output_tokens: int | None = None,
|
|
208
|
+
cached_tokens: int | None = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Record token usage. ``None`` leaves the existing value unchanged.
|
|
211
|
+
|
|
212
|
+
``input_tokens`` is the *total* prompt size, including any cached
|
|
213
|
+
tokens; ``cached_tokens`` is the subset served from the prompt cache.
|
|
214
|
+
"""
|
|
215
|
+
if not self._mutable:
|
|
216
|
+
return
|
|
217
|
+
if input_tokens is not None:
|
|
218
|
+
self.input_tokens = int(input_tokens)
|
|
219
|
+
if output_tokens is not None:
|
|
220
|
+
self.output_tokens = int(output_tokens)
|
|
221
|
+
if cached_tokens is not None:
|
|
222
|
+
self.cached_tokens = int(cached_tokens)
|
|
223
|
+
|
|
224
|
+
def set_request_hash(self, value: str | None) -> None:
|
|
225
|
+
"""Record the request hash used to group identical calls.
|
|
226
|
+
|
|
227
|
+
Provider wrappers set this automatically. A value that is not 32
|
|
228
|
+
lowercase hex characters is ignored, because the server would reject
|
|
229
|
+
the whole batch.
|
|
230
|
+
"""
|
|
231
|
+
if not self._mutable:
|
|
232
|
+
return
|
|
233
|
+
if value is None or (isinstance(value, str) and _REQUEST_HASH_PATTERN.fullmatch(value)):
|
|
234
|
+
self.request_hash = value
|
|
235
|
+
else:
|
|
236
|
+
logger.debug("Ignoring malformed request hash %r", value)
|
|
237
|
+
|
|
238
|
+
def set_finish_reason(self, reason: str | None) -> None:
|
|
239
|
+
"""Record why generation stopped, exactly as the provider reports it."""
|
|
240
|
+
if self._mutable and (reason is None or isinstance(reason, str)):
|
|
241
|
+
self.finish_reason = reason[:_MAX_FINISH_REASON] if reason else None
|
|
242
|
+
|
|
243
|
+
def set_prompt_version(self, version_id: str | None) -> None:
|
|
244
|
+
"""Link the span to the prompt version it used; ``None`` removes the link.
|
|
245
|
+
|
|
246
|
+
:class:`~spanlight.CompiledPrompt` does this for you. A value that is
|
|
247
|
+
not a UUID is ignored, because the server would reject the span.
|
|
248
|
+
"""
|
|
249
|
+
if not self._mutable:
|
|
250
|
+
return
|
|
251
|
+
if version_id is None:
|
|
252
|
+
self.prompt_version_id = None
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
self.prompt_version_id = str(uuid.UUID(str(version_id)))
|
|
256
|
+
except ValueError:
|
|
257
|
+
logger.debug("Ignoring malformed prompt version id %r", version_id)
|
|
258
|
+
|
|
259
|
+
def mark_first_token(self) -> None:
|
|
260
|
+
"""Record time-to-first-token as "now"; only the first call counts."""
|
|
261
|
+
if self._mutable and self.time_to_first_token_ms is None:
|
|
262
|
+
elapsed_ns = time.perf_counter_ns() - self._start_perf_ns
|
|
263
|
+
self.time_to_first_token_ms = elapsed_ns / 1_000_000
|
|
264
|
+
|
|
265
|
+
def record_error(self, error: BaseException | str) -> None:
|
|
266
|
+
"""Mark the span as failed.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
error: The exception that occurred, or a human-readable message.
|
|
270
|
+
"""
|
|
271
|
+
if not self._mutable:
|
|
272
|
+
return
|
|
273
|
+
self.status = "error"
|
|
274
|
+
if isinstance(error, BaseException):
|
|
275
|
+
message = str(error) or type(error).__name__
|
|
276
|
+
self.attributes["error.type"] = type(error).__name__
|
|
277
|
+
else:
|
|
278
|
+
message = error
|
|
279
|
+
self.status_message = message[:_MAX_STATUS_MESSAGE]
|
|
280
|
+
|
|
281
|
+
def end(self) -> None:
|
|
282
|
+
"""Finish the span and hand it to the exporter. Idempotent."""
|
|
283
|
+
with self._end_lock:
|
|
284
|
+
if self._end_epoch_ns is not None:
|
|
285
|
+
return
|
|
286
|
+
elapsed_ns = time.perf_counter_ns() - self._start_perf_ns
|
|
287
|
+
self._end_epoch_ns = self._start_epoch_ns + elapsed_ns
|
|
288
|
+
if self.status == "unset":
|
|
289
|
+
self.status = "ok"
|
|
290
|
+
if self.recording and self._on_end is not None:
|
|
291
|
+
try:
|
|
292
|
+
self._on_end(self)
|
|
293
|
+
except Exception:
|
|
294
|
+
logger.debug("Failed to export span %s", self.span_id, exc_info=True)
|
|
295
|
+
|
|
296
|
+
def to_payload(self) -> dict[str, JsonValue]:
|
|
297
|
+
"""Serialize the span into the ``POST /v1/traces`` span schema."""
|
|
298
|
+
end_ns = self._end_epoch_ns if self._end_epoch_ns is not None else time.time_ns()
|
|
299
|
+
usage: dict[str, int] | None = None
|
|
300
|
+
token_counts = {
|
|
301
|
+
"input_tokens": self.input_tokens,
|
|
302
|
+
"output_tokens": self.output_tokens,
|
|
303
|
+
"cached_tokens": self.cached_tokens,
|
|
304
|
+
}
|
|
305
|
+
if any(count is not None for count in token_counts.values()):
|
|
306
|
+
usage = {key: count for key, count in token_counts.items() if count is not None}
|
|
307
|
+
payload: dict[str, JsonValue] = {
|
|
308
|
+
"trace_id": self.trace_id,
|
|
309
|
+
"span_id": self.span_id,
|
|
310
|
+
"parent_span_id": self.parent_span_id,
|
|
311
|
+
"name": self.name,
|
|
312
|
+
"kind": self.kind,
|
|
313
|
+
"status": self.status,
|
|
314
|
+
"status_message": self.status_message,
|
|
315
|
+
"start_time": _isoformat_ns(self._start_epoch_ns),
|
|
316
|
+
"end_time": _isoformat_ns(end_ns),
|
|
317
|
+
"provider": self.provider,
|
|
318
|
+
"model": self.model,
|
|
319
|
+
"usage": usage,
|
|
320
|
+
"time_to_first_token_ms": self.time_to_first_token_ms,
|
|
321
|
+
"request_hash": self.request_hash,
|
|
322
|
+
"finish_reason": self.finish_reason,
|
|
323
|
+
"input": self.input,
|
|
324
|
+
"output": self.output,
|
|
325
|
+
"attributes": dict(self.attributes),
|
|
326
|
+
"trace": self.trace.to_payload(),
|
|
327
|
+
}
|
|
328
|
+
# Sent only when set, so payloads for servers that predate prompts stay unchanged.
|
|
329
|
+
if self.prompt_version_id is not None:
|
|
330
|
+
payload["prompt_version_id"] = self.prompt_version_id
|
|
331
|
+
return payload
|
|
332
|
+
|
|
333
|
+
def __repr__(self) -> str:
|
|
334
|
+
return (
|
|
335
|
+
f"Span(name={self.name!r}, kind={self.kind!r}, trace_id={self.trace_id!r}, "
|
|
336
|
+
f"span_id={self.span_id!r}, status={self.status!r})"
|
|
337
|
+
)
|
spanlight/_templates.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Prompt template compiler: ``{{name}}`` substitution, pure and engine-free.
|
|
2
|
+
|
|
3
|
+
These are the same rules the Spanlight server and dashboard apply, checked against one shared
|
|
4
|
+
fixture file. A variable is ``{{name}}`` with optional spaces or tabs inside the braces; values
|
|
5
|
+
are inserted verbatim and never re-expanded. There are no sections, escapes or partials. The
|
|
6
|
+
rendered output is capped at 256 KB (UTF-8, the text or all messages together).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
MAX_TEMPLATE_BYTES = 64 * 1024
|
|
17
|
+
MAX_CHAT_MESSAGES = 50
|
|
18
|
+
# Bound on the rendered output (text, or the sum of every message), in UTF-8 bytes. Without it a
|
|
19
|
+
# small template that repeats a large variable could render hundreds of megabytes.
|
|
20
|
+
MAX_COMPILED_BYTES = 256 * 1024
|
|
21
|
+
|
|
22
|
+
Role = Literal["system", "user", "assistant"]
|
|
23
|
+
Kind = Literal["text", "chat"]
|
|
24
|
+
|
|
25
|
+
_ROLES: frozenset[str] = frozenset({"system", "user", "assistant"})
|
|
26
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}")
|
|
27
|
+
# A closed `{{ ... }}` on one line (inner text may be anything), or a lone `{{` that never closes.
|
|
28
|
+
_TOKEN = re.compile(r"\{\{(?P<inner>[^\n]*?)\}\}|\{\{")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InvalidTemplateError(ValueError):
|
|
32
|
+
"""The template is malformed (bad braces, bad name, wrong shape or too large)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MissingVariablesError(ValueError):
|
|
36
|
+
"""``compile`` was called without every variable the template uses.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
names: Every missing variable name, sorted.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, names: tuple[str, ...]) -> None:
|
|
43
|
+
super().__init__(f"missing variables: {', '.join(names)}")
|
|
44
|
+
self.names = names
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TemplateTooLargeError(ValueError):
|
|
48
|
+
"""The rendered output would exceed ``MAX_COMPILED_BYTES`` (256 KB) with the variables in."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
super().__init__(f"compiled prompt exceeds {MAX_COMPILED_BYTES} bytes")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Message:
|
|
56
|
+
"""One message of a chat template."""
|
|
57
|
+
|
|
58
|
+
role: Role
|
|
59
|
+
content: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Template:
|
|
64
|
+
"""A text template (``text``) or a chat template (``messages``)."""
|
|
65
|
+
|
|
66
|
+
kind: Kind
|
|
67
|
+
text: str | None = None
|
|
68
|
+
messages: tuple[Message, ...] | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class RenderedTemplate:
|
|
73
|
+
"""The result of :func:`compile`."""
|
|
74
|
+
|
|
75
|
+
kind: Kind
|
|
76
|
+
text: str | None
|
|
77
|
+
messages: tuple[Message, ...] | None
|
|
78
|
+
variables_used: tuple[str, ...]
|
|
79
|
+
unused_variables: tuple[str, ...]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# A scanned piece of a template: literal text, or a variable reference when `name` is set.
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class _Part:
|
|
85
|
+
literal: str
|
|
86
|
+
name: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _position(source: str, offset: int) -> str:
|
|
90
|
+
line = source.count("\n", 0, offset) + 1
|
|
91
|
+
column = offset - (source.rfind("\n", 0, offset) + 1) + 1
|
|
92
|
+
return f"line {line}, column {column}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _scan(source: str) -> list[_Part]:
|
|
96
|
+
parts: list[_Part] = []
|
|
97
|
+
cursor = 0
|
|
98
|
+
for match in _TOKEN.finditer(source):
|
|
99
|
+
if match.start() > cursor:
|
|
100
|
+
parts.append(_Part(source[cursor : match.start()]))
|
|
101
|
+
inner = match.group("inner")
|
|
102
|
+
if inner is None:
|
|
103
|
+
raise InvalidTemplateError(f"unclosed '{{{{' at {_position(source, match.start())}")
|
|
104
|
+
name = inner.strip(" \t")
|
|
105
|
+
if not _NAME.fullmatch(name):
|
|
106
|
+
raise InvalidTemplateError(
|
|
107
|
+
f"invalid variable name at {_position(source, match.start())}"
|
|
108
|
+
)
|
|
109
|
+
parts.append(_Part("", name))
|
|
110
|
+
cursor = match.end()
|
|
111
|
+
if cursor < len(source):
|
|
112
|
+
parts.append(_Part(source[cursor:]))
|
|
113
|
+
return parts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sources(template: Template) -> list[str]:
|
|
117
|
+
"""Validate the template's shape and bounds, and return its text blocks in order."""
|
|
118
|
+
if template.kind == "text":
|
|
119
|
+
if template.text is None or template.messages is not None:
|
|
120
|
+
raise InvalidTemplateError("a text template needs `text` and no `messages`")
|
|
121
|
+
blocks = [template.text]
|
|
122
|
+
elif template.kind == "chat":
|
|
123
|
+
if template.messages is None or template.text is not None:
|
|
124
|
+
raise InvalidTemplateError("a chat template needs `messages` and no `text`")
|
|
125
|
+
if not 1 <= len(template.messages) <= MAX_CHAT_MESSAGES:
|
|
126
|
+
raise InvalidTemplateError(f"a chat template needs 1 to {MAX_CHAT_MESSAGES} messages")
|
|
127
|
+
blocks = [message.content for message in template.messages]
|
|
128
|
+
else:
|
|
129
|
+
raise InvalidTemplateError(f"unknown template kind: {template.kind!r}")
|
|
130
|
+
if sum(len(block.encode("utf-8")) for block in blocks) > MAX_TEMPLATE_BYTES:
|
|
131
|
+
raise InvalidTemplateError(f"template exceeds {MAX_TEMPLATE_BYTES} bytes")
|
|
132
|
+
return blocks
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ordered_unique(parts_per_block: list[list[_Part]]) -> list[str]:
|
|
136
|
+
seen: dict[str, None] = {}
|
|
137
|
+
for parts in parts_per_block:
|
|
138
|
+
for part in parts:
|
|
139
|
+
if part.name is not None:
|
|
140
|
+
seen.setdefault(part.name)
|
|
141
|
+
return list(seen)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_variables(template: Template) -> tuple[str, ...]:
|
|
145
|
+
"""Variable names in first-appearance order, de-duplicated."""
|
|
146
|
+
return tuple(_ordered_unique([_scan(block) for block in _sources(template)]))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _check_size(parts_per_block: list[list[_Part]], variables: Mapping[str, str]) -> None:
|
|
150
|
+
"""Raise `TemplateTooLargeError` before rendering when the output would pass the cap."""
|
|
151
|
+
sizes: dict[str, int] = {}
|
|
152
|
+
total = 0
|
|
153
|
+
for parts in parts_per_block:
|
|
154
|
+
for part in parts:
|
|
155
|
+
if part.name is None:
|
|
156
|
+
total += len(part.literal.encode("utf-8"))
|
|
157
|
+
else:
|
|
158
|
+
if part.name not in sizes:
|
|
159
|
+
sizes[part.name] = len(variables[part.name].encode("utf-8"))
|
|
160
|
+
total += sizes[part.name]
|
|
161
|
+
if total > MAX_COMPILED_BYTES:
|
|
162
|
+
raise TemplateTooLargeError()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _render(parts: list[_Part], variables: Mapping[str, str]) -> str:
|
|
166
|
+
return "".join(
|
|
167
|
+
variables[part.name] if part.name is not None else part.literal for part in parts
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def compile(template: Template, variables: Mapping[str, str]) -> RenderedTemplate:
|
|
172
|
+
"""Substitute ``variables`` into ``template``.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
InvalidTemplateError: The template is malformed.
|
|
176
|
+
MissingVariablesError: A variable the template uses was not given.
|
|
177
|
+
TemplateTooLargeError: The rendered output would exceed 256 KB (UTF-8, the text or all
|
|
178
|
+
messages together).
|
|
179
|
+
"""
|
|
180
|
+
scanned = [_scan(block) for block in _sources(template)]
|
|
181
|
+
used = _ordered_unique(scanned)
|
|
182
|
+
missing = tuple(sorted(name for name in used if name not in variables))
|
|
183
|
+
if missing:
|
|
184
|
+
raise MissingVariablesError(missing)
|
|
185
|
+
unused = tuple(sorted(name for name in variables if name not in used))
|
|
186
|
+
_check_size(scanned, variables)
|
|
187
|
+
if template.kind == "text":
|
|
188
|
+
return RenderedTemplate("text", _render(scanned[0], variables), None, tuple(used), unused)
|
|
189
|
+
assert template.messages is not None # narrowed by _sources
|
|
190
|
+
messages = tuple(
|
|
191
|
+
Message(message.role, _render(parts, variables))
|
|
192
|
+
for message, parts in zip(template.messages, scanned, strict=True)
|
|
193
|
+
)
|
|
194
|
+
return RenderedTemplate("chat", None, messages, tuple(used), unused)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def template_from_wire(raw: Any) -> Template:
|
|
198
|
+
"""Build a :class:`Template` from its JSON form.
|
|
199
|
+
|
|
200
|
+
The JSON form is ``{"kind": "text", "text": ...}`` or
|
|
201
|
+
``{"kind": "chat", "messages": [{"role": ..., "content": ...}, ...]}``.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
InvalidTemplateError: ``raw`` does not have that shape.
|
|
205
|
+
"""
|
|
206
|
+
if not isinstance(raw, Mapping):
|
|
207
|
+
raise InvalidTemplateError("a template must be a JSON object")
|
|
208
|
+
kind = raw.get("kind")
|
|
209
|
+
if kind == "text":
|
|
210
|
+
text = raw.get("text")
|
|
211
|
+
if not isinstance(text, str) or raw.get("messages") is not None:
|
|
212
|
+
raise InvalidTemplateError("a text template needs a string `text` and no `messages`")
|
|
213
|
+
return Template("text", text=text)
|
|
214
|
+
if kind == "chat":
|
|
215
|
+
if raw.get("text") is not None:
|
|
216
|
+
raise InvalidTemplateError("a chat template needs `messages` and no `text`")
|
|
217
|
+
return Template("chat", messages=messages_from_wire(raw.get("messages")))
|
|
218
|
+
raise InvalidTemplateError(f"unknown template kind: {kind!r}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def messages_from_wire(raw: Any) -> tuple[Message, ...]:
|
|
222
|
+
"""Build chat messages from a list of ``{"role", "content"}`` objects.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
InvalidTemplateError: An item is not a message with a known role and string content.
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(raw, list | tuple):
|
|
228
|
+
raise InvalidTemplateError("a chat template needs a list of `messages`")
|
|
229
|
+
messages: list[Message] = []
|
|
230
|
+
for index, item in enumerate(raw):
|
|
231
|
+
role = item.get("role") if isinstance(item, Mapping) else None
|
|
232
|
+
content = item.get("content") if isinstance(item, Mapping) else None
|
|
233
|
+
if role not in _ROLES or not isinstance(content, str):
|
|
234
|
+
raise InvalidTemplateError(
|
|
235
|
+
f"message {index} needs a role of system, user or assistant and string content"
|
|
236
|
+
)
|
|
237
|
+
messages.append(Message(role, content))
|
|
238
|
+
return tuple(messages)
|