evalstone 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {evalstone-0.1.1 → evalstone-0.2.0}/.gitignore +3 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/CHANGELOG.md +18 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/PKG-INFO +1 -1
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/__init__.py +1 -1
- evalstone-0.2.0/src/evalstone/center.py +253 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/_base.py +8 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/comparison.py +2 -2
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/human.py +32 -1
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/judge.py +2 -2
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/cli.py +34 -4
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/config.py +59 -11
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/mcp_server.py +4 -0
- evalstone-0.2.0/tests/test_center.py +343 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/LICENSE +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/README.md +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/justfile +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/pyproject.toml +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/__main__.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/_git.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/_json.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/__init__.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/deterministic.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/rag.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/safety.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/trajectory.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/dataset.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/gate.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/output.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/__init__.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/_http.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/anthropic_.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/gemini.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/openai_compat.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/pytest_plugin.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/run.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/score.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/suite.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/target.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/conftest.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_action.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_cli.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_config.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_dataset.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_deterministic.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_docs.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_human.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_judge.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_judge_switching.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_mcp.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_providers.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_pytest_plugin.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_rag_and_comparison.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_safety.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_score_and_checks.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_suite_run_gate.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_target.py +0 -0
- {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_trajectory.py +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `python/evalstone`. Semantic versioning; tags `lib-vX.Y.Z`.
|
|
4
4
|
|
|
5
|
+
## 0.2.0 — 2026-10-14
|
|
6
|
+
|
|
7
|
+
Attach a project to a center.
|
|
8
|
+
|
|
9
|
+
- `center:` in `evalstone.yaml` (`url`, `project`, `api_key_env` — an `es_…` ingest key)
|
|
10
|
+
routes judge and prompt-target calls through the center's model proxy, so no provider
|
|
11
|
+
key lives on the machine running the suite; the project's judge settings pick the
|
|
12
|
+
model (per-check overrides, then the default), `--judge-model` still wins.
|
|
13
|
+
- Judge verdicts are cached in the center (`CenterCache`); a center hiccup is a cache
|
|
14
|
+
miss, never a failed run.
|
|
15
|
+
- `human` checks of an attached project look labels up in the center (`labels: center`,
|
|
16
|
+
the default when attached; a file path still works).
|
|
17
|
+
- `evalstone run` pushes its report to the center (`push_runs: false` to opt out); a
|
|
18
|
+
failed push warns and leaves the exit code alone.
|
|
19
|
+
- Prompt targets (the system under test) always use the local `providers:` + env keys,
|
|
20
|
+
even when attached — the center proxy is for judges only. The MCP server honours the
|
|
21
|
+
attach too.
|
|
22
|
+
|
|
5
23
|
## 0.1.1 — 2026-10-13
|
|
6
24
|
|
|
7
25
|
Judge switching and cost honesty.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evalstone
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: The plug-and-play evals layer for LLM-based projects: datasets, checks, suites, runs and gates.
|
|
5
5
|
Project-URL: Homepage, https://evalstone.com
|
|
6
6
|
Project-URL: Documentation, https://evalstone.com/docs
|
|
@@ -8,7 +8,7 @@ import types
|
|
|
8
8
|
from importlib import import_module
|
|
9
9
|
from typing import TYPE_CHECKING, Any
|
|
10
10
|
|
|
11
|
-
__version__ = "0.
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
12
|
|
|
13
13
|
# Exports resolve on first access (PEP 562) so that importing the package — which
|
|
14
14
|
# the pytest plugin does in every pytest session on the interpreter — costs
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Attach a project to an Evalstone center (``center:`` in evalstone.yaml).
|
|
2
|
+
|
|
3
|
+
Attached, the library never holds a provider key: judge (and prompt-target) calls go
|
|
4
|
+
through the center's model proxy, which resolves the model from the project's judge
|
|
5
|
+
settings and dials the provider with the organization's key server-side. The judge
|
|
6
|
+
cache lives in the center (one verdict serves every machine), ``human`` checks look
|
|
7
|
+
labels up there, and every ``evalstone run`` pushes its report. ``providers:`` and
|
|
8
|
+
``judges:`` in the YAML remain the standalone path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from evalstone.providers import ChatRequest, ChatResponse, ProviderError
|
|
20
|
+
from evalstone.score import MemoryCache, Score
|
|
21
|
+
|
|
22
|
+
DEFAULT_KEY_ENV = "EVALSTONE_API_KEY"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CenterConfig(BaseModel):
|
|
26
|
+
url: str
|
|
27
|
+
project: str # the center project id (uuid)
|
|
28
|
+
api_key_env: str = DEFAULT_KEY_ENV # an ingest key (es_…) or a user token
|
|
29
|
+
timeout: float = 120.0 # a judge call waits on the provider behind the proxy
|
|
30
|
+
cache_timeout: float = 10.0 # cache and label lookups: a slow center is a miss, fast
|
|
31
|
+
push_runs: bool = True # `evalstone run` pushes its report
|
|
32
|
+
cache: bool = True # judge verdicts read/written in the center
|
|
33
|
+
|
|
34
|
+
def api_key(self) -> str | None:
|
|
35
|
+
return os.environ.get(self.api_key_env) or None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CenterError(ProviderError):
|
|
39
|
+
"""The center refused or could not serve a request."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CenterClient:
|
|
43
|
+
"""Thin HTTP client for the center's project endpoints."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: CenterConfig, *, client: Any = None) -> None:
|
|
46
|
+
self.config = config
|
|
47
|
+
key = config.api_key()
|
|
48
|
+
if not key:
|
|
49
|
+
raise CenterError(
|
|
50
|
+
f"center: no API key — set {config.api_key_env} (an ingest key from the "
|
|
51
|
+
"project's settings) in the environment"
|
|
52
|
+
)
|
|
53
|
+
self._client = client or httpx.Client(
|
|
54
|
+
base_url=config.url.rstrip("/"),
|
|
55
|
+
timeout=config.timeout,
|
|
56
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
57
|
+
)
|
|
58
|
+
self._owns = client is None
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
if self._owns:
|
|
62
|
+
self._client.close()
|
|
63
|
+
|
|
64
|
+
# ── transport ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
67
|
+
try:
|
|
68
|
+
return self._client.request(method, path, **kwargs)
|
|
69
|
+
except httpx.HTTPError as exc:
|
|
70
|
+
raise CenterError(f"center: {type(exc).__name__}: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _detail(resp: httpx.Response) -> str:
|
|
74
|
+
try:
|
|
75
|
+
body = resp.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
body = None
|
|
78
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
79
|
+
return str(detail)[:300] if detail else resp.text[:300]
|
|
80
|
+
|
|
81
|
+
def _json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
82
|
+
resp = self._request(method, path, **kwargs)
|
|
83
|
+
if resp.status_code >= 400:
|
|
84
|
+
raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
|
|
85
|
+
try:
|
|
86
|
+
return resp.json()
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise CenterError(f"center: non-JSON response: {resp.text[:200]!r}") from exc
|
|
89
|
+
|
|
90
|
+
def _p(self, suffix: str) -> str:
|
|
91
|
+
return f"/projects/{self.config.project}{suffix}"
|
|
92
|
+
|
|
93
|
+
# ── endpoints ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def whoami(self) -> dict[str, Any]:
|
|
96
|
+
return self._json("GET", "/ingest/whoami")
|
|
97
|
+
|
|
98
|
+
def judge_settings(self) -> dict[str, Any]:
|
|
99
|
+
return self._json("GET", self._p("/judge-settings"))
|
|
100
|
+
|
|
101
|
+
def complete(
|
|
102
|
+
self, model: str | None, request: ChatRequest, *, check_key: str | None = None
|
|
103
|
+
) -> ChatResponse:
|
|
104
|
+
"""The proxy call: `model` is `<provider>/<model>` (or None → the center picks)."""
|
|
105
|
+
body = {
|
|
106
|
+
"model": model,
|
|
107
|
+
"check_key": check_key,
|
|
108
|
+
"system": request.system,
|
|
109
|
+
"messages": [m.model_dump() for m in request.messages],
|
|
110
|
+
"max_tokens": request.max_tokens,
|
|
111
|
+
"temperature": request.temperature,
|
|
112
|
+
}
|
|
113
|
+
data = self._json("POST", self._p("/model/complete"), json=body)
|
|
114
|
+
return ChatResponse.model_validate(data)
|
|
115
|
+
|
|
116
|
+
def cache_get(self, key: str) -> Score | None:
|
|
117
|
+
resp = self._request(
|
|
118
|
+
"GET", self._p(f"/judge-cache/{key}"), timeout=self.config.cache_timeout
|
|
119
|
+
)
|
|
120
|
+
if resp.status_code == 404:
|
|
121
|
+
return None
|
|
122
|
+
if resp.status_code >= 400:
|
|
123
|
+
raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
|
|
124
|
+
return Score.model_validate(resp.json()["score"])
|
|
125
|
+
|
|
126
|
+
def cache_set(self, key: str, score: Score) -> None:
|
|
127
|
+
judge_version = (
|
|
128
|
+
(score.raw or {}).get("judge_version") if isinstance(score.raw, dict) else None
|
|
129
|
+
)
|
|
130
|
+
self._json(
|
|
131
|
+
"PUT",
|
|
132
|
+
self._p(f"/judge-cache/{key}"),
|
|
133
|
+
json={"score": score.model_dump(mode="json"), "judge_version": judge_version},
|
|
134
|
+
timeout=self.config.cache_timeout,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def label_lookup(
|
|
138
|
+
self, sample_id: str, output_hash: str, check: str | None
|
|
139
|
+
) -> dict[str, Any] | None:
|
|
140
|
+
params = {"sample_key": sample_id, "output_hash": output_hash}
|
|
141
|
+
if check is not None:
|
|
142
|
+
params["check"] = check
|
|
143
|
+
resp = self._request(
|
|
144
|
+
"GET", self._p("/labels/lookup"), params=params, timeout=self.config.cache_timeout
|
|
145
|
+
)
|
|
146
|
+
if resp.status_code == 404:
|
|
147
|
+
return None
|
|
148
|
+
if resp.status_code >= 400:
|
|
149
|
+
raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
|
|
150
|
+
return resp.json()
|
|
151
|
+
|
|
152
|
+
def push_run(self, report: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
|
153
|
+
"""Store a run report; returns (run, created) — 200 means the center had it already."""
|
|
154
|
+
resp = self._request("POST", self._p("/runs"), json=report)
|
|
155
|
+
if resp.status_code >= 400:
|
|
156
|
+
raise CenterError(f"center: HTTP {resp.status_code}: {self._detail(resp)}")
|
|
157
|
+
return resp.json(), resp.status_code == 201
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── the Providers surface, backed by the proxy ───────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RemoteProvider:
|
|
164
|
+
"""One named provider entry of the center (a `Provider` whose calls go through the proxy)."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, name: str, client: CenterClient) -> None:
|
|
167
|
+
self.name = name
|
|
168
|
+
self._client = client
|
|
169
|
+
|
|
170
|
+
def complete(self, request: ChatRequest) -> ChatResponse:
|
|
171
|
+
return self._client.complete(f"{self.name}/{request.model}", request)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CenterProviders:
|
|
175
|
+
"""Drop-in for ``evalstone.providers.Providers`` when a project is attached.
|
|
176
|
+
|
|
177
|
+
``split``/``get``/``complete`` match the registry the judge check and prompt targets
|
|
178
|
+
call; the default model comes from the center's judge settings, so a check without
|
|
179
|
+
its own ``model`` resolves there.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, client: CenterClient, *, default_model: str | None = None) -> None:
|
|
183
|
+
self.client = client
|
|
184
|
+
self.default_model = default_model
|
|
185
|
+
self._instances: dict[str, RemoteProvider] = {}
|
|
186
|
+
|
|
187
|
+
def split(self, model: str | None) -> tuple[str, str]:
|
|
188
|
+
model = model or self.default_model
|
|
189
|
+
if not model:
|
|
190
|
+
raise ProviderError(
|
|
191
|
+
"no model given and the center project has no default judge model "
|
|
192
|
+
"(set it in the project's judge settings)"
|
|
193
|
+
)
|
|
194
|
+
if "/" not in model:
|
|
195
|
+
raise ProviderError(f"model must be '<provider>/<model>', got {model!r}")
|
|
196
|
+
name, _, bare = model.partition("/")
|
|
197
|
+
return name, bare
|
|
198
|
+
|
|
199
|
+
def get(self, name: str) -> RemoteProvider:
|
|
200
|
+
if name not in self._instances:
|
|
201
|
+
self._instances[name] = RemoteProvider(name, self.client)
|
|
202
|
+
return self._instances[name]
|
|
203
|
+
|
|
204
|
+
def complete(self, model: str | None, request_without_model: dict[str, Any]) -> ChatResponse:
|
|
205
|
+
name, bare = self.split(model)
|
|
206
|
+
req = ChatRequest(model=bare, **request_without_model)
|
|
207
|
+
return self.client.complete(f"{name}/{bare}", req)
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
self.client.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class CenterCache:
|
|
214
|
+
"""A ``ScoreCache`` in the center, with an in-process layer so a run asks once per key.
|
|
215
|
+
|
|
216
|
+
A center hiccup is a miss (a judge then pays for the call); it never fails the run.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, client: CenterClient) -> None:
|
|
220
|
+
self._client = client
|
|
221
|
+
self._local = MemoryCache()
|
|
222
|
+
self.errors: list[str] = []
|
|
223
|
+
|
|
224
|
+
def get(self, key: str) -> Score | None:
|
|
225
|
+
hit = self._local.get(key)
|
|
226
|
+
if hit is not None:
|
|
227
|
+
return hit
|
|
228
|
+
try:
|
|
229
|
+
score = self._client.cache_get(key)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
self.errors.append(f"{type(exc).__name__}: {exc}")
|
|
232
|
+
return None
|
|
233
|
+
if score is not None:
|
|
234
|
+
self._local.set(key, score)
|
|
235
|
+
return score
|
|
236
|
+
|
|
237
|
+
def set(self, key: str, score: Score) -> None:
|
|
238
|
+
self._local.set(key, score)
|
|
239
|
+
try:
|
|
240
|
+
self._client.cache_set(key, score)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
self.errors.append(f"{type(exc).__name__}: {exc}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
__all__ = [
|
|
246
|
+
"DEFAULT_KEY_ENV",
|
|
247
|
+
"CenterCache",
|
|
248
|
+
"CenterClient",
|
|
249
|
+
"CenterConfig",
|
|
250
|
+
"CenterError",
|
|
251
|
+
"CenterProviders",
|
|
252
|
+
"RemoteProvider",
|
|
253
|
+
]
|
|
@@ -151,3 +151,11 @@ def check(
|
|
|
151
151
|
|
|
152
152
|
|
|
153
153
|
__all__ = ["CheckFn", "FunctionCheck", "Registry", "check", "registry"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def judge_model_for(ctx: Any, key: str, model: str | None) -> str | None:
|
|
157
|
+
"""Which model a judge-style check uses: ``--judge-model`` (``ctx.judge_model``) beats the
|
|
158
|
+
attached project's per-check override (``ctx.extra["judge_models"][key]``) beats the
|
|
159
|
+
check's own ``model``. ``None`` falls through to the registry's default."""
|
|
160
|
+
overrides = (getattr(ctx, "extra", None) or {}).get("judge_models") or {}
|
|
161
|
+
return getattr(ctx, "judge_model", None) or overrides.get(key) or model
|
|
@@ -11,7 +11,7 @@ from collections.abc import Mapping
|
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
13
|
from evalstone._json import sha256
|
|
14
|
-
from evalstone.checks._base import FunctionCheck
|
|
14
|
+
from evalstone.checks._base import FunctionCheck, judge_model_for
|
|
15
15
|
from evalstone.checks.judge import _json_text, extract_json
|
|
16
16
|
from evalstone.dataset import Sample
|
|
17
17
|
from evalstone.output import Output
|
|
@@ -69,7 +69,7 @@ def pairwise(
|
|
|
69
69
|
def ask(ctx: Context, sample_input: str, a: str, b: str) -> tuple[str, str, float, float]:
|
|
70
70
|
user = USER.format(input=sample_input, a=a, b=b)
|
|
71
71
|
resp = ctx.providers.complete(
|
|
72
|
-
ctx
|
|
72
|
+
judge_model_for(ctx, key, model),
|
|
73
73
|
{
|
|
74
74
|
"system": SYSTEM.format(rubric=rubric.strip()),
|
|
75
75
|
"messages": [ChatMessage(role="user", content=user)],
|
|
@@ -85,8 +85,11 @@ def human(
|
|
|
85
85
|
"""Score from a person's label for this (sample, output); unlabelled → error.
|
|
86
86
|
|
|
87
87
|
``check`` selects labels recorded for a specific rubric (``evalstone label
|
|
88
|
-
--check``); ``None`` uses the overall grade.
|
|
88
|
+
--check``); ``None`` uses the overall grade. ``labels="center"`` (what an attached
|
|
89
|
+
project gets by default) looks the label up in the center instead of a file.
|
|
89
90
|
"""
|
|
91
|
+
if labels == "center":
|
|
92
|
+
return FunctionCheck(key, _center_lookup(check), kind=CheckKind.HUMAN)
|
|
90
93
|
store_path = Path(labels)
|
|
91
94
|
cached: dict[str, Any] = {}
|
|
92
95
|
|
|
@@ -121,4 +124,32 @@ def human(
|
|
|
121
124
|
return FunctionCheck(key, fn, kind=CheckKind.HUMAN)
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def _center_lookup(check: str | None):
|
|
128
|
+
def fn(sample: Sample, output: Output, ctx: Any) -> Score:
|
|
129
|
+
client = (ctx.extra or {}).get("center_client") if ctx is not None else None
|
|
130
|
+
if client is None:
|
|
131
|
+
return Score.errored("human: labels=center but the run is not attached to a center")
|
|
132
|
+
try:
|
|
133
|
+
found = client.label_lookup(sample.id, output_hash(output), check)
|
|
134
|
+
except Exception as exc: # a center hiccup is an error score, never a pass
|
|
135
|
+
return Score.errored(f"human: center lookup failed: {exc}")
|
|
136
|
+
if found is None:
|
|
137
|
+
return Score.errored(
|
|
138
|
+
f"no human label in the center for {sample.id} @ {output_hash(output)}"
|
|
139
|
+
)
|
|
140
|
+
reason = found.get("notes") or ("labelled pass" if found["passed"] else "labelled fail")
|
|
141
|
+
return Score(
|
|
142
|
+
value=found["value"],
|
|
143
|
+
passed=found["passed"],
|
|
144
|
+
reason=reason,
|
|
145
|
+
raw={
|
|
146
|
+
"labeler": found.get("labeler", ""),
|
|
147
|
+
"grade": found.get("raw"),
|
|
148
|
+
"at": found.get("labeled_at"),
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return fn
|
|
153
|
+
|
|
154
|
+
|
|
124
155
|
__all__ = ["Label", "LabelStore", "human", "output_hash"]
|
|
@@ -13,7 +13,7 @@ import re
|
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
15
|
from evalstone._json import dumps, sha256
|
|
16
|
-
from evalstone.checks._base import FunctionCheck
|
|
16
|
+
from evalstone.checks._base import FunctionCheck, judge_model_for
|
|
17
17
|
from evalstone.dataset import Sample
|
|
18
18
|
from evalstone.output import Output
|
|
19
19
|
from evalstone.providers import ChatMessage, ProviderError
|
|
@@ -134,7 +134,7 @@ def judge(
|
|
|
134
134
|
providers = ctx.providers
|
|
135
135
|
if providers is None:
|
|
136
136
|
return Score.errored("judge: no providers configured (ctx.providers is None)")
|
|
137
|
-
chosen = ctx
|
|
137
|
+
chosen = judge_model_for(ctx, key, model)
|
|
138
138
|
try:
|
|
139
139
|
resolved = "/".join(providers.split(chosen))
|
|
140
140
|
except ProviderError as exc:
|
|
@@ -14,9 +14,11 @@ from rich.markup import escape
|
|
|
14
14
|
from rich.table import Table
|
|
15
15
|
|
|
16
16
|
from evalstone import __version__
|
|
17
|
+
from evalstone.center import CenterError
|
|
17
18
|
from evalstone.config import CONFIG_FILENAME, Config
|
|
18
19
|
from evalstone.providers import ProviderError
|
|
19
20
|
from evalstone.run import DEFAULT_FAILURES, Run, compare
|
|
21
|
+
from evalstone.score import Context
|
|
20
22
|
|
|
21
23
|
app = typer.Typer(
|
|
22
24
|
name="evalstone",
|
|
@@ -136,7 +138,7 @@ def run(
|
|
|
136
138
|
cfg = _load(config)
|
|
137
139
|
try:
|
|
138
140
|
s, target = cfg.suite(suite)
|
|
139
|
-
except SUITE_ERRORS as exc:
|
|
141
|
+
except (*SUITE_ERRORS, CenterError) as exc:
|
|
140
142
|
err.print(f"[red]{escape(str(exc))}[/red]")
|
|
141
143
|
raise typer.Exit(2) from None
|
|
142
144
|
base = _load_run(baseline) if baseline else None
|
|
@@ -147,20 +149,48 @@ def run(
|
|
|
147
149
|
except ProviderError as exc:
|
|
148
150
|
err.print(f"[red]--judge-model: {escape(str(exc))}[/red]")
|
|
149
151
|
raise typer.Exit(2) from None
|
|
152
|
+
try:
|
|
153
|
+
ctx = cfg.context(judge_model=judge_model)
|
|
154
|
+
except CenterError as exc:
|
|
155
|
+
err.print(f"[red]{escape(str(exc))}[/red]")
|
|
156
|
+
raise typer.Exit(2) from None
|
|
150
157
|
with console.status(f"running {suite} over {len(s.dataset)} samples…"):
|
|
151
|
-
result = s.run(
|
|
152
|
-
target, baseline=base, trigger="cli", ctx=cfg.context(judge_model=judge_model)
|
|
153
|
-
)
|
|
158
|
+
result = s.run(target, baseline=base, trigger="cli", ctx=ctx)
|
|
154
159
|
console.print(_run_table(result))
|
|
160
|
+
_warn_cache_errors(ctx)
|
|
155
161
|
_print_failures(result, failures)
|
|
156
162
|
_print_gate(result)
|
|
157
163
|
out = report or (cfg.root / cfg.report)
|
|
158
164
|
result.to_json(out)
|
|
159
165
|
console.print(f"\nreport → {out}")
|
|
166
|
+
_push_to_center(cfg, result)
|
|
160
167
|
if strict and result.gate_passed is False:
|
|
161
168
|
raise typer.Exit(1)
|
|
162
169
|
|
|
163
170
|
|
|
171
|
+
def _warn_cache_errors(ctx: Context) -> None:
|
|
172
|
+
"""Say so when the center cache was unreachable: those verdicts were paid for."""
|
|
173
|
+
errors = getattr(ctx.cache, "errors", None)
|
|
174
|
+
if errors:
|
|
175
|
+
err.print(
|
|
176
|
+
f"[yellow]center cache: {len(errors)} request(s) failed "
|
|
177
|
+
f"(first: {escape(errors[0][:160])}) — judges paid for those verdicts[/yellow]"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _push_to_center(cfg: Config, result: Run) -> None:
|
|
182
|
+
"""Attached projects store every run in the center; a push failure warns, never fails."""
|
|
183
|
+
if cfg.center is None or not cfg.center.push_runs:
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
stored, created = cfg.center_client().push_run(result.model_dump(mode="json"))
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
err.print(f"[yellow]center: run not pushed — {escape(str(exc))}[/yellow]")
|
|
189
|
+
return
|
|
190
|
+
verb = "stored" if created else "already stored"
|
|
191
|
+
console.print(f"center → run {stored.get('id', '?')} {verb} ({cfg.center.url})")
|
|
192
|
+
|
|
193
|
+
|
|
164
194
|
@app.command()
|
|
165
195
|
def gate(
|
|
166
196
|
suite: str = typer.Argument(..., help="Suite name from evalstone.yaml"),
|
|
@@ -29,13 +29,14 @@ from typing import Any
|
|
|
29
29
|
import yaml
|
|
30
30
|
from pydantic import BaseModel, Field, field_validator
|
|
31
31
|
|
|
32
|
+
from evalstone.center import CenterCache, CenterClient, CenterConfig, CenterProviders
|
|
32
33
|
from evalstone.checks import comparison, deterministic, rag, registry, safety, trajectory
|
|
33
34
|
from evalstone.checks.human import human as human_factory
|
|
34
35
|
from evalstone.checks.judge import judge as judge_factory
|
|
35
36
|
from evalstone.dataset import Dataset
|
|
36
37
|
from evalstone.gate import Gate
|
|
37
38
|
from evalstone.providers import ProviderConfig, Providers
|
|
38
|
-
from evalstone.score import Check, Context
|
|
39
|
+
from evalstone.score import Check, Context, MemoryCache
|
|
39
40
|
from evalstone.suite import Suite
|
|
40
41
|
from evalstone.target import Target
|
|
41
42
|
from evalstone.target import target as make_target
|
|
@@ -70,18 +71,51 @@ class Config(BaseModel):
|
|
|
70
71
|
suites: dict[str, SuiteConfig] = Field(default_factory=dict)
|
|
71
72
|
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
|
|
72
73
|
judges: JudgesConfig = Field(default_factory=JudgesConfig)
|
|
74
|
+
center: CenterConfig | None = None # attached: keys, judge model and cache live there
|
|
73
75
|
# model → (USD per 1M input tokens, USD per 1M output tokens); overrides the built-in table
|
|
74
76
|
prices: dict[str, tuple[float, float]] = Field(default_factory=dict)
|
|
75
77
|
report: str = "evalstone-report.json"
|
|
76
78
|
root: Path = Field(default=Path("."), exclude=True)
|
|
77
|
-
_providers: Providers | None = None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
_providers: Providers | CenterProviders | None = None
|
|
80
|
+
_local_providers: Providers | None = None
|
|
81
|
+
_center_client: CenterClient | None = None
|
|
82
|
+
_judge_settings: dict[str, Any] | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def attached(self) -> bool:
|
|
86
|
+
return self.center is not None
|
|
87
|
+
|
|
88
|
+
def center_client(self) -> CenterClient:
|
|
89
|
+
if self.center is None:
|
|
90
|
+
raise ValueError("this project is not attached to a center (no `center:` block)")
|
|
91
|
+
if self._center_client is None:
|
|
92
|
+
self._center_client = CenterClient(self.center)
|
|
93
|
+
return self._center_client
|
|
94
|
+
|
|
95
|
+
def judge_settings(self) -> dict[str, Any]:
|
|
96
|
+
"""The center's judge settings for this project, fetched once per config."""
|
|
97
|
+
if self._judge_settings is None:
|
|
98
|
+
self._judge_settings = self.center_client().judge_settings()
|
|
99
|
+
return self._judge_settings
|
|
100
|
+
|
|
101
|
+
def local_provider_registry(self) -> Providers:
|
|
102
|
+
"""The standalone registry: `providers:` + env keys. Prompt targets always use it —
|
|
103
|
+
the system under test is never routed through the center's judge proxy."""
|
|
104
|
+
if self._local_providers is None:
|
|
105
|
+
self._local_providers = Providers(
|
|
83
106
|
self.providers, default_model=self.judges.default_model, prices=self.prices
|
|
84
107
|
)
|
|
108
|
+
return self._local_providers
|
|
109
|
+
|
|
110
|
+
def provider_registry(self) -> Providers | CenterProviders:
|
|
111
|
+
"""The judges' registry: the center's proxy when attached, else the local one."""
|
|
112
|
+
if self._providers is None:
|
|
113
|
+
if self.center is not None:
|
|
114
|
+
self._providers = CenterProviders(
|
|
115
|
+
self.center_client(), default_model=self.judge_settings().get("default_model")
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
self._providers = self.local_provider_registry()
|
|
85
119
|
return self._providers
|
|
86
120
|
|
|
87
121
|
def context(self, *, judge_model: str | None = None) -> Context:
|
|
@@ -89,9 +123,19 @@ class Config(BaseModel):
|
|
|
89
123
|
|
|
90
124
|
``judge_model`` overrides every judge's model for this context only (the
|
|
91
125
|
CLI's ``--judge-model``) — judges and pairwise comparisons, never the target
|
|
92
|
-
under test: the same suite, a different judge, one flag.
|
|
126
|
+
under test: the same suite, a different judge, one flag. Attached, the cache is
|
|
127
|
+
the center's and the project's per-check judge models ride in ``extra``.
|
|
93
128
|
"""
|
|
94
|
-
|
|
129
|
+
if self.center is None:
|
|
130
|
+
return Context(providers=self.provider_registry(), judge_model=judge_model)
|
|
131
|
+
client = self.center_client()
|
|
132
|
+
settings = self.judge_settings()
|
|
133
|
+
return Context(
|
|
134
|
+
providers=self.provider_registry(),
|
|
135
|
+
judge_model=judge_model,
|
|
136
|
+
cache=CenterCache(client) if self.center.cache else MemoryCache(),
|
|
137
|
+
extra={"center_client": client, "judge_models": dict(settings.get("overrides") or {})},
|
|
138
|
+
)
|
|
95
139
|
|
|
96
140
|
# ── loading ──────────────────────────────────────────────────────────
|
|
97
141
|
|
|
@@ -138,7 +182,11 @@ class Config(BaseModel):
|
|
|
138
182
|
known = ", ".join(sorted(FACTORIES))
|
|
139
183
|
raise ValueError(f"check {key!r}: unknown type {kind!r} (known: {known})")
|
|
140
184
|
if kind == "human": # the labels file lives relative to evalstone.yaml
|
|
141
|
-
|
|
185
|
+
labels = spec.get("labels")
|
|
186
|
+
if labels == "center" or (labels is None and self.center is not None):
|
|
187
|
+
spec["labels"] = "center" # attached: labels live in the center
|
|
188
|
+
else:
|
|
189
|
+
spec["labels"] = self.root / (labels or "labels.jsonl")
|
|
142
190
|
baseline = spec.get("baseline")
|
|
143
191
|
if kind == "comparison.pairwise" and isinstance(baseline, dict) and "kind" in baseline:
|
|
144
192
|
spec["baseline"] = self.target(baseline) # a target spec → a Target
|
|
@@ -162,7 +210,7 @@ class Config(BaseModel):
|
|
|
162
210
|
"prompt target needs its own `model` — it is the system under test and must "
|
|
163
211
|
"not silently share judges.default_model (a model grading itself)"
|
|
164
212
|
)
|
|
165
|
-
spec["providers"] = self.
|
|
213
|
+
spec["providers"] = self.local_provider_registry()
|
|
166
214
|
return make_target(kind, **spec)
|
|
167
215
|
|
|
168
216
|
def suite(self, name: str) -> tuple[Suite, Target]:
|
|
@@ -74,6 +74,8 @@ class Session:
|
|
|
74
74
|
|
|
75
75
|
def config(self) -> Config:
|
|
76
76
|
cfg = Config.load(self.config_path or CONFIG_FILENAME)
|
|
77
|
+
if cfg.attached:
|
|
78
|
+
return cfg # judges go through the center's proxy; targets use cfg's local registry
|
|
77
79
|
key = canonical(
|
|
78
80
|
{
|
|
79
81
|
"providers": {k: v.model_dump(mode="json") for k, v in cfg.providers.items()},
|
|
@@ -91,6 +93,8 @@ class Session:
|
|
|
91
93
|
return cfg
|
|
92
94
|
|
|
93
95
|
def context(self, cfg: Config) -> Context:
|
|
96
|
+
if cfg.attached:
|
|
97
|
+
return cfg.context() # center proxy, center cache, per-check overrides, labels
|
|
94
98
|
return Context(providers=cfg.provider_registry(), cache=self.cache)
|
|
95
99
|
|
|
96
100
|
def root(self) -> Path:
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""The center attach: the proxy as a Providers, the center as a ScoreCache, labels, run push."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
import respx
|
|
10
|
+
|
|
11
|
+
from evalstone.center import CenterCache, CenterClient, CenterConfig, CenterError, CenterProviders
|
|
12
|
+
from evalstone.checks.human import human, output_hash
|
|
13
|
+
from evalstone.checks.judge import judge
|
|
14
|
+
from evalstone.config import Config
|
|
15
|
+
from evalstone.dataset import Sample
|
|
16
|
+
from evalstone.output import Output
|
|
17
|
+
from evalstone.providers import ChatMessage, ProviderError
|
|
18
|
+
from evalstone.score import Context, Score
|
|
19
|
+
|
|
20
|
+
URL = "http://center.test"
|
|
21
|
+
PROJECT = "11111111-2222-4333-8444-555555555555"
|
|
22
|
+
P = f"{URL}/projects/{PROJECT}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def cfg(monkeypatch):
|
|
27
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
28
|
+
return CenterConfig(url=URL + "/", project=PROJECT)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _verdict(text='{"score": 4, "reason": "fine"}', model="small"):
|
|
32
|
+
return httpx.Response(
|
|
33
|
+
200,
|
|
34
|
+
json={
|
|
35
|
+
"text": text,
|
|
36
|
+
"model": model,
|
|
37
|
+
"input_tokens": 10,
|
|
38
|
+
"output_tokens": 2,
|
|
39
|
+
"cost_usd": 0.00001,
|
|
40
|
+
"latency_ms": 12.5,
|
|
41
|
+
"stop_reason": "stop",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_client_needs_a_key(monkeypatch):
|
|
47
|
+
monkeypatch.delenv("EVALSTONE_API_KEY", raising=False)
|
|
48
|
+
with pytest.raises(CenterError, match="EVALSTONE_API_KEY"):
|
|
49
|
+
CenterClient(CenterConfig(url=URL, project=PROJECT))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@respx.mock
|
|
53
|
+
def test_providers_complete_posts_the_proxy_body_with_the_bearer(cfg):
|
|
54
|
+
route = respx.post(f"{P}/model/complete").mock(return_value=_verdict())
|
|
55
|
+
providers = CenterProviders(CenterClient(cfg), default_model="judge/small")
|
|
56
|
+
resp = providers.complete(
|
|
57
|
+
None,
|
|
58
|
+
{"system": "Grade.", "messages": [ChatMessage(role="user", content="x")], "max_tokens": 5},
|
|
59
|
+
)
|
|
60
|
+
assert resp.text.startswith('{"score": 4') and resp.cost_usd == pytest.approx(0.00001)
|
|
61
|
+
sent = route.calls[0].request
|
|
62
|
+
assert sent.headers["Authorization"] == "Bearer es_test_key"
|
|
63
|
+
body = json.loads(sent.content)
|
|
64
|
+
assert body["model"] == "judge/small" and body["system"] == "Grade." and body["max_tokens"] == 5
|
|
65
|
+
assert body["messages"] == [{"role": "user", "content": "x"}]
|
|
66
|
+
assert providers.split("other/big") == ("other", "big")
|
|
67
|
+
with pytest.raises(ProviderError, match="<provider>/<model>"):
|
|
68
|
+
providers.split("bare")
|
|
69
|
+
with pytest.raises(ProviderError, match="default judge model"):
|
|
70
|
+
CenterProviders(CenterClient(cfg)).split(None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@respx.mock
|
|
74
|
+
def test_center_errors_become_provider_errors(cfg):
|
|
75
|
+
respx.post(f"{P}/model/complete").mock(
|
|
76
|
+
return_value=httpx.Response(502, json={"detail": "provider 'judge' failed: HTTP 401"})
|
|
77
|
+
)
|
|
78
|
+
providers = CenterProviders(CenterClient(cfg))
|
|
79
|
+
with pytest.raises(ProviderError, match="HTTP 502: provider 'judge' failed"):
|
|
80
|
+
providers.complete("judge/small", {"messages": [ChatMessage(role="user", content="x")]})
|
|
81
|
+
respx.post(f"{P}/model/complete").mock(side_effect=httpx.ConnectError("refused"))
|
|
82
|
+
with pytest.raises(ProviderError, match="ConnectError"):
|
|
83
|
+
providers.complete("judge/small", {"messages": [ChatMessage(role="user", content="x")]})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@respx.mock
|
|
87
|
+
def test_cache_get_set_and_tolerance(cfg):
|
|
88
|
+
key = "a" * 64
|
|
89
|
+
get = respx.get(f"{P}/judge-cache/{key}").mock(return_value=httpx.Response(404))
|
|
90
|
+
put = respx.put(f"{P}/judge-cache/{key}").mock(return_value=httpx.Response(201, json={}))
|
|
91
|
+
cache = CenterCache(CenterClient(cfg))
|
|
92
|
+
assert cache.get(key) is None
|
|
93
|
+
score = Score(value=0.75, passed=True, reason="4/5", raw={"score": 4, "judge_version": "j1"})
|
|
94
|
+
cache.set(key, score)
|
|
95
|
+
assert json.loads(put.calls[0].request.content)["judge_version"] == "j1"
|
|
96
|
+
assert cache.get(key) == score and get.call_count == 1 # the local layer answers now
|
|
97
|
+
other = "b" * 64
|
|
98
|
+
respx.get(f"{P}/judge-cache/{other}").mock(
|
|
99
|
+
return_value=httpx.Response(200, json={"score": score.model_dump(mode="json")})
|
|
100
|
+
)
|
|
101
|
+
assert cache.get(other) == score
|
|
102
|
+
down = "c" * 64
|
|
103
|
+
respx.get(f"{P}/judge-cache/{down}").mock(side_effect=httpx.ConnectError("down"))
|
|
104
|
+
assert cache.get(down) is None and cache.errors # a hiccup is a miss, not a failure
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@respx.mock
|
|
108
|
+
def test_judge_through_the_center_uses_overrides_and_caches_there(cfg):
|
|
109
|
+
respx.get(f"{P}/judge-cache/__").mock(return_value=httpx.Response(404))
|
|
110
|
+
respx.route(method="GET", path__regex=r"/judge-cache/.*").mock(return_value=httpx.Response(404))
|
|
111
|
+
puts = respx.route(method="PUT", path__regex=r"/judge-cache/.*").mock(
|
|
112
|
+
return_value=httpx.Response(201, json={})
|
|
113
|
+
)
|
|
114
|
+
calls = respx.post(f"{P}/model/complete").mock(return_value=_verdict())
|
|
115
|
+
client = CenterClient(cfg)
|
|
116
|
+
ctx = Context(
|
|
117
|
+
providers=CenterProviders(client, default_model="judge/small"),
|
|
118
|
+
cache=CenterCache(client),
|
|
119
|
+
extra={"center_client": client, "judge_models": {"strict": "other/big"}},
|
|
120
|
+
)
|
|
121
|
+
check = judge("strict", "Be strict.")
|
|
122
|
+
score = check.evaluate(Sample(input="q"), Output(value="a"), ctx)
|
|
123
|
+
assert score.passed and score.value == 0.75
|
|
124
|
+
assert json.loads(calls.calls[0].request.content)["model"] == "other/big" # the override
|
|
125
|
+
assert puts.call_count == 1 # the verdict went to the center
|
|
126
|
+
assert judge("plain", "Be nice.").evaluate(Sample(input="q"), Output(value="a"), ctx).passed
|
|
127
|
+
assert json.loads(calls.calls[1].request.content)["model"] == "judge/small" # the default
|
|
128
|
+
ctx2 = ctx.model_copy(update={"judge_model": "cli/flag"})
|
|
129
|
+
judge("strict", "Be strict.").evaluate(Sample(input="q"), Output(value="a"), ctx2)
|
|
130
|
+
assert json.loads(calls.calls[2].request.content)["model"] == "cli/flag" # --judge-model wins
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@respx.mock
|
|
134
|
+
def test_human_labels_from_the_center(cfg):
|
|
135
|
+
client = CenterClient(cfg)
|
|
136
|
+
ctx = Context(extra={"center_client": client})
|
|
137
|
+
sample, out = Sample(id="s1", input="q"), Output(value="the answer")
|
|
138
|
+
route = respx.get(f"{P}/labels/lookup").mock(return_value=httpx.Response(404))
|
|
139
|
+
check = human(labels="center", check="helpful")
|
|
140
|
+
missing = check.evaluate(sample, out, ctx)
|
|
141
|
+
assert missing.error and "no human label in the center" in missing.error
|
|
142
|
+
assert route.calls[0].request.url.params["output_hash"] == output_hash(out)
|
|
143
|
+
assert route.calls[0].request.url.params["check"] == "helpful"
|
|
144
|
+
respx.get(f"{P}/labels/lookup").mock(
|
|
145
|
+
return_value=httpx.Response(
|
|
146
|
+
200, json={"value": 1.0, "passed": True, "notes": "good", "labeler": "ada", "raw": 5}
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
got = check.evaluate(sample, out, ctx)
|
|
150
|
+
assert got.passed and got.reason == "good" and got.raw["labeler"] == "ada"
|
|
151
|
+
unattached = human(labels="center").evaluate(sample, out, Context())
|
|
152
|
+
assert unattached.error and "not attached" in unattached.error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@respx.mock
|
|
156
|
+
def test_config_attached_wires_registry_cache_and_human(tmp_path, monkeypatch):
|
|
157
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
158
|
+
respx.get(f"{P}/judge-settings").mock(
|
|
159
|
+
return_value=httpx.Response(
|
|
160
|
+
200,
|
|
161
|
+
json={"project_id": PROJECT, "default_model": "judge/small", "overrides": {"h": "o/b"}},
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
165
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\n"
|
|
166
|
+
"checks:\n h:\n type: human\n f:\n type: human\n labels: my.jsonl\n"
|
|
167
|
+
)
|
|
168
|
+
cfg = Config.load(tmp_path)
|
|
169
|
+
assert cfg.attached
|
|
170
|
+
ctx = cfg.context()
|
|
171
|
+
assert (
|
|
172
|
+
isinstance(ctx.providers, CenterProviders) and ctx.providers.default_model == "judge/small"
|
|
173
|
+
)
|
|
174
|
+
assert isinstance(ctx.cache, CenterCache)
|
|
175
|
+
assert (
|
|
176
|
+
ctx.extra["judge_models"] == {"h": "o/b"}
|
|
177
|
+
and ctx.extra["center_client"] is cfg.center_client()
|
|
178
|
+
)
|
|
179
|
+
assert cfg.check("h").evaluate(Sample(id="s", input="q"), Output(value="a"), Context()).error
|
|
180
|
+
file_mode = cfg.check("f").evaluate(Sample(id="s", input="q"), Output(value="a"), Context())
|
|
181
|
+
assert "my.jsonl" in file_mode.error
|
|
182
|
+
assert Config.model_validate({"project": "x"}).attached is False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@respx.mock
|
|
186
|
+
def test_cli_run_pushes_the_report(tmp_path, monkeypatch):
|
|
187
|
+
from typer.testing import CliRunner
|
|
188
|
+
|
|
189
|
+
from evalstone.cli import app
|
|
190
|
+
|
|
191
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
192
|
+
respx.get(f"{P}/judge-settings").mock(
|
|
193
|
+
return_value=httpx.Response(
|
|
194
|
+
200, json={"project_id": PROJECT, "default_model": None, "overrides": {}}
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
pushed = respx.post(f"{P}/runs").mock(return_value=httpx.Response(201, json={"id": "run-1"}))
|
|
198
|
+
(tmp_path / "golden.jsonl").write_text('{"input": "hi", "expected": "hi"}\n')
|
|
199
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
200
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\n"
|
|
201
|
+
"datasets:\n golden: golden.jsonl\n"
|
|
202
|
+
"checks:\n same:\n type: exact\n"
|
|
203
|
+
"suites:\n ci:\n dataset: golden\n"
|
|
204
|
+
" target: {kind: recorded, outputs: {}}\n checks: [same]\n"
|
|
205
|
+
)
|
|
206
|
+
monkeypatch.chdir(tmp_path)
|
|
207
|
+
result = CliRunner().invoke(app, ["run", "ci"])
|
|
208
|
+
assert result.exit_code == 0, result.output
|
|
209
|
+
assert "center → run run-1 stored" in result.output
|
|
210
|
+
body = json.loads(pushed.calls[0].request.content)
|
|
211
|
+
assert body["suite"] == "ci" and body["results"]
|
|
212
|
+
pushed.mock(return_value=httpx.Response(500, json={"detail": "boom"}))
|
|
213
|
+
result = CliRunner().invoke(app, ["run", "ci"])
|
|
214
|
+
assert result.exit_code == 0 # a failed push never fails the run
|
|
215
|
+
assert "run not pushed" in result.output + getattr(result, "stderr", "")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# --- regressions from the #33 review ---------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@respx.mock
|
|
222
|
+
def test_explicit_labels_center_in_yaml_reaches_the_center(tmp_path, monkeypatch):
|
|
223
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
224
|
+
respx.get(f"{P}/judge-settings").mock(
|
|
225
|
+
return_value=httpx.Response(
|
|
226
|
+
200, json={"project_id": PROJECT, "default_model": None, "overrides": {}}
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
lookup = respx.get(f"{P}/labels/lookup").mock(return_value=httpx.Response(404))
|
|
230
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
231
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\n"
|
|
232
|
+
"checks:\n h:\n type: human\n labels: center\n"
|
|
233
|
+
)
|
|
234
|
+
cfg = Config.load(tmp_path)
|
|
235
|
+
score = cfg.check("h").evaluate(Sample(id="s", input="q"), Output(value="a"), cfg.context())
|
|
236
|
+
assert "in the center" in score.error and lookup.call_count == 1
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@respx.mock
|
|
240
|
+
def test_mcp_session_honours_the_attach(tmp_path, monkeypatch):
|
|
241
|
+
from evalstone.mcp_server import Session
|
|
242
|
+
|
|
243
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
244
|
+
respx.get(f"{P}/judge-settings").mock(
|
|
245
|
+
return_value=httpx.Response(
|
|
246
|
+
200, json={"project_id": PROJECT, "default_model": "j/m", "overrides": {}}
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
250
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\n"
|
|
251
|
+
)
|
|
252
|
+
session = Session(config_path=str(tmp_path / "evalstone.yaml"))
|
|
253
|
+
cfg = session.config()
|
|
254
|
+
ctx = session.context(cfg)
|
|
255
|
+
assert isinstance(ctx.providers, CenterProviders) and isinstance(ctx.cache, CenterCache)
|
|
256
|
+
assert ctx.extra["center_client"] is cfg.center_client()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_cli_center_error_while_building_the_suite_is_a_clean_exit(tmp_path, monkeypatch):
|
|
260
|
+
from typer.testing import CliRunner
|
|
261
|
+
|
|
262
|
+
from evalstone.cli import app
|
|
263
|
+
|
|
264
|
+
monkeypatch.delenv("EVALSTONE_API_KEY", raising=False)
|
|
265
|
+
(tmp_path / "golden.jsonl").write_text('{"input": "hi"}\n')
|
|
266
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
267
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\n"
|
|
268
|
+
"datasets:\n golden: golden.jsonl\n"
|
|
269
|
+
"checks:\n j:\n type: judge\n rubric: ok?\n"
|
|
270
|
+
"suites:\n ci:\n dataset: golden\n"
|
|
271
|
+
" target: {kind: prompt, model: openai/x, template: '{input}'}\n checks: [j]\n"
|
|
272
|
+
)
|
|
273
|
+
monkeypatch.chdir(tmp_path)
|
|
274
|
+
result = CliRunner().invoke(app, ["run", "ci"])
|
|
275
|
+
assert result.exit_code == 2 and "EVALSTONE_API_KEY" in result.output + getattr(
|
|
276
|
+
result, "stderr", ""
|
|
277
|
+
)
|
|
278
|
+
assert "Traceback" not in result.output
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@respx.mock
|
|
282
|
+
def test_cache_treats_a_corrupt_row_and_a_non_object_error_as_misses(cfg):
|
|
283
|
+
key = "d" * 64
|
|
284
|
+
respx.get(f"{P}/judge-cache/{key}").mock(
|
|
285
|
+
return_value=httpx.Response(200, json={"score": {"value": 1.2, "passed": True}}) # invalid
|
|
286
|
+
)
|
|
287
|
+
cache = CenterCache(CenterClient(cfg))
|
|
288
|
+
assert cache.get(key) is None and "ValidationError" in cache.errors[0]
|
|
289
|
+
other = "e" * 64
|
|
290
|
+
respx.get(f"{P}/judge-cache/{other}").mock(
|
|
291
|
+
return_value=httpx.Response(504, json=["upstream timeout"])
|
|
292
|
+
)
|
|
293
|
+
assert cache.get(other) is None and "upstream timeout" in cache.errors[1]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@respx.mock
|
|
297
|
+
def test_pairwise_honours_the_center_override(cfg):
|
|
298
|
+
from evalstone.checks.comparison import pairwise
|
|
299
|
+
|
|
300
|
+
calls = respx.post(f"{P}/model/complete").mock(
|
|
301
|
+
return_value=_verdict(text='{"winner": "A", "reason": "clearer"}')
|
|
302
|
+
)
|
|
303
|
+
client = CenterClient(cfg)
|
|
304
|
+
ctx = Context(
|
|
305
|
+
providers=CenterProviders(client, default_model="judge/small"),
|
|
306
|
+
extra={"center_client": client, "judge_models": {"better": "other/big"}},
|
|
307
|
+
)
|
|
308
|
+
check = pairwise(
|
|
309
|
+
"better", "Which is clearer?", baseline={"s": "baseline answer"}, model="judge/small"
|
|
310
|
+
)
|
|
311
|
+
check.evaluate(Sample(id="s", input="q"), Output(value="candidate"), ctx)
|
|
312
|
+
assert json.loads(calls.calls[0].request.content)["model"] == "other/big"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@respx.mock
|
|
316
|
+
def test_prompt_targets_stay_local_when_attached(tmp_path, monkeypatch):
|
|
317
|
+
from evalstone.providers import Providers
|
|
318
|
+
|
|
319
|
+
monkeypatch.setenv("EVALSTONE_API_KEY", "es_test_key")
|
|
320
|
+
respx.get(f"{P}/judge-settings").mock(
|
|
321
|
+
return_value=httpx.Response(
|
|
322
|
+
200, json={"project_id": PROJECT, "default_model": None, "overrides": {}}
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
(tmp_path / "evalstone.yaml").write_text(
|
|
326
|
+
f"project: demo\ncenter:\n url: {URL}\n project: {PROJECT}\nproviders:\n openai: {{}}\n"
|
|
327
|
+
)
|
|
328
|
+
cfg = Config.load(tmp_path)
|
|
329
|
+
target = cfg.target({"kind": "prompt", "model": "openai/x", "template": "{input}"})
|
|
330
|
+
assert isinstance(target.providers, Providers) # the local registry, not the proxy
|
|
331
|
+
assert isinstance(cfg.provider_registry(), CenterProviders) # judges: the proxy
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@respx.mock
|
|
335
|
+
def test_cache_lookups_use_the_short_timeout_and_the_cli_warns(cfg):
|
|
336
|
+
key = "f" * 64
|
|
337
|
+
route = respx.get(f"{P}/judge-cache/{key}").mock(side_effect=httpx.ReadTimeout("slow"))
|
|
338
|
+
cache = CenterCache(CenterClient(cfg))
|
|
339
|
+
assert cache.get(key) is None
|
|
340
|
+
assert route.calls[0].request.extensions["timeout"]["read"] == cfg.cache_timeout
|
|
341
|
+
from evalstone.cli import _warn_cache_errors
|
|
342
|
+
|
|
343
|
+
_warn_cache_errors(Context(cache=cache)) # prints a warning; must not raise
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|