@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

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 (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. 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
- CAPACITY = (
502
- "rate limit",
503
- "quota exceeded",
504
- "insufficient_quota",
505
- # ChatGPT's plan: the weekly cap the user set for this app, or the plan's own.
506
- "usage_limit_exceeded",
507
- "too many requests",
508
- "request too large",
509
- "context length",
510
- "service temporarily overloaded",
511
- "internal server error",
512
- # OpenRouter's own wording when the upstream provider behind a `:free`
513
- # model fails. Says nothing about the model — transient, and retryable.
514
- "provider returned error",
515
- "bad gateway",
516
- "502",
517
- "503",
518
- # NVIDIA's shared worker being full: "ResourceExhausted: Worker local
519
- # total request limit reached (33/32)". Other people's load, not ours, and
520
- # not the model going away -- `laguna-xs-2.1` failed 21 of 44 probes this
521
- # way, filed as `other`, which the 3-day auto-retire rule would act on.
522
- "request limit reached",
523
- "resourceexhausted",
524
- )
525
-
526
-
527
- # EVERY CLASS `failure_class` CAN RETURN, WORST FIRST, IN ONE PLACE.
528
- #
529
- # `perf/run.py` kept its own copy of this tuple to pick the worst class a
530
- # model showed across samples, and when `paywalled` was added the copy was not
531
- # updated -- so `failure_class` correctly returned "paywalled", the lookup
532
- # found no match, and the row was written with an EMPTY failure. That is worse
533
- # than a wrong class: `check_deprecations` skips blank failures and keeps
534
- # whatever it saw last, so two retired models silently kept their old
535
- # classification. Imported rather than repeated, so the next class added
536
- # cannot reintroduce this.
537
- FAILURE_CLASSES = ("structural", "paywalled", "entitlement", "capacity",
538
- "timeout", "empty", "other")
539
-
540
-
541
- def failure_class(output: str, min_length: int = 40) -> str | None:
542
- """`signin`, `structural`, `paywalled`, `entitlement`, `capacity`,
543
- `timeout`, or None if the model answered.
544
-
545
- `min_length` carries the same meaning as in `outcome`, and for the same
546
- reason: without it a correct one-word reply falls through to the final
547
- check and is classified `other`. Any caller asking about a short-answer
548
- prompt must pass it.
549
- """
550
- head = output.strip().lower()[:400]
551
- if "<<timed out" in head:
552
- return "timeout"
553
- # FIRST, because a refused key says nothing about the model: an expired
554
- # sign-in (Pollinations keys last days) or a revoked key fails every model
555
- # on the endpoint at once, and `entitlement` retired them the same day.
556
- for marker in SIGNIN:
557
- if marker in head:
558
- return "signin"
559
- for marker in STRUCTURAL:
560
- if marker in head:
561
- return "structural"
562
- # BEFORE entitlement, because a paywall message can read like one and the
563
- # two carry opposite advice -- act on it, versus keep it and wait.
564
- for marker in PAYWALLED:
565
- if marker in head:
566
- return "paywalled"
567
- for marker in ENTITLEMENT:
568
- if marker in head:
569
- return "entitlement"
570
- # A credit/quota notice delivered as a success reply: the account, not the
571
- # model -- and `other` would count it toward auto-retirement.
572
- if provider_notice(output):
573
- return "entitlement"
574
- for marker in CAPACITY:
575
- if marker in head:
576
- return "capacity"
577
- # NOTHING CAME BACK AT ALL. Usually a reasoning model that spent its whole
578
- # output budget thinking (the inkling pair at max_tokens 8000), which is a
579
- # budget problem, not a missing model -- so it is kept out of `other`,
580
- # which the deprecation check counts toward auto-retirement.
581
- if not head:
582
- return "empty"
583
- return None if outcome(output, min_length) is None else "other"
584
-
585
-
586
- # A 200 THAT IS NOT AN ANSWER. Some relays return a quota or credit notice as
587
- # the reply's TEXT with a success status, so nothing upstream flags it and it
588
- # would be scored as a model that answered badly. Only short replies are read
589
- # this way: a real answer that discusses quotas is long. (Learned from
590
- # FreeLLMAPI's openai-compat provider, 2026-10-17.)
591
- PROVIDER_NOTICES = (
592
- "run out of credits", "out of credits", "insufficient credits", "credit balance",
593
- "upgrade your plan", "upgrade to continue", "upgrade your account",
594
- "exceeded your current quota", "quota has been exhausted", "daily limit reached",
595
- "you have reached your", "free tier limit", "please add credits", "top up your",
596
- )
597
-
598
-
599
- def provider_notice(output: str) -> str | None:
600
- """The notice phrase, if a short reply is a provider's quota/credit notice."""
601
- text = output.strip().lower()
602
- if len(text) > 400:
603
- return None
604
- return next((m for m in PROVIDER_NOTICES if m in text), None)
605
-
606
-
607
- def outcome(output: str, min_length: int = 40) -> tuple[str, str] | None:
608
- """(kind, detail) for a non-answer, or None if the model actually spoke.
609
-
610
- `min_length` is the shortest reply that could be a real answer. The default
611
- suits this suite, whose tasks all demand tables or prose — but it is a
612
- property of the QUESTION, not of answering. The perf tier asks for a single
613
- word, and the default scored a correct `ready` as an empty response, which
614
- made a healthy endpoint look like it was failing every call.
615
- """
616
- lowered = output.strip().lower()
617
- head = lowered[:400]
618
- if "<<timed out" in head:
619
- return ("timeout", "no answer within the budget")
620
- for marker in ERRORS:
621
- if marker in head:
622
- # Retryable, and NOT a fact about the model.
623
- return ("error", marker)
624
- if notice := provider_notice(output):
625
- return ("error", f"provider notice in a success reply: {notice}")
626
- for marker in REFUSALS:
627
- if marker in head:
628
- return ("refused", marker)
629
- if len(lowered) < min_length:
630
- return ("error", "empty response")
631
- return None
632
-
633
-
634
- def format_check(spec: dict, output: str) -> tuple[bool, str]:
635
- """Did the answer arrive in the SHAPE the prompt demanded?
636
-
637
- Separate from correctness on purpose, because the two failures look
638
- identical in a recall score and are not the same problem.
639
- `codestral-latest` returned 13,000 characters on `satellite-writes` and
640
- scored 0/11 — not because it did not know, but because it never named a
641
- function, so nothing could be matched. `gemini-3.8-flash-high` returned 150
642
- characters on `edit-points`: a table header and nothing else.
643
-
644
- A model that is right but unparseable is useless in a pipeline and fine in
645
- a chat. That is a routing decision, and it needs its own number.
646
-
647
- Declared per task under `format`, since only the prompt knows what it
648
- asked for.
649
- """
650
- want = spec.get("format") or {}
651
- if not want:
652
- return True, ""
653
- text = output.strip()
654
- rows = [ln for ln in text.splitlines() if ln.lstrip().startswith("|")]
655
- if want.get("table") and len(rows) < want.get("min_rows", 2):
656
- return False, f"expected a markdown table, found {len(rows)} row(s)"
657
- if len(text) < want.get("min_chars", 0):
658
- return False, (f"{len(text)} chars, under the {want['min_chars']} a "
659
- f"real answer to this needs")
660
- for banned in want.get("must_not_contain", []):
661
- if banned.lower() in text.lower():
662
- return False, f"contains {banned!r}, which the prompt excluded"
663
- return True, ""
664
-
665
-
666
- def score(spec: dict, output: str) -> dict:
667
- """Recall against the verified findings, and invention against reality."""
668
- # THE SHORTEST VALID ANSWER IS A PROPERTY OF THE QUESTION. `outcome`
669
- # already said so in its own docstring and this call ignored it, so the
670
- # calibration task — whose correct answer is the 18 characters
671
- # "NO DATABASE WRITES" — scored four models as having returned an empty
672
- # response when all four were exactly right. Third time this class of bug
673
- # has appeared here; it is declared per task now rather than assumed.
674
- non_answer = outcome(output, spec.get("min_answer_chars", 40))
675
- if non_answer:
676
- kind, detail = non_answer
677
- return {"found": 0, "of": len(spec.get("must_find", [])),
678
- "missed": [], "invented": [],
679
- "blocked": f"{kind}: {detail}", "kind": kind,
680
- "format_ok": None, "format_why": ""}
681
- lowered = output.lower()
682
- # `score_in: "first_line"`: a claim-verification task asks for its verdict
683
- # on the first line, so only that line is scored. Reasoning that wanders
684
- # towards TRUE and ends FALSE is judged on what it committed to first.
685
- if spec.get("score_in") == "first_line":
686
- lowered = next((ln.strip() for ln in lowered.splitlines() if ln.strip()), "")
687
-
688
- def present(clause) -> bool:
689
- # A clause is a string, {"any_of": [...]} where any spelling of the
690
- # same finding counts, or {"regex": "..."} where a substring cannot be
691
- # exact ("fault: line 1" is a substring of "fault: line 12").
692
- if isinstance(clause, dict) and "regex" in clause:
693
- return re.search(clause["regex"], lowered) is not None
694
- if isinstance(clause, dict):
695
- return any(alt.lower() in lowered for alt in clause["any_of"])
696
- return clause.lower() in lowered
697
-
698
- findings = spec.get("must_find", [])
699
- found = [c for c in findings if present(c)]
700
- missed = [c for c in findings if not present(c)]
701
-
702
- # INVENTION. Anything asserted here is not true of the code, so saying it
703
- # is a fabrication rather than a miss. This is the score that matters:
704
- # a confident wrong answer costs more than a silent one.
705
- #
706
- # But WHERE it is said decides whether it was asserted at all. On
707
- # `satellite-writes`, kimi-k3 wrote "writes in wave_contact_rows ... are
708
- # out of scope and not listed" -- demonstrating precisely the
709
- # understanding the trap tests -- and a whole-output substring match
710
- # scored it as a fabrication. That is the scorer inventing, not the model.
711
- #
712
- # `must_not_claim_in: "table"` confines the check to markdown table rows,
713
- # where a task asks for its answer. Naming the item in a row is the claim;
714
- # naming it in prose to rule it out is the opposite of one.
715
- claim_text = lowered
716
- if spec.get("must_not_claim_in") == "table":
717
- claim_text = "\n".join(ln for ln in lowered.splitlines()
718
- if ln.lstrip().startswith("|"))
719
-
720
- # A NEGATED MENTION IS NOT A CLAIM. "NO DATABASE WRITES: there are no
721
- # INSERT or DELETE statements" is the right answer to `no-writes-here`, and
722
- # a bare substring match scored it as inventing an INSERT. (Latent when
723
- # found on 2026-10-16: every stored answer was terse enough to escape it.)
724
- def claimed(clause) -> bool:
725
- if isinstance(clause, dict) and "regex" in clause:
726
- return re.search(clause["regex"], claim_text) is not None
727
- alts = clause["any_of"] if isinstance(clause, dict) else [clause]
728
- return any(asserted(claim_text, alt.lower()) for alt in alts)
729
-
730
- invented = [c for c in spec.get("must_not_claim", []) if claimed(c)]
731
-
732
- shape_ok, shape_why = format_check(spec, output)
733
- return {
734
- "found": len(found), "of": len(findings),
735
- "missed": [_label(c) for c in missed],
736
- "invented": [_label(c) for c in invented],
737
- "blocked": None, "kind": "answered",
738
- # Shape, judged apart from substance. A low recall WITH a format
739
- # failure means "could not be read"; a low recall with a clean format
740
- # means "did not know".
741
- "format_ok": shape_ok, "format_why": shape_why,
742
- }
743
-
744
-
745
- NEGATION = re.compile(r"\b(no|not|never|none|without|zero|cannot|can't|isn't|aren't|"
746
- r"doesn't|don't|didn't|false|disproved|neither|nor)\b")
747
-
748
-
749
- def asserted(text: str, phrase: str) -> bool:
750
- """True if `phrase` occurs in `text` at least once WITHOUT a negation
751
- earlier in the same sentence (within 60 characters)."""
752
- start = 0
753
- while (i := text.find(phrase, start)) != -1:
754
- before = text[max(0, i - 60):i]
755
- before = re.split(r"[.!?\n]", before)[-1] # this sentence only
756
- if not NEGATION.search(before):
757
- return True
758
- start = i + 1
759
- return False
760
-
761
-
762
- def _label(clause) -> str:
763
- if isinstance(clause, dict):
764
- return clause.get("label") or clause.get("regex") or clause["any_of"][0]
765
- return clause
766
-
767
-
768
- # THE DIRECT PATH IS GONE, since 2026-10-15. The inkling pair used to bypass
769
- # Open WebUI here, with OpenRouter's key read live out of Open WebUI's config.
770
- # They are now ordinary registrations on the `openrouter` endpoint
771
- # (config/endpoints.json), with the attribution headers in their yaml entry and
772
- # max_tokens in config/model-options.json, so `llm -m` reaches them like any
773
- # other model.
774
-
775
-
776
- def _child_env() -> dict:
777
- """The environment the `llm` CLI must run in, or its output is corrupted.
778
-
779
- WE READ UTF-8; THE CHILD WROTE CP1252. When `llm`'s stdout is a pipe --
780
- which it always is under `capture_output=True` -- Windows gives the child
781
- `locale.getpreferredencoding()`, i.e. cp1252, while the `encoding="utf-8"`
782
- below decodes what comes back as UTF-8. The two disagreed, and that broke
783
- in two directions:
784
-
785
- CRASH an em dash, curly quote or non-breaking hyphen has no cp1252
786
- encoding, so the CLI died mid-write. `nemotron-lightning`
787
- returned 378 bytes of a real answer and then
788
- `charmap codec can't encode '\\u2011'`.
789
- CORRUPTION anything cp1252 COULD encode (accented letters) arrived as
790
- bytes this end then decoded as UTF-8, and `errors="replace"`
791
- quietly turned them into replacement characters.
792
-
793
- The crash was the worse of the two, because `outcome()` does not recognise
794
- a CLIENT-side failure: the message is over `min_length` and matches no
795
- marker in `ERRORS`, so a dead CLI scored as `answered` and the truncated
796
- text was graded as the model's work. An infrastructure failure recorded as
797
- model incompetence is exactly the confusion this repo exists to prevent.
798
-
799
- Forcing UTF-8 on the child makes both ends agree. `PYTHONUTF8` covers
800
- interpreters that ignore `PYTHONIOENCODING`.
801
- """
802
- env = dict(os.environ)
803
- env["PYTHONIOENCODING"] = "utf-8"
804
- env["PYTHONUTF8"] = "1"
805
- return env
806
-
807
-
808
- def ask_once(binary: str, model: str, prompt: str,
809
- timeout: int) -> tuple[str, float]:
810
- # BENCH_CLIENT=builtin: the same call without the `llm` subprocess, giving
811
- # the same text for the same failure (metrics/client.py).
812
- if str(ROOT / "metrics") not in sys.path:
813
- sys.path.insert(0, str(ROOT / "metrics"))
814
- import client
815
- if client.backend(model) == "builtin":
816
- res = client.chat(model, prompt, timeout=timeout)
817
- return client.output(res, timeout), res["seconds"]
818
- started = time.monotonic()
819
- try:
820
- out = subprocess.run(
821
- [binary, "-m", model], input=prompt, capture_output=True,
822
- text=True, encoding="utf-8", errors="replace", timeout=timeout,
823
- env=_child_env())
824
- body = out.stdout or out.stderr
825
- # A NON-ZERO EXIT IS FREE EVIDENCE AND WAS BEING THROWN AWAY. On a
826
- # mid-stream crash `out.stdout` holds real but TRUNCATED text, so
827
- # `stdout or stderr` prefers it and the failure becomes invisible --
828
- # it reads as a model that simply stopped early. The exit code is the
829
- # only signal that separates those, so it decides, not the text --
830
- # except when it is only llm failing to log an answer it printed in
831
- # full, its logs.db locked by another llm (metrics/client.py).
832
- if out.returncode != 0 and not client.llm_log_failed_only(out.stdout, out.stderr):
833
- detail = (out.stderr or body or "").strip().replace("\n", " ")
834
- body = f"Error: llm exited {out.returncode}: {detail[:300]}"
835
- except subprocess.TimeoutExpired:
836
- body = f"<<TIMED OUT after {timeout}s>>"
837
- return body, time.monotonic() - started
838
-
839
-
840
- def ask(binary: str, model: str, prompt: str, timeout: int,
841
- attempts: int = 3) -> tuple[str, float, int]:
842
- """Ask, retrying UPSTREAM FAILURES only.
843
-
844
- A rate limit or an overloaded service is a fact about the afternoon, not
845
- about the model, and retrying is the only way to tell one from the other.
846
- A refusal is not retried — a filter will refuse again — and neither is a
847
- timeout, because retrying a five-minute wait turns one slow run into
848
- fifteen minutes of the same answer.
849
- """
850
- total = 0.0
851
- for attempt in range(1, attempts + 1):
852
- body, seconds = ask_once(binary, model, prompt, timeout)
853
- total += seconds
854
- kind = (outcome(body) or ("answered", ""))[0]
855
- if kind != "error" or attempt == attempts:
856
- return body, total, attempt
857
- # Back off before trying again: hammering a service that just said it
858
- # was overloaded is how one failure becomes three.
859
- time.sleep(5 * attempt)
860
- return body, total, attempts
861
-
862
-
863
- def main() -> int:
864
- ap = argparse.ArgumentParser(description=__doc__)
865
- ap.add_argument("--task")
866
- ap.add_argument("--model", action="append", dest="models")
867
- ap.add_argument("--timeout", type=int, default=420)
868
- ap.add_argument("--repeat", type=int, default=1,
869
- help="ask each model the same task N times and report the "
870
- "SPREAD. Every score in this suite has been n=1, so a "
871
- "model that scores 10/11 once and 6/11 the next time "
872
- "reads identically to one that scores 8/11 twice — "
873
- "and they are not interchangeable")
874
- ap.add_argument("--next-free", action="store_true", dest="next_free",
875
- help="score ONE OpenRouter :free model — the one most in "
876
- "need. For a daily timer: the free tier is 50 calls "
877
- "a day shared across all of them, so a full sweep "
878
- "would spend most of the budget in one go")
879
- ap.add_argument("--next", action="store_true", dest="pick_next",
880
- help="score ONE paid model — the one most in need. For a "
881
- "timer: never-scored first, then INCOMPLETE, then "
882
- "oldest. This is the tier that matches how these "
883
- "models are actually used, so it is the one worth "
884
- "automating. `:free` models are left to --next-free")
885
- ap.add_argument("--dry-run", action="store_true")
886
- args = ap.parse_args()
887
-
888
- tasks = load_tasks(args.task)
889
- if args.next_free:
890
- chosen = next_free(TASKS)
891
- if chosen is None:
892
- sys.exit("no :free model registered")
893
- args.models = [chosen]
894
- tasks = pick_tasks(tasks, chosen)
895
- print(f"--next-free chose {chosen}: {len(tasks)} tasks "
896
- f"(one model per run; the free tier is 50 calls a day, shared)")
897
-
898
- if args.pick_next:
899
- chosen = next_model(TASKS)
900
- if chosen is None:
901
- sys.exit("no paid model registered")
902
- args.models = [chosen]
903
- total = len([p for p in TASKS.iterdir() if p.is_dir()])
904
- have = len(list(TASKS.glob(f"*/output/{chosen}.md")))
905
- tasks = pick_tasks(tasks, chosen)
906
- print(f"--next chose {chosen} ({have}/{total} tasks scored; "
907
- f"{len(tasks)} this run)")
908
-
909
- models = args.models or [m for m in available_models()
910
- if m not in free_models() or args.next_free]
911
- binary = None if args.dry_run else llm_binary()
912
-
913
- # A task whose files cannot be found is skipped, with a line saying why;
914
- # it used to end the whole run.
915
- ready = []
916
- for spec in tasks:
917
- try:
918
- ready.append((spec, build_prompt(spec)))
919
- except MissingAttachment as exc:
920
- print(f"skipped {exc}")
921
- tasks, prompts = [s for s, _ in ready], {s["id"]: p for s, p in ready}
922
-
923
- if args.dry_run:
924
- for spec in tasks:
925
- prompt = prompts[spec["id"]]
926
- print(f"=== {spec['id']} ({len(prompt):,} chars) ===")
927
- print(prompt[:600])
928
- print("...\n")
929
- return 0
930
-
931
- RUNS.mkdir(exist_ok=True)
932
- stamp = dt.datetime.now().strftime("%Y-%m-%d")
933
- report = RUNS / f"suite-{stamp}.md"
934
- rows = []
935
-
936
- for spec in tasks:
937
- prompt = prompts[spec["id"]]
938
- print(f"\n## {spec['id']} — {spec['title']}")
939
- print(f" {len(prompt):,} chars, {len(spec.get('must_find', []))} findings\n")
940
- for model in models:
941
- # N attempts at the SAME question. The spread is the point: a
942
- # single sample is a measurement of one afternoon, and this suite
943
- # has been reporting those as though they were properties.
944
- takes = []
945
- for _ in range(max(1, args.repeat)):
946
- body, seconds, attempts = ask(binary, model, prompt,
947
- args.timeout)
948
- takes.append((score(spec, body), seconds, attempts, body))
949
- if args.repeat > 1:
950
- time.sleep(2)
951
- result, seconds, attempts, body = takes[0]
952
- scored = [t[0]["found"] for t in takes
953
- if t[0]["kind"] == "answered"]
954
- row = {"task": spec["id"], "model": model,
955
- "seconds": round(seconds, 1), "attempts": attempts,
956
- **result}
957
- if args.repeat > 1:
958
- row["takes"] = len(takes)
959
- row["found_min"] = min(scored) if scored else None
960
- row["found_max"] = max(scored) if scored else None
961
- # The number that decides whether a single score can be
962
- # trusted at all.
963
- row["spread"] = ((max(scored) - min(scored))
964
- if len(scored) > 1 else 0)
965
- rows.append(row)
966
- # Tell the counter these were MEASUREMENT, not work. Without it a
967
- # model's call count says how much it was benchmarked and reads as
968
- # how much it was relied on — the opposite of the truth.
969
- _note_benchmark(model, len(takes))
970
- # A courtesy gap. The first baseline fired fifteen calls back to
971
- # back, which is itself a plausible way to trip a shared limit.
972
- time.sleep(2)
973
- tries = f" x{attempts}" if attempts > 1 else ""
974
- if args.repeat > 1 and row.get("found_min") is not None:
975
- tries += (f" [{row['found_min']}-{row['found_max']} over "
976
- f"{row['takes']}]")
977
- if result["blocked"]:
978
- _learn(model, body, "suite")
979
- print(f" {model:24} {result['kind'].upper():8} {seconds:6.1f}s{tries}"
980
- f" ({result['blocked'].split(': ', 1)[1]})")
981
- else:
982
- flag = (" INVENTED " + ", ".join(result["invented"])
983
- if result["invented"] else "")
984
- print(f" {model:24} {result['found']}/{result['of']}"
985
- f" {seconds:6.1f}s{tries}{flag}")
986
- (spec["dir"] / "output").mkdir(exist_ok=True)
987
- (spec["dir"] / "output" / f"{model}.md").write_text(body, encoding="utf-8")
988
-
989
- _write_report(report, stamp, rows)
990
- print(f"\nwritten: {report}")
991
- return 0
992
-
993
-
994
- def _learn(model: str, body: str, where: str) -> None:
995
- """Pass a failed call's text to metrics/learn.py: a context window or rate
996
- limit the error states, and a cooldown if it was rate-limited. Never lets
997
- a learning failure break a run."""
998
- try:
999
- sys.path.insert(0, str(ROOT / "metrics"))
1000
- import learn
1001
- learn.record(model, body, where)
1002
- except Exception: # noqa: BLE001
1003
- pass
1004
-
1005
-
1006
- def _write_report(path: pathlib.Path, stamp: str, rows: list[dict]) -> None:
1007
- lines = [f"\n## Suite run {stamp}\n",
1008
- "| Task | Model | Found | Invented | Seconds |",
1009
- "| --- | --- | --- | --- | --- |"]
1010
- for r in rows:
1011
- invented = ", ".join(r["invented"]) if r["invented"] else "—"
1012
- found = (r.get("kind") if r.get("blocked")
1013
- else f"{r['found']}/{r['of']}")
1014
- tries = f" (x{r.get('attempts', 1)})" if r.get("attempts", 1) > 1 else ""
1015
- lines.append(f"| {r['task']} | `{r['model']}` | {found}"
1016
- f" | {invented} | {r['seconds']}{tries} |")
1017
- non_answers = [r for r in rows if r.get("blocked")]
1018
- if non_answers:
1019
- lines += ["", "**Not a score — the model did not answer:**"]
1020
- for r in non_answers:
1021
- lines.append(f"- `{r['model']}` on {r['task']}: {r['blocked']}")
1022
- lines += ["",
1023
- "`error` is retryable and says nothing about the model — it "
1024
- "was already retried up to three times before appearing "
1025
- "here.",
1026
- "",
1027
- "**A `timeout` is not clean evidence either.** From the "
1028
- "client there is no difference between a model that is slow "
1029
- "and a request that queued behind a rate limit. Treat "
1030
- "timeouts as \"did not answer through this path today\", "
1031
- "not as a property of the model."]
1032
- misses = [r for r in rows if r["missed"]]
1033
- if misses:
1034
- lines += ["", "**Missed:**"]
1035
- for r in misses:
1036
- lines.append(f"- `{r['model']}` on {r['task']}: {', '.join(r['missed'])}")
1037
- existing = path.read_text(encoding="utf-8") if path.exists() else ""
1038
- path.write_text(existing + "\n".join(lines) + "\n", encoding="utf-8")
1039
-
1040
-
1041
- if __name__ == "__main__":
1042
- raise SystemExit(main())
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())