evalstone 0.1.0__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.0 → evalstone-0.2.0}/.gitignore +3 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/CHANGELOG.md +46 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/PKG-INFO +3 -3
- {evalstone-0.1.0 → evalstone-0.2.0}/README.md +2 -2
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/__init__.py +1 -1
- evalstone-0.2.0/src/evalstone/center.py +253 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/_base.py +8 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/comparison.py +13 -4
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/human.py +32 -1
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/judge.py +4 -3
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/cli.py +58 -3
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/config.py +67 -12
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/gate.py +24 -1
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/mcp_server.py +4 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/__init__.py +31 -5
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/_http.py +10 -2
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/anthropic_.py +1 -1
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/run.py +5 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/score.py +9 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/suite.py +13 -1
- evalstone-0.2.0/tests/test_center.py +343 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_docs.py +20 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_human.py +1 -1
- evalstone-0.2.0/tests/test_judge_switching.py +317 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_rag_and_comparison.py +5 -2
- {evalstone-0.1.0 → evalstone-0.2.0}/LICENSE +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/justfile +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/pyproject.toml +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/__main__.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/_git.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/_json.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/__init__.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/deterministic.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/rag.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/safety.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/checks/trajectory.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/dataset.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/output.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/gemini.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/providers/openai_compat.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/pytest_plugin.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/src/evalstone/target.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/conftest.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_action.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_cli.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_config.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_dataset.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_deterministic.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_judge.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_mcp.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_providers.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_pytest_plugin.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_safety.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_score_and_checks.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_suite_run_gate.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_target.py +0 -0
- {evalstone-0.1.0 → evalstone-0.2.0}/tests/test_trajectory.py +0 -0
|
@@ -2,6 +2,52 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.1.1 — 2026-10-13
|
|
24
|
+
|
|
25
|
+
Judge switching and cost honesty.
|
|
26
|
+
|
|
27
|
+
- **Named providers**: a `providers:` entry may name any adapter (`adapter:
|
|
28
|
+
openai | anthropic | gemini`), so `openrouter:`, `ollama:` and `openai:` can
|
|
29
|
+
coexist and a judge model reads `openrouter/anthropic/claude-haiku-5-5`.
|
|
30
|
+
Unknown provider names and adapters are reported with the known ones. A
|
|
31
|
+
named entry never falls back to the adapter's default key variable (one
|
|
32
|
+
vendor's key is never sent to another); a 401/403 without a key says so.
|
|
33
|
+
- **`--judge-model provider/model`** on `evalstone run` and `gate` overrides
|
|
34
|
+
every judge's and pairwise comparison's model for that run — never the
|
|
35
|
+
target under test (`Context.judge_model`; `Config.context(judge_model=…)`).
|
|
36
|
+
Validated before the run starts; a typo exits 2.
|
|
37
|
+
- **`short_circuit: true`** on a suite skips the judge checks on a sample that
|
|
38
|
+
a contract check (deterministic, safety, trajectory — not a human label,
|
|
39
|
+
which is a quality verdict the judge should still be compared against)
|
|
40
|
+
already failed. The skipped result is a failure, not an error
|
|
41
|
+
(`Score.skipped`, `RunSummary.skipped`, `CheckSummary.skipped`), so no paid
|
|
42
|
+
call is spent on an output that already failed and the error-rate gate is
|
|
43
|
+
not tripped. Off by default. Turning it on changes pass rates (a skipped
|
|
44
|
+
judge counts as a failure), so re-take your baseline when you enable it.
|
|
45
|
+
- **Cheap-first ordering** now includes safety rules and human labels, not only
|
|
46
|
+
deterministic and trajectory checks.
|
|
47
|
+
- **Gate**: with `max_cost_usd` set, a `WARN` line counts results from models
|
|
48
|
+
outside the price table (they count as $0). Gemini models priced from the
|
|
49
|
+
official table (2026-10-09); OpenAI models are set under `prices:`.
|
|
50
|
+
|
|
5
51
|
## 0.1.0 — 2026-10-13
|
|
6
52
|
|
|
7
53
|
The first usable release: the hero path from `evalstone.yaml` to a gated run.
|
|
@@ -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
|
|
@@ -65,8 +65,8 @@ evalstone gate nightly # exit 1 when the gate fails (CI)
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
A fuller example — a small retrieval bot with a gated suite — lives in the
|
|
68
|
-
repository under `samples/rag-bot` (`git clone` it, `
|
|
69
|
-
|
|
68
|
+
repository under `samples/rag-bot` (`git clone` it, then `evalstone run nightly`
|
|
69
|
+
inside the sample).
|
|
70
70
|
|
|
71
71
|
Judges and prompt targets need a provider key in the environment
|
|
72
72
|
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or any
|
|
@@ -28,8 +28,8 @@ evalstone gate nightly # exit 1 when the gate fails (CI)
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
A fuller example — a small retrieval bot with a gated suite — lives in the
|
|
31
|
-
repository under `samples/rag-bot` (`git clone` it, `
|
|
32
|
-
|
|
31
|
+
repository under `samples/rag-bot` (`git clone` it, then `evalstone run nightly`
|
|
32
|
+
inside the sample).
|
|
33
33
|
|
|
34
34
|
Judges and prompt targets need a provider key in the environment
|
|
35
35
|
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or any
|
|
@@ -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
|
|
@@ -64,11 +64,12 @@ def pairwise(
|
|
|
64
64
|
"""
|
|
65
65
|
base_target: Target = baseline if isinstance(baseline, Target) else RecordedTarget(baseline)
|
|
66
66
|
identity = {"v": 1, "rubric": rubric, "both_orders": both_orders}
|
|
67
|
+
unpriced: list[str] = [] # models of this sample's calls that the price table lacks
|
|
67
68
|
|
|
68
69
|
def ask(ctx: Context, sample_input: str, a: str, b: str) -> tuple[str, str, float, float]:
|
|
69
70
|
user = USER.format(input=sample_input, a=a, b=b)
|
|
70
71
|
resp = ctx.providers.complete(
|
|
71
|
-
model,
|
|
72
|
+
judge_model_for(ctx, key, model),
|
|
72
73
|
{
|
|
73
74
|
"system": SYSTEM.format(rubric=rubric.strip()),
|
|
74
75
|
"messages": [ChatMessage(role="user", content=user)],
|
|
@@ -76,13 +77,16 @@ def pairwise(
|
|
|
76
77
|
},
|
|
77
78
|
)
|
|
78
79
|
winner, reason = parse_winner(resp.text)
|
|
80
|
+
if resp.cost_usd is None:
|
|
81
|
+
unpriced.append(resp.model)
|
|
79
82
|
return winner, reason, resp.cost_usd or 0.0, resp.latency_ms
|
|
80
83
|
|
|
81
84
|
def fn(sample: Sample, output: Output, ctx: Context) -> Score:
|
|
82
85
|
if ctx.providers is None:
|
|
83
86
|
return Score.errored("pairwise: no providers configured")
|
|
87
|
+
unpriced.clear()
|
|
84
88
|
try:
|
|
85
|
-
resolved = "/".join(ctx.providers.split(model))
|
|
89
|
+
resolved = "/".join(ctx.providers.split(ctx.judge_model or model))
|
|
86
90
|
except ProviderError as exc:
|
|
87
91
|
return Score.errored(str(exc))
|
|
88
92
|
version = sha256({**identity, "model": resolved})[:16]
|
|
@@ -130,7 +134,12 @@ def pairwise(
|
|
|
130
134
|
reason=f"candidate {verdict}: " + " | ".join(r for r in reasons if r),
|
|
131
135
|
cost_usd=cost,
|
|
132
136
|
latency_ms=latency,
|
|
133
|
-
raw={
|
|
137
|
+
raw={
|
|
138
|
+
"points": points,
|
|
139
|
+
"orders": len(points),
|
|
140
|
+
"judge_version": version,
|
|
141
|
+
"unpriced": bool(unpriced),
|
|
142
|
+
},
|
|
134
143
|
)
|
|
135
144
|
if ctx.cache is not None:
|
|
136
145
|
ctx.cache.set(cache_key, score)
|
|
@@ -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,8 +134,9 @@ 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 = judge_model_for(ctx, key, model)
|
|
137
138
|
try:
|
|
138
|
-
resolved = "/".join(providers.split(
|
|
139
|
+
resolved = "/".join(providers.split(chosen))
|
|
139
140
|
except ProviderError as exc:
|
|
140
141
|
return Score.errored(str(exc))
|
|
141
142
|
judge_version = sha256({**identity, "model": resolved})[:16]
|
|
@@ -176,7 +177,7 @@ def judge(
|
|
|
176
177
|
)
|
|
177
178
|
try:
|
|
178
179
|
resp = providers.complete(
|
|
179
|
-
|
|
180
|
+
chosen,
|
|
180
181
|
{
|
|
181
182
|
"system": system,
|
|
182
183
|
"messages": [ChatMessage(role="user", content=user)],
|
|
@@ -14,8 +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
|
|
19
|
+
from evalstone.providers import ProviderError
|
|
18
20
|
from evalstone.run import DEFAULT_FAILURES, Run, compare
|
|
21
|
+
from evalstone.score import Context
|
|
19
22
|
|
|
20
23
|
app = typer.Typer(
|
|
21
24
|
name="evalstone",
|
|
@@ -28,6 +31,11 @@ err = Console(stderr=True, soft_wrap=True) # never wrap paths inside error mess
|
|
|
28
31
|
|
|
29
32
|
ConfigOpt = typer.Option("evalstone.yaml", "--config", "-c", help="Path to evalstone.yaml")
|
|
30
33
|
ReportOpt = typer.Option(None, "--report", "-r", help="Where to write the JSON report")
|
|
34
|
+
JudgeModelOpt = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--judge-model",
|
|
37
|
+
help="Override every judge's model for this run (provider/model), e.g. to compare judges",
|
|
38
|
+
)
|
|
31
39
|
BaselineOpt = typer.Option(
|
|
32
40
|
None, "--baseline", "-b", help="A previous report to gate regressions against"
|
|
33
41
|
)
|
|
@@ -124,36 +132,83 @@ def run(
|
|
|
124
132
|
baseline: Path | None = BaselineOpt,
|
|
125
133
|
failures: int = FailuresOpt,
|
|
126
134
|
strict: bool = typer.Option(False, "--strict", help="Exit 1 when the gate fails"),
|
|
135
|
+
judge_model: str | None = JudgeModelOpt,
|
|
127
136
|
) -> None:
|
|
128
137
|
"""Run a suite, print the table, write the JSON report."""
|
|
129
138
|
cfg = _load(config)
|
|
130
139
|
try:
|
|
131
140
|
s, target = cfg.suite(suite)
|
|
132
|
-
except SUITE_ERRORS as exc:
|
|
141
|
+
except (*SUITE_ERRORS, CenterError) as exc:
|
|
133
142
|
err.print(f"[red]{escape(str(exc))}[/red]")
|
|
134
143
|
raise typer.Exit(2) from None
|
|
135
144
|
base = _load_run(baseline) if baseline else None
|
|
145
|
+
if judge_model is not None:
|
|
146
|
+
try:
|
|
147
|
+
registry = cfg.provider_registry()
|
|
148
|
+
registry.get(registry.split(judge_model)[0])
|
|
149
|
+
except ProviderError as exc:
|
|
150
|
+
err.print(f"[red]--judge-model: {escape(str(exc))}[/red]")
|
|
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
|
|
136
157
|
with console.status(f"running {suite} over {len(s.dataset)} samples…"):
|
|
137
|
-
result = s.run(target, baseline=base, trigger="cli", ctx=
|
|
158
|
+
result = s.run(target, baseline=base, trigger="cli", ctx=ctx)
|
|
138
159
|
console.print(_run_table(result))
|
|
160
|
+
_warn_cache_errors(ctx)
|
|
139
161
|
_print_failures(result, failures)
|
|
140
162
|
_print_gate(result)
|
|
141
163
|
out = report or (cfg.root / cfg.report)
|
|
142
164
|
result.to_json(out)
|
|
143
165
|
console.print(f"\nreport → {out}")
|
|
166
|
+
_push_to_center(cfg, result)
|
|
144
167
|
if strict and result.gate_passed is False:
|
|
145
168
|
raise typer.Exit(1)
|
|
146
169
|
|
|
147
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
|
+
|
|
148
194
|
@app.command()
|
|
149
195
|
def gate(
|
|
150
196
|
suite: str = typer.Argument(..., help="Suite name from evalstone.yaml"),
|
|
151
197
|
config: str = ConfigOpt,
|
|
152
198
|
report: Path | None = ReportOpt,
|
|
153
199
|
baseline: Path | None = BaselineOpt,
|
|
200
|
+
judge_model: str | None = JudgeModelOpt,
|
|
154
201
|
) -> None:
|
|
155
202
|
"""Run a suite and exit 1 if its gate fails (for CI)."""
|
|
156
|
-
run(
|
|
203
|
+
run(
|
|
204
|
+
suite=suite,
|
|
205
|
+
config=config,
|
|
206
|
+
report=report,
|
|
207
|
+
baseline=baseline,
|
|
208
|
+
failures=10,
|
|
209
|
+
strict=True,
|
|
210
|
+
judge_model=judge_model,
|
|
211
|
+
)
|
|
157
212
|
|
|
158
213
|
|
|
159
214
|
@app.command("compare")
|