@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1
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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/suite/run.py
CHANGED
|
@@ -1,1042 +1,1046 @@
|
|
|
1
|
-
"""Run the local task suite against the configured models.
|
|
2
|
-
|
|
3
|
-
Each task is something that actually happened, with an answer that was
|
|
4
|
-
verified against source or a live database before it went into the code. So a
|
|
5
|
-
score here is "would this model have found what we found", which is the only
|
|
6
|
-
question worth asking of a model we are about to hand work to.
|
|
7
|
-
|
|
8
|
-
Scoring is keyword-based on purpose. A rubric graded by a model is a rubric
|
|
9
|
-
that model can be wrong about; a string either appears in the output or it
|
|
10
|
-
does not. Crude, reproducible, and it cannot flatter anybody.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import datetime as dt
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import pathlib
|
|
20
|
-
import re
|
|
21
|
-
import shutil
|
|
22
|
-
import subprocess
|
|
23
|
-
import sys
|
|
24
|
-
import time
|
|
25
|
-
|
|
26
|
-
HERE = pathlib.Path(__file__).resolve().parent
|
|
27
|
-
ROOT = HERE.parent
|
|
28
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
29
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
30
|
-
TASKS = DATA / "suite" / "tasks"
|
|
31
|
-
RUNS = DATA / "runs"
|
|
32
|
-
|
|
33
|
-
# THE CODEBASES THIS BENCHMARK IS DRAWN FROM.
|
|
34
|
-
#
|
|
35
|
-
# Private repositories, which is the entire point: nobody trained on them, so
|
|
36
|
-
# a score here cannot be inflated by memorisation the way every public
|
|
37
|
-
# benchmark's can. That is a property no public suite can offer at any price,
|
|
38
|
-
# and it gets stronger as more real work goes in.
|
|
39
|
-
#
|
|
40
|
-
# Tasks name a repo with `"repo": "<key>"`, else DEFAULT_REPO. They point at
|
|
41
|
-
# REAL FILES rather than copies, so a fixture cannot quietly go stale while the
|
|
42
|
-
# thing it copied moves on — the cost being that a task must be re-verified
|
|
43
|
-
# when the code under it changes. Which folder each key means is the install's
|
|
44
|
-
# own: config/repos.json in the data folder, `{"<key>": "<path>"}`. A key it
|
|
45
|
-
# does not list falls back to the task's own fixtures/ copies, which is how
|
|
46
|
-
# the public starter pack runs with no repositories at all.
|
|
47
|
-
DEFAULT_REPO = "default"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def repos() -> dict[str, pathlib.Path]:
|
|
51
|
-
try:
|
|
52
|
-
data = json.loads((DATA / "config" / "repos.json").read_text(encoding="utf-8"))
|
|
53
|
-
except (OSError, ValueError):
|
|
54
|
-
return {}
|
|
55
|
-
return {k: pathlib.Path(v) for k, v in data.items() if not k.startswith("_") and isinstance(v, str)}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
REPOS = repos()
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class MissingAttachment(Exception):
|
|
62
|
-
"""A task's file is in neither its repo nor its fixtures: skip that task."""
|
|
63
|
-
|
|
64
|
-
def registered_models() -> list[str]:
|
|
65
|
-
"""Every alias in the canonical yaml, newest registration last.
|
|
66
|
-
|
|
67
|
-
Derived rather than listed. A hardcoded default went stale the moment
|
|
68
|
-
models were added: it named five while twenty-two were registered, so a
|
|
69
|
-
scheduled run would have quietly covered a quarter of them and reported
|
|
70
|
-
nothing missing. A list that has to be edited alongside another list is a
|
|
71
|
-
list that will disagree with it. Empty on a fresh install (no yaml yet).
|
|
72
|
-
"""
|
|
73
|
-
import endpoints
|
|
74
|
-
return list(endpoints.registrations())
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def ad_hoc_models() -> set[str]:
|
|
78
|
-
"""Aliases that are only ever called BY NAME — never picked automatically.
|
|
79
|
-
|
|
80
|
-
Declared in `config/model-roles.json` under `ad_hoc`. These are the
|
|
81
|
-
expensive or tightly capped models: a daily sweep or a rotation that
|
|
82
|
-
reached them would spend the cap on a benchmark and leave nothing for the
|
|
83
|
-
moment someone actually wanted them. So every automatic selection skips
|
|
84
|
-
them, and an explicit `--model`, `--with` or `llm -m` still works.
|
|
85
|
-
"""
|
|
86
|
-
import json
|
|
87
|
-
try:
|
|
88
|
-
data = json.loads((DATA / "config" / "model-roles.json").read_text(
|
|
89
|
-
encoding="utf-8"))
|
|
90
|
-
except (OSError, ValueError):
|
|
91
|
-
return set()
|
|
92
|
-
return set(data.get("ad_hoc", []))
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def available_models(cache: dict | None = None,
|
|
96
|
-
include_ad_hoc: bool = False) -> list[str]:
|
|
97
|
-
"""Registered, still served, and answering — the ones worth spending on.
|
|
98
|
-
|
|
99
|
-
THERE IS NO CURATED DEFAULT LIST ANY MORE. There was one, of five models,
|
|
100
|
-
written when five were registered; by the time twenty-two were it named a
|
|
101
|
-
quarter of them and looked perfectly healthy doing it. A list that must be
|
|
102
|
-
edited whenever another list changes is a list that will disagree with it.
|
|
103
|
-
|
|
104
|
-
Three filters, each removing a different kind of waste:
|
|
105
|
-
|
|
106
|
-
1. REGISTERED — in the canonical yaml.
|
|
107
|
-
2. STILL SERVED — present on the endpoint now. This is the deprecation
|
|
108
|
-
check, applied automatically: `laguna-m1` was retired upstream and
|
|
109
|
-
`gpt-oss-120b` never existed under that name, and both were being sent
|
|
110
|
-
46,000-character prompts to earn a `0/11`.
|
|
111
|
-
3. STILL ANSWERING — the last perf reading recorded at least one answer.
|
|
112
|
-
`mistral-large` (subscription), `mistral-medium` and `mistral-small`
|
|
113
|
-
(quota) are all SERVED and all refuse, so being on the endpoint is not
|
|
114
|
-
enough on its own.
|
|
115
|
-
|
|
116
|
-
And, unless `include_ad_hoc`, NOT AD HOC — see `ad_hoc_models`. Every
|
|
117
|
-
automatic picker comes through here, so this is the one place that keeps
|
|
118
|
-
them away from those models. A caller that is honouring an explicit request
|
|
119
|
-
by name passes `include_ad_hoc=True`.
|
|
120
|
-
|
|
121
|
-
**`perf` deliberately does NOT use this** — it runs everything registered
|
|
122
|
-
and served, because a model that starts answering again is discovered by
|
|
123
|
-
asking it, and a tier that skipped failing models would never notice a
|
|
124
|
-
recovery.
|
|
125
|
-
"""
|
|
126
|
-
import csv
|
|
127
|
-
models = registered_models()
|
|
128
|
-
if not include_ad_hoc:
|
|
129
|
-
held = ad_hoc_models()
|
|
130
|
-
models = [m for m in models if m not in held]
|
|
131
|
-
served = _served_model_names(cache)
|
|
132
|
-
if served is not None:
|
|
133
|
-
names = _registered_names()
|
|
134
|
-
models = [m for m in models if names.get(m) in served]
|
|
135
|
-
|
|
136
|
-
history = DATA / "perf" / "history.csv"
|
|
137
|
-
if history.exists():
|
|
138
|
-
last: dict[str, bool] = {}
|
|
139
|
-
with history.open(encoding="utf-8", newline="") as fh:
|
|
140
|
-
for row in csv.DictReader(fh):
|
|
141
|
-
# Later rows win: a model that recovered is back in.
|
|
142
|
-
last[row["model"]] = last.get(row["model"], False) or int(row["answered"] or 0) > 0
|
|
143
|
-
# A model with NO perf history is kept: unmeasured is not the same as
|
|
144
|
-
# failing, and excluding it would make it permanently invisible.
|
|
145
|
-
models = [m for m in models if last.get(m, True)]
|
|
146
|
-
return models
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _registered_names() -> dict[str, str]:
|
|
150
|
-
"""alias -> the model name the endpoint knows it by; {} with no yaml yet."""
|
|
151
|
-
import endpoints
|
|
152
|
-
return {alias: r.get("model_name", "") for alias, r in endpoints.registrations().items()}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _served_model_names(cache: dict | None = None) -> set[str] | None:
|
|
156
|
-
"""What the endpoint offers right now, or None if it cannot be asked.
|
|
157
|
-
|
|
158
|
-
None rather than an empty set on failure, and the caller treats that as
|
|
159
|
-
"do not filter". An unreachable endpoint is not evidence that every model
|
|
160
|
-
was retired.
|
|
161
|
-
"""
|
|
162
|
-
if cache is not None and "served" in cache:
|
|
163
|
-
return cache["served"]
|
|
164
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
165
|
-
import endpoints
|
|
166
|
-
served: set[str] = set()
|
|
167
|
-
for name in endpoints.load():
|
|
168
|
-
try:
|
|
169
|
-
served |= set(endpoints.models(name, timeout=20))
|
|
170
|
-
except Exception:
|
|
171
|
-
# The default endpoint down says nothing about any model: do not
|
|
172
|
-
# filter. Another one down only drops its own models for today.
|
|
173
|
-
if name == endpoints.default():
|
|
174
|
-
return None
|
|
175
|
-
if cache is not None:
|
|
176
|
-
cache["served"] = served
|
|
177
|
-
return served
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _note_benchmark(model: str, calls: int) -> None:
|
|
181
|
-
"""Report benchmark calls to the usage counter, never fatally."""
|
|
182
|
-
try:
|
|
183
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
184
|
-
from cost import note_benchmark_calls
|
|
185
|
-
note_benchmark_calls(model, calls)
|
|
186
|
-
except Exception: # noqa: BLE001
|
|
187
|
-
pass # a counter is not the job
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def llm_binary() -> str:
|
|
191
|
-
found = shutil.which("llm")
|
|
192
|
-
if found:
|
|
193
|
-
return found
|
|
194
|
-
fallback = pathlib.Path.home() / ".local" / "bin" / "llm.exe"
|
|
195
|
-
if fallback.exists():
|
|
196
|
-
return str(fallback)
|
|
197
|
-
sys.exit("llm CLI not found. See the README.")
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def bench_python() -> str:
|
|
201
|
-
"""The interpreter for child scripts: the repo's venv when there is one
|
|
202
|
-
(Windows or POSIX layout), else the one running now. The dashboard may run
|
|
203
|
-
on another Python, and onboarding's children want the venv's packages; a
|
|
204
|
-
hardcoded `.venv\\Scripts\\python.exe` blocked onboarding on macOS, Linux
|
|
205
|
-
and Docker."""
|
|
206
|
-
for venv in (ROOT / ".venv" / "Scripts" / "python.exe", ROOT / ".venv" / "bin" / "python"):
|
|
207
|
-
if venv.exists():
|
|
208
|
-
return str(venv)
|
|
209
|
-
return sys.executable
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def free_models() -> list[str]:
|
|
213
|
-
"""Registered models on OpenRouter's shared free budget."""
|
|
214
|
-
served = _registered_names()
|
|
215
|
-
return [m for m in registered_models() if ":free" in served.get(m, "")]
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def next_free(tasks_dir: pathlib.Path) -> str | None:
|
|
219
|
-
"""The `:free` model most in need of a suite score — ONE per run.
|
|
220
|
-
|
|
221
|
-
`free-models-per-day` is 50 without credits and is shared across every
|
|
222
|
-
`:free` model, so a full sweep of nine models over four tasks is 36 calls
|
|
223
|
-
and crowds out everything else for the day. Rotating one model per run
|
|
224
|
-
costs four, which fits alongside perf with room left.
|
|
225
|
-
|
|
226
|
-
Never-scored first, then least-recently, so a newly registered free model
|
|
227
|
-
is picked up on the next run rather than after a full cycle.
|
|
228
|
-
"""
|
|
229
|
-
best, best_age = None, None
|
|
230
|
-
held = ad_hoc_models()
|
|
231
|
-
for model in free_models():
|
|
232
|
-
if model in held:
|
|
233
|
-
continue
|
|
234
|
-
outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
|
|
235
|
-
if not outputs:
|
|
236
|
-
return model
|
|
237
|
-
age = max(o.stat().st_mtime for o in outputs)
|
|
238
|
-
if best_age is None or age < best_age:
|
|
239
|
-
best, best_age = model, age
|
|
240
|
-
return best
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def next_model(tasks_dir: pathlib.Path) -> str | None:
|
|
244
|
-
"""The PAID model most in need of a suite score — ONE per run.
|
|
245
|
-
|
|
246
|
-
THIS TIER MATCHES HOW THE MODELS ARE ACTUALLY USED, which is why it holds
|
|
247
|
-
the recurring slot and `funcbench/` no longer does. Every offload recorded
|
|
248
|
-
in `metrics/offloads.jsonl` is a single-shot review: a large document goes
|
|
249
|
-
in, prose comes back, and the operator verifies it. Nothing here passes a
|
|
250
|
-
tool schema -- there is no `tools=` anywhere in this harness -- so a
|
|
251
|
-
tool-calling benchmark was measuring a capability this bench never
|
|
252
|
-
exercises. It ranked the roster close to backwards: `muse-glimmer-30b`
|
|
253
|
-
scored nothing at all on it and gave the best answer of the panel on real
|
|
254
|
-
work the same day.
|
|
255
|
-
|
|
256
|
-
`:free` models are DELIBERATELY EXCLUDED and left to `--next-free`. They
|
|
257
|
-
share one 50-calls-a-day budget, `--next-free` already rotates them at six
|
|
258
|
-
calls a run, and letting this rotation pick them too would spend that
|
|
259
|
-
budget twice over.
|
|
260
|
-
|
|
261
|
-
Order of need, most urgent first:
|
|
262
|
-
|
|
263
|
-
1. never scored -- a newly registered model, or one nothing has run
|
|
264
|
-
2. INCOMPLETE, before merely old. Thirteen models sit at four of six
|
|
265
|
-
tasks, frozen on 2026-09-04, missing `no-writes-here` and
|
|
266
|
-
`observation-boundary` because both were written after that sweep.
|
|
267
|
-
A gap is a worse blind spot than a stale number: an old score is
|
|
268
|
-
still evidence, a missing one is none.
|
|
269
|
-
3. oldest, so the set refreshes on its own
|
|
270
|
-
"""
|
|
271
|
-
total = len([p for p in tasks_dir.iterdir() if p.is_dir()])
|
|
272
|
-
free = set(free_models())
|
|
273
|
-
best, best_key = None, None
|
|
274
|
-
for model in available_models():
|
|
275
|
-
if model in free:
|
|
276
|
-
continue
|
|
277
|
-
outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
|
|
278
|
-
if not outputs:
|
|
279
|
-
return model # never scored: go first
|
|
280
|
-
key = (len(outputs) >= total, # False (incomplete) sorts first
|
|
281
|
-
max(o.stat().st_mtime for o in outputs))
|
|
282
|
-
if best_key is None or key < best_key:
|
|
283
|
-
best, best_key = model, key
|
|
284
|
-
return best
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
PER_RUN = 6 # tasks per scheduled run (--next, --next-free)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def pick_tasks(tasks: list[dict], model: str, n: int = PER_RUN) -> list[dict]:
|
|
291
|
-
"""What a scheduled run asks its one model: the tasks it has no saved
|
|
292
|
-
answer for, else its oldest answers, at most `n`.
|
|
293
|
-
|
|
294
|
-
The schedule was sized for six tasks a run; the suite has grown to over
|
|
295
|
-
fifty, and running all of them each time redid every answer the model
|
|
296
|
-
already had (2026-10-20: 52 calls on one free model at 6am, more than
|
|
297
|
-
OpenRouter's whole free day, and a paid run stopped at its two-hour limit).
|
|
298
|
-
Every result is saved, a failure included, so a gap is a task never asked,
|
|
299
|
-
or one whose saved answer is a rate limit or quota (`capacity`: the
|
|
300
|
-
afternoon, not the model, as pace.py treats it). A failure that is the
|
|
301
|
-
model's own (a context too small, a timeout) stays: asking again would
|
|
302
|
-
only repeat it. Once there are no gaps the oldest answers are refreshed,
|
|
303
|
-
which keeps the scores current."""
|
|
304
|
-
def answer(spec):
|
|
305
|
-
return spec["dir"] / "output" / f"{model}.md" # where main() saves it
|
|
306
|
-
|
|
307
|
-
def gap(spec):
|
|
308
|
-
saved = answer(spec)
|
|
309
|
-
return (not saved.exists()
|
|
310
|
-
or failure_class(saved.read_text(encoding="utf-8", errors="replace")) == "capacity")
|
|
311
|
-
missing = [s for s in tasks if gap(s)]
|
|
312
|
-
if missing:
|
|
313
|
-
return missing[:n]
|
|
314
|
-
return sorted(tasks, key=lambda s: answer(s).stat().st_mtime)[:n]
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def load_tasks(only: str | None) -> list[dict]:
|
|
318
|
-
tasks = []
|
|
319
|
-
for path in sorted(TASKS.glob("*/task.json")):
|
|
320
|
-
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
321
|
-
spec["dir"] = path.parent
|
|
322
|
-
spec.setdefault("id", path.parent.name)
|
|
323
|
-
if only and spec["id"] != only:
|
|
324
|
-
continue
|
|
325
|
-
# A mined task is a draft until someone has read it (suite/mine.py).
|
|
326
|
-
if spec.get("draft") and not only:
|
|
327
|
-
continue
|
|
328
|
-
tasks.append(spec)
|
|
329
|
-
if only and not tasks:
|
|
330
|
-
sys.exit(f"no task called {only!r}")
|
|
331
|
-
return tasks
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def build_prompt(spec: dict) -> str:
|
|
335
|
-
parts = [spec["prompt"].strip(), ""]
|
|
336
|
-
key = spec.get("repo", DEFAULT_REPO)
|
|
337
|
-
root = REPOS.get(key)
|
|
338
|
-
for rel in spec.get("attach", []):
|
|
339
|
-
source = root / rel if root else None
|
|
340
|
-
if not (source and source.exists()):
|
|
341
|
-
source = spec["dir"] / "fixtures" / pathlib.Path(rel).name
|
|
342
|
-
if not source.exists():
|
|
343
|
-
raise MissingAttachment(
|
|
344
|
-
f"{spec['id']}: attachment not found: {rel}"
|
|
345
|
-
+ ("" if root else f" (no repo {key!r} in config/repos.json, and no fixture)"))
|
|
346
|
-
parts += [f"===== FILE: {rel} =====",
|
|
347
|
-
source.read_text(encoding="utf-8", errors="replace"), ""]
|
|
348
|
-
return "\n".join(parts)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
# A model that was never allowed to answer has not answered badly, and a
|
|
352
|
-
# service that fell over has not answered at all. Reporting either as 0/N is a
|
|
353
|
-
# lie about the model — and reporting them as the SAME thing is a second lie,
|
|
354
|
-
# because one is a fact about the model and the other is a fact about
|
|
355
|
-
# Tuesday afternoon.
|
|
356
|
-
#
|
|
357
|
-
# `nemotron-ultra-primary` returned "Service temporarily overloaded" in 11
|
|
358
|
-
# seconds and was filed beside a safety-filter block until somebody read it.
|
|
359
|
-
REFUSALS = (
|
|
360
|
-
"blocked by gemini's filters",
|
|
361
|
-
"i can't help with that",
|
|
362
|
-
"i cannot help with that",
|
|
363
|
-
"i'm not able to help",
|
|
364
|
-
"against my guidelines",
|
|
365
|
-
"unable to assist",
|
|
366
|
-
)
|
|
367
|
-
ERRORS = (
|
|
368
|
-
"service temporarily overloaded",
|
|
369
|
-
"rate limit",
|
|
370
|
-
"internal server error",
|
|
371
|
-
"bad gateway",
|
|
372
|
-
"connection reset",
|
|
373
|
-
"502",
|
|
374
|
-
"503",
|
|
375
|
-
# --- added 2026-09-04, after a 16-model run scored four of them 0/11 ----
|
|
376
|
-
#
|
|
377
|
-
# Every one of these came back in FOUR SECONDS against a 46,000-character
|
|
378
|
-
# prompt and was recorded as a model that read the code and found nothing.
|
|
379
|
-
# They are the gateway refusing before a model ever saw the question:
|
|
380
|
-
#
|
|
381
|
-
# gpt-oss "Model not found" -- a stale registration
|
|
382
|
-
# mistral-large "not available in your subscription tier"
|
|
383
|
-
# laguna-m1 "No endpoints found for poolside/laguna-m.1:free"
|
|
384
|
-
# qwen3.8-27b "Request too large ... ITPM: Limit 7000"
|
|
385
|
-
#
|
|
386
|
-
# The `llm` CLI prefixes all of them with `Error: Error code: NNN`, which
|
|
387
|
-
# is the reliable signal; the rest are spelled out so a changed prefix
|
|
388
|
-
# cannot silently reopen this.
|
|
389
|
-
"error: error code:",
|
|
390
|
-
"model not found",
|
|
391
|
-
"not available in your subscription",
|
|
392
|
-
"no endpoints found",
|
|
393
|
-
"request too large",
|
|
394
|
-
"insufficient_quota",
|
|
395
|
-
"quota exceeded",
|
|
396
|
-
"context length",
|
|
397
|
-
"too many requests",
|
|
398
|
-
# --- added 2026-09-08, after a dead CLI scored as an answer -------------
|
|
399
|
-
#
|
|
400
|
-
# Every marker above is the PROVIDER failing. These two are the CLIENT
|
|
401
|
-
# failing, and nothing here recognised that class at all. `llm` exiting
|
|
402
|
-
# non-zero produced `Error: 'utf-8' codec can't encode character ...` --
|
|
403
|
-
# 93 characters, over `min_length`, matching no marker -- so `outcome()`
|
|
404
|
-
# returned None and the floor tier sanitised the error text into a
|
|
405
|
-
# "solution" and graded it wrong.
|
|
406
|
-
#
|
|
407
|
-
# `Error: Error code:` was described above as "the reliable signal". It is
|
|
408
|
-
# reliable for UPSTREAM errors only; a client-side crash also begins
|
|
409
|
-
# `Error:` and goes no further. Ordered last so an upstream reason still
|
|
410
|
-
# wins the `detail` when both are present.
|
|
411
|
-
"error: llm exited",
|
|
412
|
-
# The built-in client's counterpart (metrics/client.py, BENCH_CLIENT=builtin).
|
|
413
|
-
"error: client failed",
|
|
414
|
-
"codec can't encode",
|
|
415
|
-
"request limit reached",
|
|
416
|
-
)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
# WHY A FAILURE HAPPENED decides whether it is evidence about the MODEL or
|
|
420
|
-
# about the afternoon, and the two look identical from the outside.
|
|
421
|
-
#
|
|
422
|
-
# STRUCTURAL the model is not there. No such name, no provider behind it.
|
|
423
|
-
# Waiting changes nothing and the registration should go.
|
|
424
|
-
# ENTITLEMENT it exists; this account may not call it. A plan decision, not a
|
|
425
|
-
# fault — worth keeping registered to notice if it changes.
|
|
426
|
-
# CAPACITY it is there and busy, throttled, or the prompt is too big for
|
|
427
|
-
# its window. Temporary by nature -- worth retrying, and worth
|
|
428
|
-
# retrying tomorrow.
|
|
429
|
-
# TIMEOUT no answer inside the budget. Says NOTHING about existence: a
|
|
430
|
-
# slow model and a queued request are indistinguishable here.
|
|
431
|
-
#
|
|
432
|
-
# The distinction matters because a model absent from the endpoint's CATALOGUE
|
|
433
|
-
# is deprecated, while one that is listed and failing might be either. A
|
|
434
|
-
# listed model failing STRUCTURALLY every time is retired upstream and the
|
|
435
|
-
# catalogue has not caught up -- which is exactly what `laguna-m1` did.
|
|
436
|
-
# SIGNIN the key itself was refused: 401, or a sign-in metrics/connect.py
|
|
437
|
-
# could not renew. Fixed on the Endpoints page, never by retiring.
|
|
438
|
-
SIGNIN = (
|
|
439
|
-
"error code: 401",
|
|
440
|
-
"sign in again",
|
|
441
|
-
# ChatGPT's plan cannot share usage with apps: the account's, every model at once.
|
|
442
|
-
"sharing_usage_unavailable",
|
|
443
|
-
)
|
|
444
|
-
STRUCTURAL = (
|
|
445
|
-
"model not found",
|
|
446
|
-
"no endpoints found",
|
|
447
|
-
"does not exist",
|
|
448
|
-
"unknown model",
|
|
449
|
-
"invalid model",
|
|
450
|
-
"model_not_found",
|
|
451
|
-
)
|
|
452
|
-
# ENTITLEMENT is its own class, and the difference decides what to DO.
|
|
453
|
-
# `structural` means the model is not there and the registration should go.
|
|
454
|
-
# This means it IS there and this account may not call it — a pricing or plan
|
|
455
|
-
# decision. `mistral-large` is exactly this, and the operator is keeping it
|
|
456
|
-
# registered on purpose to notice if the tier ever changes. Reporting it as a
|
|
457
|
-
# retirement would advise deleting the very thing being watched.
|
|
458
|
-
ENTITLEMENT = (
|
|
459
|
-
"not available in your subscription",
|
|
460
|
-
"not authorized",
|
|
461
|
-
"unauthorized",
|
|
462
|
-
"permission denied",
|
|
463
|
-
"no access to model",
|
|
464
|
-
# Credit exhausted or never funded. The model is there and the account
|
|
465
|
-
# cannot call it, which is an entitlement question rather than a rate
|
|
466
|
-
# limit — waiting does not clear it, adding balance does.
|
|
467
|
-
"insufficient balance",
|
|
468
|
-
"insufficient credit",
|
|
469
|
-
"payment required",
|
|
470
|
-
# OpenRouter gates some free endpoints to apps on its own published list.
|
|
471
|
-
# The model exists and works; this account's CLIENT is not eligible.
|
|
472
|
-
"only available on agentic harnesses",
|
|
473
|
-
"only available on",
|
|
474
|
-
)
|
|
475
|
-
# PAYWALLED is A FORM OF DEPRECATION HERE, which is why it is not folded into
|
|
476
|
-
# ENTITLEMENT. The two look alike -- the model exists and this account cannot
|
|
477
|
-
# call it -- but the ACTION is opposite, and the action is the whole point of
|
|
478
|
-
# separating failure kinds in this file.
|
|
479
|
-
#
|
|
480
|
-
# entitlement keep it registered and watch. `mistral-large` sits behind a
|
|
481
|
-
# subscription tier that could change, and noticing that change
|
|
482
|
-
# is worth a registration doing nothing in the meantime.
|
|
483
|
-
# paywalled a `:free` slug retired behind a paid one. THIS BENCH DOES NOT
|
|
484
|
-
# BUY PAID TIERS FOR THIS, so the model is gone for our
|
|
485
|
-
# purposes as surely as if it had been deleted. Waiting does
|
|
486
|
-
# not fix it: the free endpoint is not coming back. Re-point
|
|
487
|
-
# the registration at the paid slug and pay, or drop it.
|
|
488
|
-
#
|
|
489
|
-
# Two models did this within days of each other, both on OpenRouter, both
|
|
490
|
-
# still listed in the catalogue and both returning 400 on every call:
|
|
491
|
-
#
|
|
492
|
-
# minimax-m3-free -> use this slug instead: minimax/minimax-m3
|
|
493
|
-
# glm-5.2-free -> use this slug instead: z-ai/glm-5.2
|
|
494
|
-
#
|
|
495
|
-
# Filed as `other` and reported by nothing until 2026-09-09.
|
|
496
|
-
PAYWALLED = (
|
|
497
|
-
"unavailable for free",
|
|
498
|
-
"use this slug instead",
|
|
499
|
-
"the paid version is available",
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
"
|
|
503
|
-
"
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
"
|
|
507
|
-
"
|
|
508
|
-
"
|
|
509
|
-
|
|
510
|
-
"
|
|
511
|
-
"
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
"
|
|
515
|
-
"
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
"
|
|
523
|
-
"
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
#
|
|
532
|
-
#
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
#
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if
|
|
656
|
-
return
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
#
|
|
673
|
-
#
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
#
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
#
|
|
714
|
-
#
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
"
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
#
|
|
773
|
-
#
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
`
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
#
|
|
830
|
-
#
|
|
831
|
-
#
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
ap
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
"
|
|
877
|
-
"
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
"
|
|
882
|
-
"
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
chosen
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
row["
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
#
|
|
971
|
-
#
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
if
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
lines
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
"
|
|
1028
|
-
"
|
|
1029
|
-
"
|
|
1030
|
-
"
|
|
1031
|
-
"not
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if
|
|
1042
|
-
|
|
1
|
+
"""Run the local task suite against the configured models.
|
|
2
|
+
|
|
3
|
+
Each task is something that actually happened, with an answer that was
|
|
4
|
+
verified against source or a live database before it went into the code. So a
|
|
5
|
+
score here is "would this model have found what we found", which is the only
|
|
6
|
+
question worth asking of a model we are about to hand work to.
|
|
7
|
+
|
|
8
|
+
Scoring is keyword-based on purpose. A rubric graded by a model is a rubric
|
|
9
|
+
that model can be wrong about; a string either appears in the output or it
|
|
10
|
+
does not. Crude, reproducible, and it cannot flatter anybody.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
27
|
+
ROOT = HERE.parent
|
|
28
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
29
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
30
|
+
TASKS = DATA / "suite" / "tasks"
|
|
31
|
+
RUNS = DATA / "runs"
|
|
32
|
+
|
|
33
|
+
# THE CODEBASES THIS BENCHMARK IS DRAWN FROM.
|
|
34
|
+
#
|
|
35
|
+
# Private repositories, which is the entire point: nobody trained on them, so
|
|
36
|
+
# a score here cannot be inflated by memorisation the way every public
|
|
37
|
+
# benchmark's can. That is a property no public suite can offer at any price,
|
|
38
|
+
# and it gets stronger as more real work goes in.
|
|
39
|
+
#
|
|
40
|
+
# Tasks name a repo with `"repo": "<key>"`, else DEFAULT_REPO. They point at
|
|
41
|
+
# REAL FILES rather than copies, so a fixture cannot quietly go stale while the
|
|
42
|
+
# thing it copied moves on — the cost being that a task must be re-verified
|
|
43
|
+
# when the code under it changes. Which folder each key means is the install's
|
|
44
|
+
# own: config/repos.json in the data folder, `{"<key>": "<path>"}`. A key it
|
|
45
|
+
# does not list falls back to the task's own fixtures/ copies, which is how
|
|
46
|
+
# the public starter pack runs with no repositories at all.
|
|
47
|
+
DEFAULT_REPO = "default"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def repos() -> dict[str, pathlib.Path]:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads((DATA / "config" / "repos.json").read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
return {}
|
|
55
|
+
return {k: pathlib.Path(v) for k, v in data.items() if not k.startswith("_") and isinstance(v, str)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
REPOS = repos()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MissingAttachment(Exception):
|
|
62
|
+
"""A task's file is in neither its repo nor its fixtures: skip that task."""
|
|
63
|
+
|
|
64
|
+
def registered_models() -> list[str]:
|
|
65
|
+
"""Every alias in the canonical yaml, newest registration last.
|
|
66
|
+
|
|
67
|
+
Derived rather than listed. A hardcoded default went stale the moment
|
|
68
|
+
models were added: it named five while twenty-two were registered, so a
|
|
69
|
+
scheduled run would have quietly covered a quarter of them and reported
|
|
70
|
+
nothing missing. A list that has to be edited alongside another list is a
|
|
71
|
+
list that will disagree with it. Empty on a fresh install (no yaml yet).
|
|
72
|
+
"""
|
|
73
|
+
import endpoints
|
|
74
|
+
return list(endpoints.registrations())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ad_hoc_models() -> set[str]:
|
|
78
|
+
"""Aliases that are only ever called BY NAME — never picked automatically.
|
|
79
|
+
|
|
80
|
+
Declared in `config/model-roles.json` under `ad_hoc`. These are the
|
|
81
|
+
expensive or tightly capped models: a daily sweep or a rotation that
|
|
82
|
+
reached them would spend the cap on a benchmark and leave nothing for the
|
|
83
|
+
moment someone actually wanted them. So every automatic selection skips
|
|
84
|
+
them, and an explicit `--model`, `--with` or `llm -m` still works.
|
|
85
|
+
"""
|
|
86
|
+
import json
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads((DATA / "config" / "model-roles.json").read_text(
|
|
89
|
+
encoding="utf-8"))
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
return set()
|
|
92
|
+
return set(data.get("ad_hoc", []))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def available_models(cache: dict | None = None,
|
|
96
|
+
include_ad_hoc: bool = False) -> list[str]:
|
|
97
|
+
"""Registered, still served, and answering — the ones worth spending on.
|
|
98
|
+
|
|
99
|
+
THERE IS NO CURATED DEFAULT LIST ANY MORE. There was one, of five models,
|
|
100
|
+
written when five were registered; by the time twenty-two were it named a
|
|
101
|
+
quarter of them and looked perfectly healthy doing it. A list that must be
|
|
102
|
+
edited whenever another list changes is a list that will disagree with it.
|
|
103
|
+
|
|
104
|
+
Three filters, each removing a different kind of waste:
|
|
105
|
+
|
|
106
|
+
1. REGISTERED — in the canonical yaml.
|
|
107
|
+
2. STILL SERVED — present on the endpoint now. This is the deprecation
|
|
108
|
+
check, applied automatically: `laguna-m1` was retired upstream and
|
|
109
|
+
`gpt-oss-120b` never existed under that name, and both were being sent
|
|
110
|
+
46,000-character prompts to earn a `0/11`.
|
|
111
|
+
3. STILL ANSWERING — the last perf reading recorded at least one answer.
|
|
112
|
+
`mistral-large` (subscription), `mistral-medium` and `mistral-small`
|
|
113
|
+
(quota) are all SERVED and all refuse, so being on the endpoint is not
|
|
114
|
+
enough on its own.
|
|
115
|
+
|
|
116
|
+
And, unless `include_ad_hoc`, NOT AD HOC — see `ad_hoc_models`. Every
|
|
117
|
+
automatic picker comes through here, so this is the one place that keeps
|
|
118
|
+
them away from those models. A caller that is honouring an explicit request
|
|
119
|
+
by name passes `include_ad_hoc=True`.
|
|
120
|
+
|
|
121
|
+
**`perf` deliberately does NOT use this** — it runs everything registered
|
|
122
|
+
and served, because a model that starts answering again is discovered by
|
|
123
|
+
asking it, and a tier that skipped failing models would never notice a
|
|
124
|
+
recovery.
|
|
125
|
+
"""
|
|
126
|
+
import csv
|
|
127
|
+
models = registered_models()
|
|
128
|
+
if not include_ad_hoc:
|
|
129
|
+
held = ad_hoc_models()
|
|
130
|
+
models = [m for m in models if m not in held]
|
|
131
|
+
served = _served_model_names(cache)
|
|
132
|
+
if served is not None:
|
|
133
|
+
names = _registered_names()
|
|
134
|
+
models = [m for m in models if names.get(m) in served]
|
|
135
|
+
|
|
136
|
+
history = DATA / "perf" / "history.csv"
|
|
137
|
+
if history.exists():
|
|
138
|
+
last: dict[str, bool] = {}
|
|
139
|
+
with history.open(encoding="utf-8", newline="") as fh:
|
|
140
|
+
for row in csv.DictReader(fh):
|
|
141
|
+
# Later rows win: a model that recovered is back in.
|
|
142
|
+
last[row["model"]] = last.get(row["model"], False) or int(row["answered"] or 0) > 0
|
|
143
|
+
# A model with NO perf history is kept: unmeasured is not the same as
|
|
144
|
+
# failing, and excluding it would make it permanently invisible.
|
|
145
|
+
models = [m for m in models if last.get(m, True)]
|
|
146
|
+
return models
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _registered_names() -> dict[str, str]:
|
|
150
|
+
"""alias -> the model name the endpoint knows it by; {} with no yaml yet."""
|
|
151
|
+
import endpoints
|
|
152
|
+
return {alias: r.get("model_name", "") for alias, r in endpoints.registrations().items()}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _served_model_names(cache: dict | None = None) -> set[str] | None:
|
|
156
|
+
"""What the endpoint offers right now, or None if it cannot be asked.
|
|
157
|
+
|
|
158
|
+
None rather than an empty set on failure, and the caller treats that as
|
|
159
|
+
"do not filter". An unreachable endpoint is not evidence that every model
|
|
160
|
+
was retired.
|
|
161
|
+
"""
|
|
162
|
+
if cache is not None and "served" in cache:
|
|
163
|
+
return cache["served"]
|
|
164
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
165
|
+
import endpoints
|
|
166
|
+
served: set[str] = set()
|
|
167
|
+
for name in endpoints.load():
|
|
168
|
+
try:
|
|
169
|
+
served |= set(endpoints.models(name, timeout=20))
|
|
170
|
+
except Exception:
|
|
171
|
+
# The default endpoint down says nothing about any model: do not
|
|
172
|
+
# filter. Another one down only drops its own models for today.
|
|
173
|
+
if name == endpoints.default():
|
|
174
|
+
return None
|
|
175
|
+
if cache is not None:
|
|
176
|
+
cache["served"] = served
|
|
177
|
+
return served
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _note_benchmark(model: str, calls: int) -> None:
|
|
181
|
+
"""Report benchmark calls to the usage counter, never fatally."""
|
|
182
|
+
try:
|
|
183
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
184
|
+
from cost import note_benchmark_calls
|
|
185
|
+
note_benchmark_calls(model, calls)
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
pass # a counter is not the job
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def llm_binary() -> str:
|
|
191
|
+
found = shutil.which("llm")
|
|
192
|
+
if found:
|
|
193
|
+
return found
|
|
194
|
+
fallback = pathlib.Path.home() / ".local" / "bin" / "llm.exe"
|
|
195
|
+
if fallback.exists():
|
|
196
|
+
return str(fallback)
|
|
197
|
+
sys.exit("llm CLI not found. See the README.")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def bench_python() -> str:
|
|
201
|
+
"""The interpreter for child scripts: the repo's venv when there is one
|
|
202
|
+
(Windows or POSIX layout), else the one running now. The dashboard may run
|
|
203
|
+
on another Python, and onboarding's children want the venv's packages; a
|
|
204
|
+
hardcoded `.venv\\Scripts\\python.exe` blocked onboarding on macOS, Linux
|
|
205
|
+
and Docker."""
|
|
206
|
+
for venv in (ROOT / ".venv" / "Scripts" / "python.exe", ROOT / ".venv" / "bin" / "python"):
|
|
207
|
+
if venv.exists():
|
|
208
|
+
return str(venv)
|
|
209
|
+
return sys.executable
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def free_models() -> list[str]:
|
|
213
|
+
"""Registered models on OpenRouter's shared free budget."""
|
|
214
|
+
served = _registered_names()
|
|
215
|
+
return [m for m in registered_models() if ":free" in served.get(m, "")]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def next_free(tasks_dir: pathlib.Path) -> str | None:
|
|
219
|
+
"""The `:free` model most in need of a suite score — ONE per run.
|
|
220
|
+
|
|
221
|
+
`free-models-per-day` is 50 without credits and is shared across every
|
|
222
|
+
`:free` model, so a full sweep of nine models over four tasks is 36 calls
|
|
223
|
+
and crowds out everything else for the day. Rotating one model per run
|
|
224
|
+
costs four, which fits alongside perf with room left.
|
|
225
|
+
|
|
226
|
+
Never-scored first, then least-recently, so a newly registered free model
|
|
227
|
+
is picked up on the next run rather than after a full cycle.
|
|
228
|
+
"""
|
|
229
|
+
best, best_age = None, None
|
|
230
|
+
held = ad_hoc_models()
|
|
231
|
+
for model in free_models():
|
|
232
|
+
if model in held:
|
|
233
|
+
continue
|
|
234
|
+
outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
|
|
235
|
+
if not outputs:
|
|
236
|
+
return model
|
|
237
|
+
age = max(o.stat().st_mtime for o in outputs)
|
|
238
|
+
if best_age is None or age < best_age:
|
|
239
|
+
best, best_age = model, age
|
|
240
|
+
return best
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def next_model(tasks_dir: pathlib.Path) -> str | None:
|
|
244
|
+
"""The PAID model most in need of a suite score — ONE per run.
|
|
245
|
+
|
|
246
|
+
THIS TIER MATCHES HOW THE MODELS ARE ACTUALLY USED, which is why it holds
|
|
247
|
+
the recurring slot and `funcbench/` no longer does. Every offload recorded
|
|
248
|
+
in `metrics/offloads.jsonl` is a single-shot review: a large document goes
|
|
249
|
+
in, prose comes back, and the operator verifies it. Nothing here passes a
|
|
250
|
+
tool schema -- there is no `tools=` anywhere in this harness -- so a
|
|
251
|
+
tool-calling benchmark was measuring a capability this bench never
|
|
252
|
+
exercises. It ranked the roster close to backwards: `muse-glimmer-30b`
|
|
253
|
+
scored nothing at all on it and gave the best answer of the panel on real
|
|
254
|
+
work the same day.
|
|
255
|
+
|
|
256
|
+
`:free` models are DELIBERATELY EXCLUDED and left to `--next-free`. They
|
|
257
|
+
share one 50-calls-a-day budget, `--next-free` already rotates them at six
|
|
258
|
+
calls a run, and letting this rotation pick them too would spend that
|
|
259
|
+
budget twice over.
|
|
260
|
+
|
|
261
|
+
Order of need, most urgent first:
|
|
262
|
+
|
|
263
|
+
1. never scored -- a newly registered model, or one nothing has run
|
|
264
|
+
2. INCOMPLETE, before merely old. Thirteen models sit at four of six
|
|
265
|
+
tasks, frozen on 2026-09-04, missing `no-writes-here` and
|
|
266
|
+
`observation-boundary` because both were written after that sweep.
|
|
267
|
+
A gap is a worse blind spot than a stale number: an old score is
|
|
268
|
+
still evidence, a missing one is none.
|
|
269
|
+
3. oldest, so the set refreshes on its own
|
|
270
|
+
"""
|
|
271
|
+
total = len([p for p in tasks_dir.iterdir() if p.is_dir()])
|
|
272
|
+
free = set(free_models())
|
|
273
|
+
best, best_key = None, None
|
|
274
|
+
for model in available_models():
|
|
275
|
+
if model in free:
|
|
276
|
+
continue
|
|
277
|
+
outputs = list(tasks_dir.glob(f"*/output/{model}.md"))
|
|
278
|
+
if not outputs:
|
|
279
|
+
return model # never scored: go first
|
|
280
|
+
key = (len(outputs) >= total, # False (incomplete) sorts first
|
|
281
|
+
max(o.stat().st_mtime for o in outputs))
|
|
282
|
+
if best_key is None or key < best_key:
|
|
283
|
+
best, best_key = model, key
|
|
284
|
+
return best
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
PER_RUN = 6 # tasks per scheduled run (--next, --next-free)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def pick_tasks(tasks: list[dict], model: str, n: int = PER_RUN) -> list[dict]:
|
|
291
|
+
"""What a scheduled run asks its one model: the tasks it has no saved
|
|
292
|
+
answer for, else its oldest answers, at most `n`.
|
|
293
|
+
|
|
294
|
+
The schedule was sized for six tasks a run; the suite has grown to over
|
|
295
|
+
fifty, and running all of them each time redid every answer the model
|
|
296
|
+
already had (2026-10-20: 52 calls on one free model at 6am, more than
|
|
297
|
+
OpenRouter's whole free day, and a paid run stopped at its two-hour limit).
|
|
298
|
+
Every result is saved, a failure included, so a gap is a task never asked,
|
|
299
|
+
or one whose saved answer is a rate limit or quota (`capacity`: the
|
|
300
|
+
afternoon, not the model, as pace.py treats it). A failure that is the
|
|
301
|
+
model's own (a context too small, a timeout) stays: asking again would
|
|
302
|
+
only repeat it. Once there are no gaps the oldest answers are refreshed,
|
|
303
|
+
which keeps the scores current."""
|
|
304
|
+
def answer(spec):
|
|
305
|
+
return spec["dir"] / "output" / f"{model}.md" # where main() saves it
|
|
306
|
+
|
|
307
|
+
def gap(spec):
|
|
308
|
+
saved = answer(spec)
|
|
309
|
+
return (not saved.exists()
|
|
310
|
+
or failure_class(saved.read_text(encoding="utf-8", errors="replace")) == "capacity")
|
|
311
|
+
missing = [s for s in tasks if gap(s)]
|
|
312
|
+
if missing:
|
|
313
|
+
return missing[:n]
|
|
314
|
+
return sorted(tasks, key=lambda s: answer(s).stat().st_mtime)[:n]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def load_tasks(only: str | None) -> list[dict]:
|
|
318
|
+
tasks = []
|
|
319
|
+
for path in sorted(TASKS.glob("*/task.json")):
|
|
320
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
321
|
+
spec["dir"] = path.parent
|
|
322
|
+
spec.setdefault("id", path.parent.name)
|
|
323
|
+
if only and spec["id"] != only:
|
|
324
|
+
continue
|
|
325
|
+
# A mined task is a draft until someone has read it (suite/mine.py).
|
|
326
|
+
if spec.get("draft") and not only:
|
|
327
|
+
continue
|
|
328
|
+
tasks.append(spec)
|
|
329
|
+
if only and not tasks:
|
|
330
|
+
sys.exit(f"no task called {only!r}")
|
|
331
|
+
return tasks
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_prompt(spec: dict) -> str:
|
|
335
|
+
parts = [spec["prompt"].strip(), ""]
|
|
336
|
+
key = spec.get("repo", DEFAULT_REPO)
|
|
337
|
+
root = REPOS.get(key)
|
|
338
|
+
for rel in spec.get("attach", []):
|
|
339
|
+
source = root / rel if root else None
|
|
340
|
+
if not (source and source.exists()):
|
|
341
|
+
source = spec["dir"] / "fixtures" / pathlib.Path(rel).name
|
|
342
|
+
if not source.exists():
|
|
343
|
+
raise MissingAttachment(
|
|
344
|
+
f"{spec['id']}: attachment not found: {rel}"
|
|
345
|
+
+ ("" if root else f" (no repo {key!r} in config/repos.json, and no fixture)"))
|
|
346
|
+
parts += [f"===== FILE: {rel} =====",
|
|
347
|
+
source.read_text(encoding="utf-8", errors="replace"), ""]
|
|
348
|
+
return "\n".join(parts)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# A model that was never allowed to answer has not answered badly, and a
|
|
352
|
+
# service that fell over has not answered at all. Reporting either as 0/N is a
|
|
353
|
+
# lie about the model — and reporting them as the SAME thing is a second lie,
|
|
354
|
+
# because one is a fact about the model and the other is a fact about
|
|
355
|
+
# Tuesday afternoon.
|
|
356
|
+
#
|
|
357
|
+
# `nemotron-ultra-primary` returned "Service temporarily overloaded" in 11
|
|
358
|
+
# seconds and was filed beside a safety-filter block until somebody read it.
|
|
359
|
+
REFUSALS = (
|
|
360
|
+
"blocked by gemini's filters",
|
|
361
|
+
"i can't help with that",
|
|
362
|
+
"i cannot help with that",
|
|
363
|
+
"i'm not able to help",
|
|
364
|
+
"against my guidelines",
|
|
365
|
+
"unable to assist",
|
|
366
|
+
)
|
|
367
|
+
ERRORS = (
|
|
368
|
+
"service temporarily overloaded",
|
|
369
|
+
"rate limit",
|
|
370
|
+
"internal server error",
|
|
371
|
+
"bad gateway",
|
|
372
|
+
"connection reset",
|
|
373
|
+
"502",
|
|
374
|
+
"503",
|
|
375
|
+
# --- added 2026-09-04, after a 16-model run scored four of them 0/11 ----
|
|
376
|
+
#
|
|
377
|
+
# Every one of these came back in FOUR SECONDS against a 46,000-character
|
|
378
|
+
# prompt and was recorded as a model that read the code and found nothing.
|
|
379
|
+
# They are the gateway refusing before a model ever saw the question:
|
|
380
|
+
#
|
|
381
|
+
# gpt-oss "Model not found" -- a stale registration
|
|
382
|
+
# mistral-large "not available in your subscription tier"
|
|
383
|
+
# laguna-m1 "No endpoints found for poolside/laguna-m.1:free"
|
|
384
|
+
# qwen3.8-27b "Request too large ... ITPM: Limit 7000"
|
|
385
|
+
#
|
|
386
|
+
# The `llm` CLI prefixes all of them with `Error: Error code: NNN`, which
|
|
387
|
+
# is the reliable signal; the rest are spelled out so a changed prefix
|
|
388
|
+
# cannot silently reopen this.
|
|
389
|
+
"error: error code:",
|
|
390
|
+
"model not found",
|
|
391
|
+
"not available in your subscription",
|
|
392
|
+
"no endpoints found",
|
|
393
|
+
"request too large",
|
|
394
|
+
"insufficient_quota",
|
|
395
|
+
"quota exceeded",
|
|
396
|
+
"context length",
|
|
397
|
+
"too many requests",
|
|
398
|
+
# --- added 2026-09-08, after a dead CLI scored as an answer -------------
|
|
399
|
+
#
|
|
400
|
+
# Every marker above is the PROVIDER failing. These two are the CLIENT
|
|
401
|
+
# failing, and nothing here recognised that class at all. `llm` exiting
|
|
402
|
+
# non-zero produced `Error: 'utf-8' codec can't encode character ...` --
|
|
403
|
+
# 93 characters, over `min_length`, matching no marker -- so `outcome()`
|
|
404
|
+
# returned None and the floor tier sanitised the error text into a
|
|
405
|
+
# "solution" and graded it wrong.
|
|
406
|
+
#
|
|
407
|
+
# `Error: Error code:` was described above as "the reliable signal". It is
|
|
408
|
+
# reliable for UPSTREAM errors only; a client-side crash also begins
|
|
409
|
+
# `Error:` and goes no further. Ordered last so an upstream reason still
|
|
410
|
+
# wins the `detail` when both are present.
|
|
411
|
+
"error: llm exited",
|
|
412
|
+
# The built-in client's counterpart (metrics/client.py, BENCH_CLIENT=builtin).
|
|
413
|
+
"error: client failed",
|
|
414
|
+
"codec can't encode",
|
|
415
|
+
"request limit reached",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# WHY A FAILURE HAPPENED decides whether it is evidence about the MODEL or
|
|
420
|
+
# about the afternoon, and the two look identical from the outside.
|
|
421
|
+
#
|
|
422
|
+
# STRUCTURAL the model is not there. No such name, no provider behind it.
|
|
423
|
+
# Waiting changes nothing and the registration should go.
|
|
424
|
+
# ENTITLEMENT it exists; this account may not call it. A plan decision, not a
|
|
425
|
+
# fault — worth keeping registered to notice if it changes.
|
|
426
|
+
# CAPACITY it is there and busy, throttled, or the prompt is too big for
|
|
427
|
+
# its window. Temporary by nature -- worth retrying, and worth
|
|
428
|
+
# retrying tomorrow.
|
|
429
|
+
# TIMEOUT no answer inside the budget. Says NOTHING about existence: a
|
|
430
|
+
# slow model and a queued request are indistinguishable here.
|
|
431
|
+
#
|
|
432
|
+
# The distinction matters because a model absent from the endpoint's CATALOGUE
|
|
433
|
+
# is deprecated, while one that is listed and failing might be either. A
|
|
434
|
+
# listed model failing STRUCTURALLY every time is retired upstream and the
|
|
435
|
+
# catalogue has not caught up -- which is exactly what `laguna-m1` did.
|
|
436
|
+
# SIGNIN the key itself was refused: 401, or a sign-in metrics/connect.py
|
|
437
|
+
# could not renew. Fixed on the Endpoints page, never by retiring.
|
|
438
|
+
SIGNIN = (
|
|
439
|
+
"error code: 401",
|
|
440
|
+
"sign in again",
|
|
441
|
+
# ChatGPT's plan cannot share usage with apps: the account's, every model at once.
|
|
442
|
+
"sharing_usage_unavailable",
|
|
443
|
+
)
|
|
444
|
+
STRUCTURAL = (
|
|
445
|
+
"model not found",
|
|
446
|
+
"no endpoints found",
|
|
447
|
+
"does not exist",
|
|
448
|
+
"unknown model",
|
|
449
|
+
"invalid model",
|
|
450
|
+
"model_not_found",
|
|
451
|
+
)
|
|
452
|
+
# ENTITLEMENT is its own class, and the difference decides what to DO.
|
|
453
|
+
# `structural` means the model is not there and the registration should go.
|
|
454
|
+
# This means it IS there and this account may not call it — a pricing or plan
|
|
455
|
+
# decision. `mistral-large` is exactly this, and the operator is keeping it
|
|
456
|
+
# registered on purpose to notice if the tier ever changes. Reporting it as a
|
|
457
|
+
# retirement would advise deleting the very thing being watched.
|
|
458
|
+
ENTITLEMENT = (
|
|
459
|
+
"not available in your subscription",
|
|
460
|
+
"not authorized",
|
|
461
|
+
"unauthorized",
|
|
462
|
+
"permission denied",
|
|
463
|
+
"no access to model",
|
|
464
|
+
# Credit exhausted or never funded. The model is there and the account
|
|
465
|
+
# cannot call it, which is an entitlement question rather than a rate
|
|
466
|
+
# limit — waiting does not clear it, adding balance does.
|
|
467
|
+
"insufficient balance",
|
|
468
|
+
"insufficient credit",
|
|
469
|
+
"payment required",
|
|
470
|
+
# OpenRouter gates some free endpoints to apps on its own published list.
|
|
471
|
+
# The model exists and works; this account's CLIENT is not eligible.
|
|
472
|
+
"only available on agentic harnesses",
|
|
473
|
+
"only available on",
|
|
474
|
+
)
|
|
475
|
+
# PAYWALLED is A FORM OF DEPRECATION HERE, which is why it is not folded into
|
|
476
|
+
# ENTITLEMENT. The two look alike -- the model exists and this account cannot
|
|
477
|
+
# call it -- but the ACTION is opposite, and the action is the whole point of
|
|
478
|
+
# separating failure kinds in this file.
|
|
479
|
+
#
|
|
480
|
+
# entitlement keep it registered and watch. `mistral-large` sits behind a
|
|
481
|
+
# subscription tier that could change, and noticing that change
|
|
482
|
+
# is worth a registration doing nothing in the meantime.
|
|
483
|
+
# paywalled a `:free` slug retired behind a paid one. THIS BENCH DOES NOT
|
|
484
|
+
# BUY PAID TIERS FOR THIS, so the model is gone for our
|
|
485
|
+
# purposes as surely as if it had been deleted. Waiting does
|
|
486
|
+
# not fix it: the free endpoint is not coming back. Re-point
|
|
487
|
+
# the registration at the paid slug and pay, or drop it.
|
|
488
|
+
#
|
|
489
|
+
# Two models did this within days of each other, both on OpenRouter, both
|
|
490
|
+
# still listed in the catalogue and both returning 400 on every call:
|
|
491
|
+
#
|
|
492
|
+
# minimax-m3-free -> use this slug instead: minimax/minimax-m3
|
|
493
|
+
# glm-5.2-free -> use this slug instead: z-ai/glm-5.2
|
|
494
|
+
#
|
|
495
|
+
# Filed as `other` and reported by nothing until 2026-09-09.
|
|
496
|
+
PAYWALLED = (
|
|
497
|
+
"unavailable for free",
|
|
498
|
+
"use this slug instead",
|
|
499
|
+
"the paid version is available",
|
|
500
|
+
# OpenRouter's 404 when a launch-only `:free` ends (minimax-m3.1-flash-free,
|
|
501
|
+
# 2026-10-22): "The free launch period ... ended ... variant has been retired".
|
|
502
|
+
"free launch period",
|
|
503
|
+
"variant has been retired",
|
|
504
|
+
)
|
|
505
|
+
CAPACITY = (
|
|
506
|
+
"rate limit",
|
|
507
|
+
"quota exceeded",
|
|
508
|
+
"insufficient_quota",
|
|
509
|
+
# ChatGPT's plan: the weekly cap the user set for this app, or the plan's own.
|
|
510
|
+
"usage_limit_exceeded",
|
|
511
|
+
"too many requests",
|
|
512
|
+
"request too large",
|
|
513
|
+
"context length",
|
|
514
|
+
"service temporarily overloaded",
|
|
515
|
+
"internal server error",
|
|
516
|
+
# OpenRouter's own wording when the upstream provider behind a `:free`
|
|
517
|
+
# model fails. Says nothing about the model — transient, and retryable.
|
|
518
|
+
"provider returned error",
|
|
519
|
+
"bad gateway",
|
|
520
|
+
"502",
|
|
521
|
+
"503",
|
|
522
|
+
# NVIDIA's shared worker being full: "ResourceExhausted: Worker local
|
|
523
|
+
# total request limit reached (33/32)". Other people's load, not ours, and
|
|
524
|
+
# not the model going away -- `laguna-xs-2.1` failed 21 of 44 probes this
|
|
525
|
+
# way, filed as `other`, which the 3-day auto-retire rule would act on.
|
|
526
|
+
"request limit reached",
|
|
527
|
+
"resourceexhausted",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# EVERY CLASS `failure_class` CAN RETURN, WORST FIRST, IN ONE PLACE.
|
|
532
|
+
#
|
|
533
|
+
# `perf/run.py` kept its own copy of this tuple to pick the worst class a
|
|
534
|
+
# model showed across samples, and when `paywalled` was added the copy was not
|
|
535
|
+
# updated -- so `failure_class` correctly returned "paywalled", the lookup
|
|
536
|
+
# found no match, and the row was written with an EMPTY failure. That is worse
|
|
537
|
+
# than a wrong class: `check_deprecations` skips blank failures and keeps
|
|
538
|
+
# whatever it saw last, so two retired models silently kept their old
|
|
539
|
+
# classification. Imported rather than repeated, so the next class added
|
|
540
|
+
# cannot reintroduce this.
|
|
541
|
+
FAILURE_CLASSES = ("structural", "paywalled", "entitlement", "capacity",
|
|
542
|
+
"timeout", "empty", "other")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def failure_class(output: str, min_length: int = 40) -> str | None:
|
|
546
|
+
"""`signin`, `structural`, `paywalled`, `entitlement`, `capacity`,
|
|
547
|
+
`timeout`, or None if the model answered.
|
|
548
|
+
|
|
549
|
+
`min_length` carries the same meaning as in `outcome`, and for the same
|
|
550
|
+
reason: without it a correct one-word reply falls through to the final
|
|
551
|
+
check and is classified `other`. Any caller asking about a short-answer
|
|
552
|
+
prompt must pass it.
|
|
553
|
+
"""
|
|
554
|
+
head = output.strip().lower()[:400]
|
|
555
|
+
if "<<timed out" in head:
|
|
556
|
+
return "timeout"
|
|
557
|
+
# FIRST, because a refused key says nothing about the model: an expired
|
|
558
|
+
# sign-in (Pollinations keys last days) or a revoked key fails every model
|
|
559
|
+
# on the endpoint at once, and `entitlement` retired them the same day.
|
|
560
|
+
for marker in SIGNIN:
|
|
561
|
+
if marker in head:
|
|
562
|
+
return "signin"
|
|
563
|
+
for marker in STRUCTURAL:
|
|
564
|
+
if marker in head:
|
|
565
|
+
return "structural"
|
|
566
|
+
# BEFORE entitlement, because a paywall message can read like one and the
|
|
567
|
+
# two carry opposite advice -- act on it, versus keep it and wait.
|
|
568
|
+
for marker in PAYWALLED:
|
|
569
|
+
if marker in head:
|
|
570
|
+
return "paywalled"
|
|
571
|
+
for marker in ENTITLEMENT:
|
|
572
|
+
if marker in head:
|
|
573
|
+
return "entitlement"
|
|
574
|
+
# A credit/quota notice delivered as a success reply: the account, not the
|
|
575
|
+
# model -- and `other` would count it toward auto-retirement.
|
|
576
|
+
if provider_notice(output):
|
|
577
|
+
return "entitlement"
|
|
578
|
+
for marker in CAPACITY:
|
|
579
|
+
if marker in head:
|
|
580
|
+
return "capacity"
|
|
581
|
+
# NOTHING CAME BACK AT ALL. Usually a reasoning model that spent its whole
|
|
582
|
+
# output budget thinking (the inkling pair at max_tokens 8000), which is a
|
|
583
|
+
# budget problem, not a missing model -- so it is kept out of `other`,
|
|
584
|
+
# which the deprecation check counts toward auto-retirement.
|
|
585
|
+
if not head:
|
|
586
|
+
return "empty"
|
|
587
|
+
return None if outcome(output, min_length) is None else "other"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# A 200 THAT IS NOT AN ANSWER. Some relays return a quota or credit notice as
|
|
591
|
+
# the reply's TEXT with a success status, so nothing upstream flags it and it
|
|
592
|
+
# would be scored as a model that answered badly. Only short replies are read
|
|
593
|
+
# this way: a real answer that discusses quotas is long. (Learned from
|
|
594
|
+
# FreeLLMAPI's openai-compat provider, 2026-10-17.)
|
|
595
|
+
PROVIDER_NOTICES = (
|
|
596
|
+
"run out of credits", "out of credits", "insufficient credits", "credit balance",
|
|
597
|
+
"upgrade your plan", "upgrade to continue", "upgrade your account",
|
|
598
|
+
"exceeded your current quota", "quota has been exhausted", "daily limit reached",
|
|
599
|
+
"you have reached your", "free tier limit", "please add credits", "top up your",
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def provider_notice(output: str) -> str | None:
|
|
604
|
+
"""The notice phrase, if a short reply is a provider's quota/credit notice."""
|
|
605
|
+
text = output.strip().lower()
|
|
606
|
+
if len(text) > 400:
|
|
607
|
+
return None
|
|
608
|
+
return next((m for m in PROVIDER_NOTICES if m in text), None)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def outcome(output: str, min_length: int = 40) -> tuple[str, str] | None:
|
|
612
|
+
"""(kind, detail) for a non-answer, or None if the model actually spoke.
|
|
613
|
+
|
|
614
|
+
`min_length` is the shortest reply that could be a real answer. The default
|
|
615
|
+
suits this suite, whose tasks all demand tables or prose — but it is a
|
|
616
|
+
property of the QUESTION, not of answering. The perf tier asks for a single
|
|
617
|
+
word, and the default scored a correct `ready` as an empty response, which
|
|
618
|
+
made a healthy endpoint look like it was failing every call.
|
|
619
|
+
"""
|
|
620
|
+
lowered = output.strip().lower()
|
|
621
|
+
head = lowered[:400]
|
|
622
|
+
if "<<timed out" in head:
|
|
623
|
+
return ("timeout", "no answer within the budget")
|
|
624
|
+
for marker in ERRORS:
|
|
625
|
+
if marker in head:
|
|
626
|
+
# Retryable, and NOT a fact about the model.
|
|
627
|
+
return ("error", marker)
|
|
628
|
+
if notice := provider_notice(output):
|
|
629
|
+
return ("error", f"provider notice in a success reply: {notice}")
|
|
630
|
+
for marker in REFUSALS:
|
|
631
|
+
if marker in head:
|
|
632
|
+
return ("refused", marker)
|
|
633
|
+
if len(lowered) < min_length:
|
|
634
|
+
return ("error", "empty response")
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def format_check(spec: dict, output: str) -> tuple[bool, str]:
|
|
639
|
+
"""Did the answer arrive in the SHAPE the prompt demanded?
|
|
640
|
+
|
|
641
|
+
Separate from correctness on purpose, because the two failures look
|
|
642
|
+
identical in a recall score and are not the same problem.
|
|
643
|
+
`codestral-latest` returned 13,000 characters on `satellite-writes` and
|
|
644
|
+
scored 0/11 — not because it did not know, but because it never named a
|
|
645
|
+
function, so nothing could be matched. `gemini-3.8-flash-high` returned 150
|
|
646
|
+
characters on `edit-points`: a table header and nothing else.
|
|
647
|
+
|
|
648
|
+
A model that is right but unparseable is useless in a pipeline and fine in
|
|
649
|
+
a chat. That is a routing decision, and it needs its own number.
|
|
650
|
+
|
|
651
|
+
Declared per task under `format`, since only the prompt knows what it
|
|
652
|
+
asked for.
|
|
653
|
+
"""
|
|
654
|
+
want = spec.get("format") or {}
|
|
655
|
+
if not want:
|
|
656
|
+
return True, ""
|
|
657
|
+
text = output.strip()
|
|
658
|
+
rows = [ln for ln in text.splitlines() if ln.lstrip().startswith("|")]
|
|
659
|
+
if want.get("table") and len(rows) < want.get("min_rows", 2):
|
|
660
|
+
return False, f"expected a markdown table, found {len(rows)} row(s)"
|
|
661
|
+
if len(text) < want.get("min_chars", 0):
|
|
662
|
+
return False, (f"{len(text)} chars, under the {want['min_chars']} a "
|
|
663
|
+
f"real answer to this needs")
|
|
664
|
+
for banned in want.get("must_not_contain", []):
|
|
665
|
+
if banned.lower() in text.lower():
|
|
666
|
+
return False, f"contains {banned!r}, which the prompt excluded"
|
|
667
|
+
return True, ""
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def score(spec: dict, output: str) -> dict:
|
|
671
|
+
"""Recall against the verified findings, and invention against reality."""
|
|
672
|
+
# THE SHORTEST VALID ANSWER IS A PROPERTY OF THE QUESTION. `outcome`
|
|
673
|
+
# already said so in its own docstring and this call ignored it, so the
|
|
674
|
+
# calibration task — whose correct answer is the 18 characters
|
|
675
|
+
# "NO DATABASE WRITES" — scored four models as having returned an empty
|
|
676
|
+
# response when all four were exactly right. Third time this class of bug
|
|
677
|
+
# has appeared here; it is declared per task now rather than assumed.
|
|
678
|
+
non_answer = outcome(output, spec.get("min_answer_chars", 40))
|
|
679
|
+
if non_answer:
|
|
680
|
+
kind, detail = non_answer
|
|
681
|
+
return {"found": 0, "of": len(spec.get("must_find", [])),
|
|
682
|
+
"missed": [], "invented": [],
|
|
683
|
+
"blocked": f"{kind}: {detail}", "kind": kind,
|
|
684
|
+
"format_ok": None, "format_why": ""}
|
|
685
|
+
lowered = output.lower()
|
|
686
|
+
# `score_in: "first_line"`: a claim-verification task asks for its verdict
|
|
687
|
+
# on the first line, so only that line is scored. Reasoning that wanders
|
|
688
|
+
# towards TRUE and ends FALSE is judged on what it committed to first.
|
|
689
|
+
if spec.get("score_in") == "first_line":
|
|
690
|
+
lowered = next((ln.strip() for ln in lowered.splitlines() if ln.strip()), "")
|
|
691
|
+
|
|
692
|
+
def present(clause) -> bool:
|
|
693
|
+
# A clause is a string, {"any_of": [...]} where any spelling of the
|
|
694
|
+
# same finding counts, or {"regex": "..."} where a substring cannot be
|
|
695
|
+
# exact ("fault: line 1" is a substring of "fault: line 12").
|
|
696
|
+
if isinstance(clause, dict) and "regex" in clause:
|
|
697
|
+
return re.search(clause["regex"], lowered) is not None
|
|
698
|
+
if isinstance(clause, dict):
|
|
699
|
+
return any(alt.lower() in lowered for alt in clause["any_of"])
|
|
700
|
+
return clause.lower() in lowered
|
|
701
|
+
|
|
702
|
+
findings = spec.get("must_find", [])
|
|
703
|
+
found = [c for c in findings if present(c)]
|
|
704
|
+
missed = [c for c in findings if not present(c)]
|
|
705
|
+
|
|
706
|
+
# INVENTION. Anything asserted here is not true of the code, so saying it
|
|
707
|
+
# is a fabrication rather than a miss. This is the score that matters:
|
|
708
|
+
# a confident wrong answer costs more than a silent one.
|
|
709
|
+
#
|
|
710
|
+
# But WHERE it is said decides whether it was asserted at all. On
|
|
711
|
+
# `satellite-writes`, kimi-k3 wrote "writes in wave_contact_rows ... are
|
|
712
|
+
# out of scope and not listed" -- demonstrating precisely the
|
|
713
|
+
# understanding the trap tests -- and a whole-output substring match
|
|
714
|
+
# scored it as a fabrication. That is the scorer inventing, not the model.
|
|
715
|
+
#
|
|
716
|
+
# `must_not_claim_in: "table"` confines the check to markdown table rows,
|
|
717
|
+
# where a task asks for its answer. Naming the item in a row is the claim;
|
|
718
|
+
# naming it in prose to rule it out is the opposite of one.
|
|
719
|
+
claim_text = lowered
|
|
720
|
+
if spec.get("must_not_claim_in") == "table":
|
|
721
|
+
claim_text = "\n".join(ln for ln in lowered.splitlines()
|
|
722
|
+
if ln.lstrip().startswith("|"))
|
|
723
|
+
|
|
724
|
+
# A NEGATED MENTION IS NOT A CLAIM. "NO DATABASE WRITES: there are no
|
|
725
|
+
# INSERT or DELETE statements" is the right answer to `no-writes-here`, and
|
|
726
|
+
# a bare substring match scored it as inventing an INSERT. (Latent when
|
|
727
|
+
# found on 2026-10-16: every stored answer was terse enough to escape it.)
|
|
728
|
+
def claimed(clause) -> bool:
|
|
729
|
+
if isinstance(clause, dict) and "regex" in clause:
|
|
730
|
+
return re.search(clause["regex"], claim_text) is not None
|
|
731
|
+
alts = clause["any_of"] if isinstance(clause, dict) else [clause]
|
|
732
|
+
return any(asserted(claim_text, alt.lower()) for alt in alts)
|
|
733
|
+
|
|
734
|
+
invented = [c for c in spec.get("must_not_claim", []) if claimed(c)]
|
|
735
|
+
|
|
736
|
+
shape_ok, shape_why = format_check(spec, output)
|
|
737
|
+
return {
|
|
738
|
+
"found": len(found), "of": len(findings),
|
|
739
|
+
"missed": [_label(c) for c in missed],
|
|
740
|
+
"invented": [_label(c) for c in invented],
|
|
741
|
+
"blocked": None, "kind": "answered",
|
|
742
|
+
# Shape, judged apart from substance. A low recall WITH a format
|
|
743
|
+
# failure means "could not be read"; a low recall with a clean format
|
|
744
|
+
# means "did not know".
|
|
745
|
+
"format_ok": shape_ok, "format_why": shape_why,
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
NEGATION = re.compile(r"\b(no|not|never|none|without|zero|cannot|can't|isn't|aren't|"
|
|
750
|
+
r"doesn't|don't|didn't|false|disproved|neither|nor)\b")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def asserted(text: str, phrase: str) -> bool:
|
|
754
|
+
"""True if `phrase` occurs in `text` at least once WITHOUT a negation
|
|
755
|
+
earlier in the same sentence (within 60 characters)."""
|
|
756
|
+
start = 0
|
|
757
|
+
while (i := text.find(phrase, start)) != -1:
|
|
758
|
+
before = text[max(0, i - 60):i]
|
|
759
|
+
before = re.split(r"[.!?\n]", before)[-1] # this sentence only
|
|
760
|
+
if not NEGATION.search(before):
|
|
761
|
+
return True
|
|
762
|
+
start = i + 1
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _label(clause) -> str:
|
|
767
|
+
if isinstance(clause, dict):
|
|
768
|
+
return clause.get("label") or clause.get("regex") or clause["any_of"][0]
|
|
769
|
+
return clause
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# THE DIRECT PATH IS GONE, since 2026-10-15. The inkling pair used to bypass
|
|
773
|
+
# Open WebUI here, with OpenRouter's key read live out of Open WebUI's config.
|
|
774
|
+
# They are now ordinary registrations on the `openrouter` endpoint
|
|
775
|
+
# (config/endpoints.json), with the attribution headers in their yaml entry and
|
|
776
|
+
# max_tokens in config/model-options.json, so `llm -m` reaches them like any
|
|
777
|
+
# other model.
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _child_env() -> dict:
|
|
781
|
+
"""The environment the `llm` CLI must run in, or its output is corrupted.
|
|
782
|
+
|
|
783
|
+
WE READ UTF-8; THE CHILD WROTE CP1252. When `llm`'s stdout is a pipe --
|
|
784
|
+
which it always is under `capture_output=True` -- Windows gives the child
|
|
785
|
+
`locale.getpreferredencoding()`, i.e. cp1252, while the `encoding="utf-8"`
|
|
786
|
+
below decodes what comes back as UTF-8. The two disagreed, and that broke
|
|
787
|
+
in two directions:
|
|
788
|
+
|
|
789
|
+
CRASH an em dash, curly quote or non-breaking hyphen has no cp1252
|
|
790
|
+
encoding, so the CLI died mid-write. `nemotron-lightning`
|
|
791
|
+
returned 378 bytes of a real answer and then
|
|
792
|
+
`charmap codec can't encode '\\u2011'`.
|
|
793
|
+
CORRUPTION anything cp1252 COULD encode (accented letters) arrived as
|
|
794
|
+
bytes this end then decoded as UTF-8, and `errors="replace"`
|
|
795
|
+
quietly turned them into replacement characters.
|
|
796
|
+
|
|
797
|
+
The crash was the worse of the two, because `outcome()` does not recognise
|
|
798
|
+
a CLIENT-side failure: the message is over `min_length` and matches no
|
|
799
|
+
marker in `ERRORS`, so a dead CLI scored as `answered` and the truncated
|
|
800
|
+
text was graded as the model's work. An infrastructure failure recorded as
|
|
801
|
+
model incompetence is exactly the confusion this repo exists to prevent.
|
|
802
|
+
|
|
803
|
+
Forcing UTF-8 on the child makes both ends agree. `PYTHONUTF8` covers
|
|
804
|
+
interpreters that ignore `PYTHONIOENCODING`.
|
|
805
|
+
"""
|
|
806
|
+
env = dict(os.environ)
|
|
807
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
808
|
+
env["PYTHONUTF8"] = "1"
|
|
809
|
+
return env
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def ask_once(binary: str, model: str, prompt: str,
|
|
813
|
+
timeout: int) -> tuple[str, float]:
|
|
814
|
+
# BENCH_CLIENT=builtin: the same call without the `llm` subprocess, giving
|
|
815
|
+
# the same text for the same failure (metrics/client.py).
|
|
816
|
+
if str(ROOT / "metrics") not in sys.path:
|
|
817
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
818
|
+
import client
|
|
819
|
+
if client.backend(model) == "builtin":
|
|
820
|
+
res = client.chat(model, prompt, timeout=timeout)
|
|
821
|
+
return client.output(res, timeout), res["seconds"]
|
|
822
|
+
started = time.monotonic()
|
|
823
|
+
try:
|
|
824
|
+
out = subprocess.run(
|
|
825
|
+
[binary, "-m", model], input=prompt, capture_output=True,
|
|
826
|
+
text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
|
827
|
+
env=_child_env())
|
|
828
|
+
body = out.stdout or out.stderr
|
|
829
|
+
# A NON-ZERO EXIT IS FREE EVIDENCE AND WAS BEING THROWN AWAY. On a
|
|
830
|
+
# mid-stream crash `out.stdout` holds real but TRUNCATED text, so
|
|
831
|
+
# `stdout or stderr` prefers it and the failure becomes invisible --
|
|
832
|
+
# it reads as a model that simply stopped early. The exit code is the
|
|
833
|
+
# only signal that separates those, so it decides, not the text --
|
|
834
|
+
# except when it is only llm failing to log an answer it printed in
|
|
835
|
+
# full, its logs.db locked by another llm (metrics/client.py).
|
|
836
|
+
if out.returncode != 0 and not client.llm_log_failed_only(out.stdout, out.stderr):
|
|
837
|
+
detail = (out.stderr or body or "").strip().replace("\n", " ")
|
|
838
|
+
body = f"Error: llm exited {out.returncode}: {detail[:300]}"
|
|
839
|
+
except subprocess.TimeoutExpired:
|
|
840
|
+
body = f"<<TIMED OUT after {timeout}s>>"
|
|
841
|
+
return body, time.monotonic() - started
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def ask(binary: str, model: str, prompt: str, timeout: int,
|
|
845
|
+
attempts: int = 3) -> tuple[str, float, int]:
|
|
846
|
+
"""Ask, retrying UPSTREAM FAILURES only.
|
|
847
|
+
|
|
848
|
+
A rate limit or an overloaded service is a fact about the afternoon, not
|
|
849
|
+
about the model, and retrying is the only way to tell one from the other.
|
|
850
|
+
A refusal is not retried — a filter will refuse again — and neither is a
|
|
851
|
+
timeout, because retrying a five-minute wait turns one slow run into
|
|
852
|
+
fifteen minutes of the same answer.
|
|
853
|
+
"""
|
|
854
|
+
total = 0.0
|
|
855
|
+
for attempt in range(1, attempts + 1):
|
|
856
|
+
body, seconds = ask_once(binary, model, prompt, timeout)
|
|
857
|
+
total += seconds
|
|
858
|
+
kind = (outcome(body) or ("answered", ""))[0]
|
|
859
|
+
if kind != "error" or attempt == attempts:
|
|
860
|
+
return body, total, attempt
|
|
861
|
+
# Back off before trying again: hammering a service that just said it
|
|
862
|
+
# was overloaded is how one failure becomes three.
|
|
863
|
+
time.sleep(5 * attempt)
|
|
864
|
+
return body, total, attempts
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def main() -> int:
|
|
868
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
869
|
+
ap.add_argument("--task")
|
|
870
|
+
ap.add_argument("--model", action="append", dest="models")
|
|
871
|
+
ap.add_argument("--timeout", type=int, default=420)
|
|
872
|
+
ap.add_argument("--repeat", type=int, default=1,
|
|
873
|
+
help="ask each model the same task N times and report the "
|
|
874
|
+
"SPREAD. Every score in this suite has been n=1, so a "
|
|
875
|
+
"model that scores 10/11 once and 6/11 the next time "
|
|
876
|
+
"reads identically to one that scores 8/11 twice — "
|
|
877
|
+
"and they are not interchangeable")
|
|
878
|
+
ap.add_argument("--next-free", action="store_true", dest="next_free",
|
|
879
|
+
help="score ONE OpenRouter :free model — the one most in "
|
|
880
|
+
"need. For a daily timer: the free tier is 50 calls "
|
|
881
|
+
"a day shared across all of them, so a full sweep "
|
|
882
|
+
"would spend most of the budget in one go")
|
|
883
|
+
ap.add_argument("--next", action="store_true", dest="pick_next",
|
|
884
|
+
help="score ONE paid model — the one most in need. For a "
|
|
885
|
+
"timer: never-scored first, then INCOMPLETE, then "
|
|
886
|
+
"oldest. This is the tier that matches how these "
|
|
887
|
+
"models are actually used, so it is the one worth "
|
|
888
|
+
"automating. `:free` models are left to --next-free")
|
|
889
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
890
|
+
args = ap.parse_args()
|
|
891
|
+
|
|
892
|
+
tasks = load_tasks(args.task)
|
|
893
|
+
if args.next_free:
|
|
894
|
+
chosen = next_free(TASKS)
|
|
895
|
+
if chosen is None:
|
|
896
|
+
sys.exit("no :free model registered")
|
|
897
|
+
args.models = [chosen]
|
|
898
|
+
tasks = pick_tasks(tasks, chosen)
|
|
899
|
+
print(f"--next-free chose {chosen}: {len(tasks)} tasks "
|
|
900
|
+
f"(one model per run; the free tier is 50 calls a day, shared)")
|
|
901
|
+
|
|
902
|
+
if args.pick_next:
|
|
903
|
+
chosen = next_model(TASKS)
|
|
904
|
+
if chosen is None:
|
|
905
|
+
sys.exit("no paid model registered")
|
|
906
|
+
args.models = [chosen]
|
|
907
|
+
total = len([p for p in TASKS.iterdir() if p.is_dir()])
|
|
908
|
+
have = len(list(TASKS.glob(f"*/output/{chosen}.md")))
|
|
909
|
+
tasks = pick_tasks(tasks, chosen)
|
|
910
|
+
print(f"--next chose {chosen} ({have}/{total} tasks scored; "
|
|
911
|
+
f"{len(tasks)} this run)")
|
|
912
|
+
|
|
913
|
+
models = args.models or [m for m in available_models()
|
|
914
|
+
if m not in free_models() or args.next_free]
|
|
915
|
+
binary = None if args.dry_run else llm_binary()
|
|
916
|
+
|
|
917
|
+
# A task whose files cannot be found is skipped, with a line saying why;
|
|
918
|
+
# it used to end the whole run.
|
|
919
|
+
ready = []
|
|
920
|
+
for spec in tasks:
|
|
921
|
+
try:
|
|
922
|
+
ready.append((spec, build_prompt(spec)))
|
|
923
|
+
except MissingAttachment as exc:
|
|
924
|
+
print(f"skipped {exc}")
|
|
925
|
+
tasks, prompts = [s for s, _ in ready], {s["id"]: p for s, p in ready}
|
|
926
|
+
|
|
927
|
+
if args.dry_run:
|
|
928
|
+
for spec in tasks:
|
|
929
|
+
prompt = prompts[spec["id"]]
|
|
930
|
+
print(f"=== {spec['id']} ({len(prompt):,} chars) ===")
|
|
931
|
+
print(prompt[:600])
|
|
932
|
+
print("...\n")
|
|
933
|
+
return 0
|
|
934
|
+
|
|
935
|
+
RUNS.mkdir(exist_ok=True)
|
|
936
|
+
stamp = dt.datetime.now().strftime("%Y-%m-%d")
|
|
937
|
+
report = RUNS / f"suite-{stamp}.md"
|
|
938
|
+
rows = []
|
|
939
|
+
|
|
940
|
+
for spec in tasks:
|
|
941
|
+
prompt = prompts[spec["id"]]
|
|
942
|
+
print(f"\n## {spec['id']} — {spec['title']}")
|
|
943
|
+
print(f" {len(prompt):,} chars, {len(spec.get('must_find', []))} findings\n")
|
|
944
|
+
for model in models:
|
|
945
|
+
# N attempts at the SAME question. The spread is the point: a
|
|
946
|
+
# single sample is a measurement of one afternoon, and this suite
|
|
947
|
+
# has been reporting those as though they were properties.
|
|
948
|
+
takes = []
|
|
949
|
+
for _ in range(max(1, args.repeat)):
|
|
950
|
+
body, seconds, attempts = ask(binary, model, prompt,
|
|
951
|
+
args.timeout)
|
|
952
|
+
takes.append((score(spec, body), seconds, attempts, body))
|
|
953
|
+
if args.repeat > 1:
|
|
954
|
+
time.sleep(2)
|
|
955
|
+
result, seconds, attempts, body = takes[0]
|
|
956
|
+
scored = [t[0]["found"] for t in takes
|
|
957
|
+
if t[0]["kind"] == "answered"]
|
|
958
|
+
row = {"task": spec["id"], "model": model,
|
|
959
|
+
"seconds": round(seconds, 1), "attempts": attempts,
|
|
960
|
+
**result}
|
|
961
|
+
if args.repeat > 1:
|
|
962
|
+
row["takes"] = len(takes)
|
|
963
|
+
row["found_min"] = min(scored) if scored else None
|
|
964
|
+
row["found_max"] = max(scored) if scored else None
|
|
965
|
+
# The number that decides whether a single score can be
|
|
966
|
+
# trusted at all.
|
|
967
|
+
row["spread"] = ((max(scored) - min(scored))
|
|
968
|
+
if len(scored) > 1 else 0)
|
|
969
|
+
rows.append(row)
|
|
970
|
+
# Tell the counter these were MEASUREMENT, not work. Without it a
|
|
971
|
+
# model's call count says how much it was benchmarked and reads as
|
|
972
|
+
# how much it was relied on — the opposite of the truth.
|
|
973
|
+
_note_benchmark(model, len(takes))
|
|
974
|
+
# A courtesy gap. The first baseline fired fifteen calls back to
|
|
975
|
+
# back, which is itself a plausible way to trip a shared limit.
|
|
976
|
+
time.sleep(2)
|
|
977
|
+
tries = f" x{attempts}" if attempts > 1 else ""
|
|
978
|
+
if args.repeat > 1 and row.get("found_min") is not None:
|
|
979
|
+
tries += (f" [{row['found_min']}-{row['found_max']} over "
|
|
980
|
+
f"{row['takes']}]")
|
|
981
|
+
if result["blocked"]:
|
|
982
|
+
_learn(model, body, "suite")
|
|
983
|
+
print(f" {model:24} {result['kind'].upper():8} {seconds:6.1f}s{tries}"
|
|
984
|
+
f" ({result['blocked'].split(': ', 1)[1]})")
|
|
985
|
+
else:
|
|
986
|
+
flag = (" INVENTED " + ", ".join(result["invented"])
|
|
987
|
+
if result["invented"] else "")
|
|
988
|
+
print(f" {model:24} {result['found']}/{result['of']}"
|
|
989
|
+
f" {seconds:6.1f}s{tries}{flag}")
|
|
990
|
+
(spec["dir"] / "output").mkdir(exist_ok=True)
|
|
991
|
+
(spec["dir"] / "output" / f"{model}.md").write_text(body, encoding="utf-8")
|
|
992
|
+
|
|
993
|
+
_write_report(report, stamp, rows)
|
|
994
|
+
print(f"\nwritten: {report}")
|
|
995
|
+
return 0
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _learn(model: str, body: str, where: str) -> None:
|
|
999
|
+
"""Pass a failed call's text to metrics/learn.py: a context window or rate
|
|
1000
|
+
limit the error states, and a cooldown if it was rate-limited. Never lets
|
|
1001
|
+
a learning failure break a run."""
|
|
1002
|
+
try:
|
|
1003
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
1004
|
+
import learn
|
|
1005
|
+
learn.record(model, body, where)
|
|
1006
|
+
except Exception: # noqa: BLE001
|
|
1007
|
+
pass
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _write_report(path: pathlib.Path, stamp: str, rows: list[dict]) -> None:
|
|
1011
|
+
lines = [f"\n## Suite run {stamp}\n",
|
|
1012
|
+
"| Task | Model | Found | Invented | Seconds |",
|
|
1013
|
+
"| --- | --- | --- | --- | --- |"]
|
|
1014
|
+
for r in rows:
|
|
1015
|
+
invented = ", ".join(r["invented"]) if r["invented"] else "—"
|
|
1016
|
+
found = (r.get("kind") if r.get("blocked")
|
|
1017
|
+
else f"{r['found']}/{r['of']}")
|
|
1018
|
+
tries = f" (x{r.get('attempts', 1)})" if r.get("attempts", 1) > 1 else ""
|
|
1019
|
+
lines.append(f"| {r['task']} | `{r['model']}` | {found}"
|
|
1020
|
+
f" | {invented} | {r['seconds']}{tries} |")
|
|
1021
|
+
non_answers = [r for r in rows if r.get("blocked")]
|
|
1022
|
+
if non_answers:
|
|
1023
|
+
lines += ["", "**Not a score — the model did not answer:**"]
|
|
1024
|
+
for r in non_answers:
|
|
1025
|
+
lines.append(f"- `{r['model']}` on {r['task']}: {r['blocked']}")
|
|
1026
|
+
lines += ["",
|
|
1027
|
+
"`error` is retryable and says nothing about the model — it "
|
|
1028
|
+
"was already retried up to three times before appearing "
|
|
1029
|
+
"here.",
|
|
1030
|
+
"",
|
|
1031
|
+
"**A `timeout` is not clean evidence either.** From the "
|
|
1032
|
+
"client there is no difference between a model that is slow "
|
|
1033
|
+
"and a request that queued behind a rate limit. Treat "
|
|
1034
|
+
"timeouts as \"did not answer through this path today\", "
|
|
1035
|
+
"not as a property of the model."]
|
|
1036
|
+
misses = [r for r in rows if r["missed"]]
|
|
1037
|
+
if misses:
|
|
1038
|
+
lines += ["", "**Missed:**"]
|
|
1039
|
+
for r in misses:
|
|
1040
|
+
lines.append(f"- `{r['model']}` on {r['task']}: {', '.join(r['missed'])}")
|
|
1041
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
1042
|
+
path.write_text(existing + "\n".join(lines) + "\n", encoding="utf-8")
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
if __name__ == "__main__":
|
|
1046
|
+
raise SystemExit(main())
|