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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Fetching prompts from ``GET /v1/prompts/{name}`` with a small revalidating cache.
|
|
2
|
+
|
|
3
|
+
The cache is keyed by ``(name, label, version)``. A fresh entry is served without a request;
|
|
4
|
+
an expired one is revalidated with ``If-None-Match``, so an unchanged prompt costs a ``304``.
|
|
5
|
+
When the server cannot be reached or answers with an error, an expired entry keeps being
|
|
6
|
+
served (a prompt outage must not become an application outage). With nothing cached, the
|
|
7
|
+
caller's ``fallback`` is used; with no fallback either, :class:`PromptUnavailableError` is
|
|
8
|
+
raised.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from ._prompts import Prompt, PromptUnavailableError, fallback_prompt, prompt_from_wire
|
|
24
|
+
from ._templates import InvalidTemplateError
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("spanlight")
|
|
28
|
+
|
|
29
|
+
CacheKey = tuple[str, str, int | None]
|
|
30
|
+
Fallback = str | Sequence[Mapping[str, Any]]
|
|
31
|
+
|
|
32
|
+
_WARNING_INTERVAL = 60.0
|
|
33
|
+
# Seconds a failed fetch is remembered, so callers queued behind it do not each time out.
|
|
34
|
+
_FAILURE_MEMO = 5.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _Entry:
|
|
39
|
+
prompt: Prompt
|
|
40
|
+
etag: str | None
|
|
41
|
+
expires_at: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _FetchError(Exception):
|
|
45
|
+
"""Why one fetch failed; the message never contains the API key or the prompt body."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PromptClient:
|
|
49
|
+
"""Reads prompts for one Spanlight client. Reach it as ``Spanlight.prompts``.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base_url: URL of the prompts collection, e.g. ``http://localhost:8000/v1/prompts``.
|
|
53
|
+
api_key: Project API key with the ``prompts:read`` scope, or ``None``.
|
|
54
|
+
timeout: Per-request timeout in seconds.
|
|
55
|
+
cache_ttl: Seconds a fetched prompt is served without asking the server again.
|
|
56
|
+
transport: Optional custom ``httpx`` transport.
|
|
57
|
+
clock: Monotonic clock, replaceable in tests.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str,
|
|
64
|
+
api_key: str | None,
|
|
65
|
+
timeout: float,
|
|
66
|
+
cache_ttl: float,
|
|
67
|
+
transport: httpx.BaseTransport | None = None,
|
|
68
|
+
clock: Callable[[], float] = time.monotonic,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._base_url = base_url.rstrip("/")
|
|
71
|
+
self._api_key = api_key
|
|
72
|
+
self._timeout = timeout
|
|
73
|
+
self._ttl = cache_ttl
|
|
74
|
+
self._transport = transport
|
|
75
|
+
self._clock = clock
|
|
76
|
+
self._cache: dict[CacheKey, _Entry] = {}
|
|
77
|
+
self._locks: dict[CacheKey, threading.Lock] = {}
|
|
78
|
+
self._locks_guard = threading.Lock()
|
|
79
|
+
self._last_warning: dict[CacheKey, float] = {}
|
|
80
|
+
# Per key: until when a failed fetch is not retried, and why it failed.
|
|
81
|
+
self._failures: dict[CacheKey, tuple[float, str]] = {}
|
|
82
|
+
self._client: httpx.Client | None = None
|
|
83
|
+
self._client_guard = threading.Lock()
|
|
84
|
+
|
|
85
|
+
def get(
|
|
86
|
+
self,
|
|
87
|
+
name: str,
|
|
88
|
+
label: str = "production",
|
|
89
|
+
*,
|
|
90
|
+
version: int | None = None,
|
|
91
|
+
fallback: Fallback | None = None,
|
|
92
|
+
) -> Prompt:
|
|
93
|
+
"""Return a prompt version by label (default ``production``) or by number.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
name: The prompt name, e.g. ``"support/answer"``.
|
|
97
|
+
label: The label to resolve; ignored when ``version`` is given.
|
|
98
|
+
version: A specific version number.
|
|
99
|
+
fallback: A text template, or a list of ``{"role", "content"}`` messages, used
|
|
100
|
+
when the prompt cannot be fetched and nothing is cached. The returned
|
|
101
|
+
prompt then has ``version=None``.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The prompt. A cached copy is returned while fresh, and also after it expires
|
|
105
|
+
whenever the server cannot be reached.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
PromptUnavailableError: Nothing is cached, the fetch failed and there is no
|
|
109
|
+
valid ``fallback``; or ``name`` is empty or has an empty, ``.`` or ``..``
|
|
110
|
+
segment (no request is made).
|
|
111
|
+
"""
|
|
112
|
+
key: CacheKey = (name, label, version)
|
|
113
|
+
if not _is_valid_name(name):
|
|
114
|
+
raise PromptUnavailableError(f"prompt {name!r} is unavailable: invalid prompt name")
|
|
115
|
+
entry = self._cache.get(key)
|
|
116
|
+
if entry is not None and self._clock() < entry.expires_at:
|
|
117
|
+
return entry.prompt
|
|
118
|
+
lock = self._lock_for(key)
|
|
119
|
+
if not lock.acquire(blocking=False):
|
|
120
|
+
if entry is not None:
|
|
121
|
+
# Another caller is refreshing this prompt; serve the expired copy rather than
|
|
122
|
+
# wait on the server.
|
|
123
|
+
return entry.prompt
|
|
124
|
+
lock.acquire()
|
|
125
|
+
try:
|
|
126
|
+
return self._get_locked(key, fallback)
|
|
127
|
+
finally:
|
|
128
|
+
lock.release()
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
"""Close the HTTP connection pool. A later ``get`` opens a new one."""
|
|
132
|
+
with self._client_guard:
|
|
133
|
+
client, self._client = self._client, None
|
|
134
|
+
if client is not None:
|
|
135
|
+
client.close()
|
|
136
|
+
|
|
137
|
+
def _get_locked(self, key: CacheKey, fallback: Fallback | None) -> Prompt:
|
|
138
|
+
# Another thread may have refreshed the entry while this one waited.
|
|
139
|
+
entry = self._cache.get(key)
|
|
140
|
+
now = self._clock()
|
|
141
|
+
if entry is not None and now < entry.expires_at:
|
|
142
|
+
return entry.prompt
|
|
143
|
+
failure = self._failures.get(key)
|
|
144
|
+
if entry is None and failure is not None and now < failure[0]:
|
|
145
|
+
# A fetch failed moments ago; do not make this caller wait on the server too.
|
|
146
|
+
return self._without_cache(key, fallback, failure[1])
|
|
147
|
+
try:
|
|
148
|
+
return self._refresh(key, entry)
|
|
149
|
+
except _FetchError as error:
|
|
150
|
+
cause = str(error)
|
|
151
|
+
except Exception as error:
|
|
152
|
+
logger.debug("Unexpected error fetching prompt %r", key[0], exc_info=True)
|
|
153
|
+
cause = type(error).__name__
|
|
154
|
+
return self._recover(key, entry, fallback, cause)
|
|
155
|
+
|
|
156
|
+
def _lock_for(self, key: CacheKey) -> threading.Lock:
|
|
157
|
+
with self._locks_guard:
|
|
158
|
+
return self._locks.setdefault(key, threading.Lock())
|
|
159
|
+
|
|
160
|
+
def _refresh(self, key: CacheKey, entry: _Entry | None) -> Prompt:
|
|
161
|
+
response = self._request(key, entry.etag if entry is not None else None)
|
|
162
|
+
now = self._clock()
|
|
163
|
+
if response.status_code == 304 and entry is not None:
|
|
164
|
+
entry.expires_at = now + self._ttl
|
|
165
|
+
return entry.prompt
|
|
166
|
+
if response.status_code != 200:
|
|
167
|
+
raise _FetchError(f"HTTP {response.status_code}")
|
|
168
|
+
try:
|
|
169
|
+
prompt = prompt_from_wire(response.json())
|
|
170
|
+
except (ValueError, InvalidTemplateError) as error:
|
|
171
|
+
raise _FetchError(f"unreadable response ({error})") from None
|
|
172
|
+
self._cache[key] = _Entry(prompt, response.headers.get("ETag"), now + self._ttl)
|
|
173
|
+
self._failures.pop(key, None)
|
|
174
|
+
return prompt
|
|
175
|
+
|
|
176
|
+
def _request(self, key: CacheKey, etag: str | None) -> httpx.Response:
|
|
177
|
+
if not self._api_key:
|
|
178
|
+
raise _FetchError("no API key configured")
|
|
179
|
+
name, label, version = key
|
|
180
|
+
params = {"version": str(version)} if version is not None else {"label": label}
|
|
181
|
+
headers = {
|
|
182
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
183
|
+
"User-Agent": f"spanlight-python/{__version__}",
|
|
184
|
+
}
|
|
185
|
+
if etag is not None:
|
|
186
|
+
headers["If-None-Match"] = etag
|
|
187
|
+
url = f"{self._base_url}/{quote(name, safe='/')}"
|
|
188
|
+
try:
|
|
189
|
+
return self._http_client().get(url, params=params, headers=headers)
|
|
190
|
+
except httpx.HTTPError as error:
|
|
191
|
+
raise _FetchError(type(error).__name__) from None
|
|
192
|
+
|
|
193
|
+
def _recover(
|
|
194
|
+
self, key: CacheKey, entry: _Entry | None, fallback: Fallback | None, cause: str
|
|
195
|
+
) -> Prompt:
|
|
196
|
+
name = key[0]
|
|
197
|
+
if entry is not None:
|
|
198
|
+
# Keep serving the stale copy and wait before asking again, so an outage does not
|
|
199
|
+
# put a request (and its timeout) on every call.
|
|
200
|
+
entry.expires_at = self._clock() + max(self._ttl, _FAILURE_MEMO)
|
|
201
|
+
self._warn(key, f"Spanlight: serving a cached copy of prompt {name!r} ({cause})")
|
|
202
|
+
return entry.prompt
|
|
203
|
+
# Remember the failure: briefly when the caller has nothing else (so concurrent callers
|
|
204
|
+
# do not each wait out a timeout), one TTL when a fallback covers the gap.
|
|
205
|
+
window = _FAILURE_MEMO if fallback is None else max(self._ttl, _FAILURE_MEMO)
|
|
206
|
+
self._failures[key] = (self._clock() + window, cause)
|
|
207
|
+
return self._without_cache(key, fallback, cause)
|
|
208
|
+
|
|
209
|
+
def _without_cache(self, key: CacheKey, fallback: Fallback | None, cause: str) -> Prompt:
|
|
210
|
+
"""The fallback prompt, or :class:`PromptUnavailableError` when there is none."""
|
|
211
|
+
name = key[0]
|
|
212
|
+
if fallback is None:
|
|
213
|
+
raise PromptUnavailableError(f"prompt {name!r} is unavailable: {cause}")
|
|
214
|
+
try:
|
|
215
|
+
prompt = fallback_prompt(name, fallback)
|
|
216
|
+
except (InvalidTemplateError, TypeError) as error:
|
|
217
|
+
raise PromptUnavailableError(
|
|
218
|
+
f"prompt {name!r} is unavailable ({cause}) and its fallback is invalid: {error}"
|
|
219
|
+
) from None
|
|
220
|
+
self._warn(key, f"Spanlight: using the fallback for prompt {name!r} ({cause})")
|
|
221
|
+
return prompt
|
|
222
|
+
|
|
223
|
+
def _warn(self, key: CacheKey, message: str) -> None:
|
|
224
|
+
now = self._clock()
|
|
225
|
+
last = self._last_warning.get(key)
|
|
226
|
+
if last is None or now - last >= _WARNING_INTERVAL:
|
|
227
|
+
self._last_warning[key] = now
|
|
228
|
+
logger.warning(message)
|
|
229
|
+
|
|
230
|
+
def _http_client(self) -> httpx.Client:
|
|
231
|
+
with self._client_guard:
|
|
232
|
+
if self._client is None:
|
|
233
|
+
self._client = httpx.Client(transport=self._transport, timeout=self._timeout)
|
|
234
|
+
return self._client
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_valid_name(name: str) -> bool:
|
|
238
|
+
"""Whether ``name`` can travel in the URL path: no empty, ``.`` or ``..`` segments."""
|
|
239
|
+
if not isinstance(name, str) or not name:
|
|
240
|
+
return False
|
|
241
|
+
return all(segment not in ("", ".", "..") for segment in name.split("/"))
|
spanlight/_prompts.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Prompts fetched from Spanlight, and their compiled form.
|
|
2
|
+
|
|
3
|
+
A :class:`Prompt` is one immutable prompt version (or a local fallback). Compiling it fills in
|
|
4
|
+
the variables and returns a :class:`CompiledPrompt`, which is also a context manager: spans
|
|
5
|
+
started inside ``with compiled:`` are linked to the prompt version they used.
|
|
6
|
+
|
|
7
|
+
The link lives in a :class:`~contextvars.ContextVar`, so it follows contextvars semantics: an
|
|
8
|
+
``asyncio`` task created inside the block keeps the link for its whole life, and a thread does
|
|
9
|
+
not see it at all. Wrap the creation of long-lived tasks in :func:`no_prompt` to keep their
|
|
10
|
+
spans unlinked.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
17
|
+
from contextvars import ContextVar, Token
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ._context import (
|
|
23
|
+
get_current_prompt_version_id,
|
|
24
|
+
restore_current_prompt_version_id,
|
|
25
|
+
set_current_prompt_version_id,
|
|
26
|
+
)
|
|
27
|
+
from ._templates import (
|
|
28
|
+
Kind,
|
|
29
|
+
Template,
|
|
30
|
+
compile,
|
|
31
|
+
extract_variables,
|
|
32
|
+
messages_from_wire,
|
|
33
|
+
template_from_wire,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# The `with compiled:` blocks open in the current context, innermost last: (prompt, token to
|
|
37
|
+
# undo its link, link before it). Kept per context so threads and tasks entering the same
|
|
38
|
+
# CompiledPrompt never pop each other's entries.
|
|
39
|
+
_Entered = tuple["CompiledPrompt", Token[str | None], str | None]
|
|
40
|
+
_entered: ContextVar[tuple[_Entered, ...]] = ContextVar("spanlight_entered_prompts", default=())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextlib.contextmanager
|
|
44
|
+
def no_prompt() -> Iterator[None]:
|
|
45
|
+
"""Unlink spans started inside the block from any prompt version.
|
|
46
|
+
|
|
47
|
+
Use it around code that starts long-lived work (``asyncio.create_task``, a background
|
|
48
|
+
worker) from inside ``with compiled:``, which would otherwise inherit the link.
|
|
49
|
+
"""
|
|
50
|
+
previous = get_current_prompt_version_id()
|
|
51
|
+
token = set_current_prompt_version_id(None)
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
restore_current_prompt_version_id(token, previous)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PromptUnavailableError(Exception):
|
|
59
|
+
"""A prompt could not be fetched and there was neither a cached copy nor a fallback.
|
|
60
|
+
|
|
61
|
+
This is the one exception the SDK raises on purpose. Pass ``fallback=`` to
|
|
62
|
+
:meth:`PromptClient.get <spanlight.PromptClient.get>` to never see it.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class CompiledPrompt:
|
|
68
|
+
"""A prompt with its variables filled in.
|
|
69
|
+
|
|
70
|
+
Use ``text`` for a text prompt and ``messages`` (plain ``{"role", "content"}`` dicts, ready
|
|
71
|
+
for the OpenAI and Anthropic SDKs) for a chat prompt. Enter it with ``with compiled:`` to link
|
|
72
|
+
every span started inside the block to this prompt version, or pass it to a wrapped client
|
|
73
|
+
call as ``spanlight_prompt=compiled``. A fallback (no version) clears the link instead.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
name: The prompt name.
|
|
77
|
+
version: The prompt version, or ``None`` for a local fallback.
|
|
78
|
+
prompt_version_id: The version id spans are linked to, or ``None`` for a fallback.
|
|
79
|
+
kind: ``"text"`` or ``"chat"``.
|
|
80
|
+
text: The compiled text of a text prompt.
|
|
81
|
+
messages: The compiled messages of a chat prompt.
|
|
82
|
+
variables_used: Variables the template uses, in first-appearance order.
|
|
83
|
+
unused_variables: Variables that were passed but not used, sorted.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
version: int | None
|
|
88
|
+
prompt_version_id: str | None
|
|
89
|
+
kind: Kind
|
|
90
|
+
text: str | None
|
|
91
|
+
messages: list[dict[str, str]] | None
|
|
92
|
+
variables_used: tuple[str, ...]
|
|
93
|
+
unused_variables: tuple[str, ...]
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> CompiledPrompt:
|
|
96
|
+
previous = get_current_prompt_version_id()
|
|
97
|
+
token = set_current_prompt_version_id(self.prompt_version_id)
|
|
98
|
+
_entered.set((*_entered.get(), (self, token, previous)))
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(
|
|
102
|
+
self,
|
|
103
|
+
exc_type: type[BaseException] | None,
|
|
104
|
+
exc: BaseException | None,
|
|
105
|
+
traceback: TracebackType | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
stack = _entered.get()
|
|
108
|
+
for index in range(len(stack) - 1, -1, -1):
|
|
109
|
+
prompt, token, previous = stack[index]
|
|
110
|
+
if prompt is self:
|
|
111
|
+
_entered.set(stack[:index] + stack[index + 1 :])
|
|
112
|
+
restore_current_prompt_version_id(token, previous)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
async def __aenter__(self) -> CompiledPrompt:
|
|
116
|
+
return self.__enter__()
|
|
117
|
+
|
|
118
|
+
async def __aexit__(
|
|
119
|
+
self,
|
|
120
|
+
exc_type: type[BaseException] | None,
|
|
121
|
+
exc: BaseException | None,
|
|
122
|
+
traceback: TracebackType | None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self.__exit__(exc_type, exc, traceback)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Prompt:
|
|
129
|
+
"""One prompt version as served by Spanlight, or a local fallback.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
name: The prompt name.
|
|
133
|
+
version: The version number, or ``None`` when this is the fallback passed to ``get``.
|
|
134
|
+
version_id: The version's id, or ``None`` for a fallback.
|
|
135
|
+
kind: ``"text"`` or ``"chat"``.
|
|
136
|
+
template: The uncompiled template.
|
|
137
|
+
variables: Variables the template uses, in first-appearance order.
|
|
138
|
+
model_config: Model settings saved with the version (model, temperature, ...).
|
|
139
|
+
labels: Labels that point at this version.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
name: str
|
|
143
|
+
version: int | None
|
|
144
|
+
version_id: str | None
|
|
145
|
+
kind: Kind
|
|
146
|
+
template: Template
|
|
147
|
+
variables: tuple[str, ...]
|
|
148
|
+
model_config: dict[str, Any] = field(default_factory=dict)
|
|
149
|
+
labels: tuple[str, ...] = ()
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def is_fallback(self) -> bool:
|
|
153
|
+
"""Whether this prompt is the local fallback rather than a served version."""
|
|
154
|
+
return self.version is None
|
|
155
|
+
|
|
156
|
+
def compile(self, **variables: str | int | float) -> CompiledPrompt:
|
|
157
|
+
"""Fill in the template's variables.
|
|
158
|
+
|
|
159
|
+
Values are strings; numbers and booleans are converted with ``str()``. Extra
|
|
160
|
+
variables are reported in ``unused_variables``, never rejected.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
MissingVariablesError: A variable the template uses was not given.
|
|
164
|
+
InvalidTemplateError: The template is malformed.
|
|
165
|
+
TemplateTooLargeError: The rendered prompt would exceed 256 KB.
|
|
166
|
+
TypeError: A value is not a string, number or boolean (``None`` included).
|
|
167
|
+
"""
|
|
168
|
+
rendered = compile(self.template, _as_strings(variables))
|
|
169
|
+
messages = (
|
|
170
|
+
[{"role": message.role, "content": message.content} for message in rendered.messages]
|
|
171
|
+
if rendered.messages is not None
|
|
172
|
+
else None
|
|
173
|
+
)
|
|
174
|
+
return CompiledPrompt(
|
|
175
|
+
name=self.name,
|
|
176
|
+
version=self.version,
|
|
177
|
+
prompt_version_id=self.version_id,
|
|
178
|
+
kind=rendered.kind,
|
|
179
|
+
text=rendered.text,
|
|
180
|
+
messages=messages,
|
|
181
|
+
variables_used=rendered.variables_used,
|
|
182
|
+
unused_variables=rendered.unused_variables,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _as_strings(variables: Mapping[str, Any]) -> dict[str, str]:
|
|
187
|
+
"""Template values as strings: numbers and booleans via ``str()``, anything else refused."""
|
|
188
|
+
converted: dict[str, str] = {}
|
|
189
|
+
for name, value in variables.items():
|
|
190
|
+
if isinstance(value, str):
|
|
191
|
+
converted[name] = value
|
|
192
|
+
elif isinstance(value, int | float):
|
|
193
|
+
converted[name] = str(value)
|
|
194
|
+
else:
|
|
195
|
+
raise TypeError(
|
|
196
|
+
f"variable {name!r} must be a string, number or boolean, not {type(value).__name__}"
|
|
197
|
+
)
|
|
198
|
+
return converted
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def prompt_from_wire(body: Any) -> Prompt:
|
|
202
|
+
"""Build a :class:`Prompt` from a ``GET /v1/prompts/{name}`` response body.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
ValueError: The body does not have the documented shape.
|
|
206
|
+
"""
|
|
207
|
+
if not isinstance(body, Mapping):
|
|
208
|
+
raise ValueError("prompt response is not a JSON object")
|
|
209
|
+
name, version, version_id = body.get("name"), body.get("version"), body.get("version_id")
|
|
210
|
+
if not isinstance(name, str) or not isinstance(version_id, str):
|
|
211
|
+
raise ValueError("prompt response lacks name or version_id")
|
|
212
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
213
|
+
raise ValueError("prompt response lacks a version number")
|
|
214
|
+
template = template_from_wire(body.get("template"))
|
|
215
|
+
model_config = body.get("model_config")
|
|
216
|
+
labels = body.get("labels")
|
|
217
|
+
return Prompt(
|
|
218
|
+
name=name,
|
|
219
|
+
version=version,
|
|
220
|
+
version_id=version_id,
|
|
221
|
+
kind=template.kind,
|
|
222
|
+
template=template,
|
|
223
|
+
variables=extract_variables(template),
|
|
224
|
+
model_config=dict(model_config) if isinstance(model_config, Mapping) else {},
|
|
225
|
+
labels=tuple(str(label) for label in labels) if isinstance(labels, list) else (),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fallback_prompt(name: str, fallback: str | Sequence[Mapping[str, Any]]) -> Prompt:
|
|
230
|
+
"""Build the ``version=None`` prompt used when nothing could be fetched or cached.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
InvalidTemplateError: ``fallback`` is not a valid text or chat template.
|
|
234
|
+
"""
|
|
235
|
+
template = (
|
|
236
|
+
Template("text", text=fallback)
|
|
237
|
+
if isinstance(fallback, str)
|
|
238
|
+
else Template("chat", messages=messages_from_wire(list(fallback)))
|
|
239
|
+
)
|
|
240
|
+
return Prompt(
|
|
241
|
+
name=name,
|
|
242
|
+
version=None,
|
|
243
|
+
version_id=None,
|
|
244
|
+
kind=template.kind,
|
|
245
|
+
template=template,
|
|
246
|
+
variables=extract_variables(template),
|
|
247
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Stable hash of a model request, used to group identical calls.
|
|
2
|
+
|
|
3
|
+
The algorithm is shared with the server and must stay byte-for-byte identical
|
|
4
|
+
to it: the first 32 hex characters of the SHA-256 of the canonical JSON
|
|
5
|
+
(sorted keys, ``,`` and ``:`` separators, non-ASCII kept) of
|
|
6
|
+
``{"model": model, "input": cleaned}``. ``cleaned`` is ``input`` without the
|
|
7
|
+
top-level keys that change per call but not the prompt (streaming flags,
|
|
8
|
+
identifiers, transport options); non-dict inputs are hashed as they are.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Top-level request keys that never change what the model is asked.
|
|
18
|
+
_IGNORED_INPUT_KEYS = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"stream",
|
|
21
|
+
"stream_options",
|
|
22
|
+
"user",
|
|
23
|
+
"metadata",
|
|
24
|
+
"idempotency_key",
|
|
25
|
+
"extra_headers",
|
|
26
|
+
"extra_query",
|
|
27
|
+
"extra_body",
|
|
28
|
+
"timeout",
|
|
29
|
+
"api_key",
|
|
30
|
+
"http_client",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def request_hash(model: str | None, value: Any) -> str | None:
|
|
36
|
+
"""Return the 32-hex request hash, or ``None`` when ``value`` is ``None``.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
model: The requested model name, or ``None`` when unknown.
|
|
40
|
+
value: The JSON-compatible request input (a dict of call parameters,
|
|
41
|
+
a string or a list).
|
|
42
|
+
"""
|
|
43
|
+
if value is None:
|
|
44
|
+
return None
|
|
45
|
+
cleaned = (
|
|
46
|
+
{key: item for key, item in value.items() if key not in _IGNORED_INPUT_KEYS}
|
|
47
|
+
if isinstance(value, dict)
|
|
48
|
+
else value
|
|
49
|
+
)
|
|
50
|
+
canonical = json.dumps(
|
|
51
|
+
{"model": model, "input": cleaned},
|
|
52
|
+
sort_keys=True,
|
|
53
|
+
separators=(",", ":"),
|
|
54
|
+
ensure_ascii=False,
|
|
55
|
+
)
|
|
56
|
+
# ``surrogatepass``, as on the server: a lone surrogate (a valid JSON escape, invalid
|
|
57
|
+
# UTF-8) is hashed instead of raising; every other string encodes exactly as UTF-8.
|
|
58
|
+
return hashlib.sha256(canonical.encode("utf-8", "surrogatepass")).hexdigest()[:32]
|
spanlight/_scores.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Building the ``POST /v1/scores`` payload for one score.
|
|
2
|
+
|
|
3
|
+
Scores are recorded with :func:`spanlight.score` and sent by the background exporter. Each
|
|
4
|
+
score gets a UUIDv7 ``id`` when it is recorded, so a retried request can never store it twice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
from ._ids import new_uuid7
|
|
13
|
+
from ._serialize import JsonValue
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("spanlight")
|
|
16
|
+
|
|
17
|
+
ScoreValue = float | int | bool | str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_score(
|
|
21
|
+
trace_id: str,
|
|
22
|
+
name: str,
|
|
23
|
+
value: ScoreValue,
|
|
24
|
+
*,
|
|
25
|
+
span_id: str | None = None,
|
|
26
|
+
comment: str | None = None,
|
|
27
|
+
) -> dict[str, JsonValue] | None:
|
|
28
|
+
"""Return the wire form of one score, or ``None`` (with a warning) when it cannot be sent.
|
|
29
|
+
|
|
30
|
+
A ``bool`` stays a JSON boolean, which makes the server infer a boolean score stored as
|
|
31
|
+
1 or 0. A ``str`` becomes ``string_value`` (a categorical score). Numbers must be finite.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(trace_id, str) or not isinstance(name, str):
|
|
34
|
+
logger.warning("Spanlight: score() needs a string trace_id and name; score dropped")
|
|
35
|
+
return None
|
|
36
|
+
payload: dict[str, JsonValue] = {"id": new_uuid7(), "trace_id": trace_id, "name": name}
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
payload["value"] = value
|
|
39
|
+
elif isinstance(value, int | float):
|
|
40
|
+
if not math.isfinite(value):
|
|
41
|
+
logger.warning("Spanlight: score %r has a non-finite value; score dropped", name)
|
|
42
|
+
return None
|
|
43
|
+
payload["value"] = value
|
|
44
|
+
elif isinstance(value, str):
|
|
45
|
+
payload["string_value"] = value
|
|
46
|
+
else:
|
|
47
|
+
logger.warning(
|
|
48
|
+
"Spanlight: score %r has an unsupported value type %s; score dropped",
|
|
49
|
+
name,
|
|
50
|
+
type(value).__name__,
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
if span_id is not None:
|
|
54
|
+
payload["span_id"] = span_id
|
|
55
|
+
if comment is not None:
|
|
56
|
+
payload["comment"] = comment
|
|
57
|
+
return payload
|