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.
Files changed (57) hide show
  1. {evalstone-0.1.1 → evalstone-0.2.0}/.gitignore +3 -0
  2. {evalstone-0.1.1 → evalstone-0.2.0}/CHANGELOG.md +18 -0
  3. {evalstone-0.1.1 → evalstone-0.2.0}/PKG-INFO +1 -1
  4. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/__init__.py +1 -1
  5. evalstone-0.2.0/src/evalstone/center.py +253 -0
  6. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/_base.py +8 -0
  7. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/comparison.py +2 -2
  8. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/human.py +32 -1
  9. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/judge.py +2 -2
  10. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/cli.py +34 -4
  11. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/config.py +59 -11
  12. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/mcp_server.py +4 -0
  13. evalstone-0.2.0/tests/test_center.py +343 -0
  14. {evalstone-0.1.1 → evalstone-0.2.0}/LICENSE +0 -0
  15. {evalstone-0.1.1 → evalstone-0.2.0}/README.md +0 -0
  16. {evalstone-0.1.1 → evalstone-0.2.0}/justfile +0 -0
  17. {evalstone-0.1.1 → evalstone-0.2.0}/pyproject.toml +0 -0
  18. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/__main__.py +0 -0
  19. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/_git.py +0 -0
  20. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/_json.py +0 -0
  21. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/__init__.py +0 -0
  22. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/deterministic.py +0 -0
  23. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/rag.py +0 -0
  24. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/safety.py +0 -0
  25. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/checks/trajectory.py +0 -0
  26. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/dataset.py +0 -0
  27. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/gate.py +0 -0
  28. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/output.py +0 -0
  29. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/__init__.py +0 -0
  30. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/_http.py +0 -0
  31. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/anthropic_.py +0 -0
  32. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/gemini.py +0 -0
  33. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/providers/openai_compat.py +0 -0
  34. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/pytest_plugin.py +0 -0
  35. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/run.py +0 -0
  36. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/score.py +0 -0
  37. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/suite.py +0 -0
  38. {evalstone-0.1.1 → evalstone-0.2.0}/src/evalstone/target.py +0 -0
  39. {evalstone-0.1.1 → evalstone-0.2.0}/tests/conftest.py +0 -0
  40. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_action.py +0 -0
  41. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_cli.py +0 -0
  42. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_config.py +0 -0
  43. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_dataset.py +0 -0
  44. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_deterministic.py +0 -0
  45. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_docs.py +0 -0
  46. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_human.py +0 -0
  47. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_judge.py +0 -0
  48. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_judge_switching.py +0 -0
  49. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_mcp.py +0 -0
  50. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_providers.py +0 -0
  51. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_pytest_plugin.py +0 -0
  52. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_rag_and_comparison.py +0 -0
  53. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_safety.py +0 -0
  54. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_score_and_checks.py +0 -0
  55. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_suite_run_gate.py +0 -0
  56. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_target.py +0 -0
  57. {evalstone-0.1.1 → evalstone-0.2.0}/tests/test_trajectory.py +0 -0
@@ -42,3 +42,6 @@ npm-debug.log*
42
42
 
43
43
  # ── Evalstone run reports (written by `evalstone run`) ──────────────────
44
44
  **/evalstone-report.json
45
+
46
+ # ── Celery beat state (written by `just beat`) ───────────────────────────
47
+ **/celerybeat-schedule*
@@ -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.1.1
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.1.1"
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.judge_model or model,
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.judge_model or model # a run-time override beats the check's own model
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
- def provider_registry(self) -> Providers:
80
- """One ``Providers`` per config (adapters are created lazily, keys read from env)."""
81
- if self._providers is None:
82
- self._providers = Providers(
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
- return Context(providers=self.provider_registry(), judge_model=judge_model)
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
- spec["labels"] = self.root / spec.get("labels", "labels.jsonl")
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.provider_registry()
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