@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -271
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -275
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -155
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -72
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -712
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -0
  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 +295 -295
  22. package/metrics/client.py +394 -373
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -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/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -574
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -64
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -104
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -0
  62. package/scripts/test_mcp.py +178 -0
  63. package/scripts/test_merged.py +214 -193
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -386
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3662
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1040
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -424
  93. package/ui/bench.js +1756 -1555
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
package/suite/mine.py CHANGED
@@ -1,154 +1,154 @@
1
- """Turn judged bench-gateway calls into DRAFT claim-verification tasks.
2
-
3
- python suite/mine.py # judged calls with claims not yet mined
4
- python suite/mine.py p20261016-... # draft tasks from one call
5
-
6
- THE SUITE GROWS FROM REAL WORK
7
- ------------------------------
8
- Every bench-gateway call logs its prompt — including the files, exactly as the
9
- models saw them — and a verdict can record verified claims:
10
-
11
- python metrics/offload.py verdict <call id> --useful \\
12
- --real "<a finding it got right>" --false "<a claim it made that is untrue>"
13
-
14
- Each claim becomes one task: the files as fixtures, the claim as a neutral
15
- hypothesis, `VERDICT: TRUE` or `VERDICT: FALSE` on the first line. A --real
16
- claim is true, a --false one is not, so both kinds accumulate. The code is the
17
- version that was reviewed, frozen: a claim task tests verification, not
18
- whether today's code still has the bug.
19
-
20
- DRAFTS, NOT TASKS
21
- -----------------
22
- A mined task is written with "draft": true, and suite/run.py skips drafts
23
- unless asked for by --task. Read it before removing the flag: is the claim
24
- stated so the files alone decide it, with no model's name and no line number
25
- that gives the answer away? Is the fixture small enough for free-tier models?
26
- A claim the attached files cannot settle is a bad task, however true.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import argparse
32
- import json
33
- import pathlib
34
- import re
35
- import sys
36
-
37
- ROOT = pathlib.Path(__file__).resolve().parent.parent
38
- sys.path.insert(0, str(ROOT / "metrics"))
39
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
40
- TASKS = DATA / "suite" / "tasks"
41
- CALLS = DATA / "runs" / "gateway" / "calls.jsonl"
42
- sys.path.insert(0, str(ROOT / "metrics"))
43
- import offload # noqa: E402
44
-
45
- PROMPT = ("Hypothesis about the attached code:\n\n{claim}\n\n"
46
- "Decide whether the hypothesis is TRUE or FALSE for the code exactly as attached, using"
47
- " standard Python and standard-library behaviour. Your first line must be exactly"
48
- " `VERDICT: TRUE` or `VERDICT: FALSE`. Then quote the line or lines that decide it and"
49
- " explain in at most five sentences. Plain ASCII.")
50
- HEADER = re.compile(r"^===== (?:FILE: )?(.+?)(?: =====)?$", re.M)
51
- BIG = 40_000 # characters of fixtures; past this, free models start failing
52
-
53
-
54
- def calls() -> dict[str, dict]:
55
- out = {}
56
- for line in CALLS.read_text(encoding="utf-8").splitlines() if CALLS.exists() else []:
57
- try:
58
- r = json.loads(line)
59
- except ValueError:
60
- continue
61
- out[r["panel"]] = r
62
- return out
63
-
64
-
65
- def files_in(prompt: str) -> dict[str, str]:
66
- """`===== path` sections of a logged prompt -> {path: text}. A section
67
- whose name is not a file (a git diff, say) is kept under a safe name."""
68
- marks = list(HEADER.finditer(prompt))
69
- out = {}
70
- for i, m in enumerate(marks):
71
- end = marks[i + 1].start() if i + 1 < len(marks) else len(prompt)
72
- name = re.sub(r"[^\w.-]+", "-", m.group(1).strip()).strip("-") or f"part{i}"
73
- out[name if "." in name else name + ".txt"] = prompt[m.end():end].strip("\n") + "\n"
74
- return out
75
-
76
-
77
- def claims_for(panel: str) -> list[tuple[str, bool, str]]:
78
- """(claim, is_true, seat call id) from every judged seat of the call."""
79
- seen, out = set(), []
80
- for e in offload.entries():
81
- if not str(e.get("call", "")).startswith(panel + "."):
82
- continue
83
- for text, truth in [(c, True) for c in e.get("real", [])] + [(c, False) for c in e.get("false", [])]:
84
- if text.lower() not in seen:
85
- seen.add(text.lower())
86
- out.append((text, truth, e["call"]))
87
- return out
88
-
89
-
90
- def mined() -> set[str]:
91
- return {json.loads(p.read_text(encoding="utf-8")).get("mined_from", "")
92
- for p in TASKS.glob("*/task.json")}
93
-
94
-
95
- def draft(panel: str) -> list[str]:
96
- record = calls().get(panel)
97
- if record is None:
98
- sys.exit(f"no gateway call {panel!r} in {CALLS.relative_to(ROOT)}")
99
- files = files_in(record["prompt"])
100
- if not files:
101
- sys.exit(f"{panel}: its prompt attached no `===== file` sections, so nothing can be frozen")
102
- found = claims_for(panel)
103
- if not found:
104
- sys.exit(f"{panel}: no verdict records --real or --false claims for it yet")
105
- size = sum(len(t) for t in files.values())
106
- written = []
107
- for n, (claim, truth, call) in enumerate(found, 1):
108
- task_id = f"mined-{panel.removeprefix('p')}-{n}"
109
- d = TASKS / task_id
110
- (d / "fixtures").mkdir(parents=True, exist_ok=True)
111
- for name, text in files.items():
112
- (d / "fixtures" / name).write_text(text, encoding="utf-8")
113
- yes, no = ("verdict: true", "verdict: false") if truth else ("verdict: false", "verdict: true")
114
- spec = {"id": task_id, "draft": True, "shape": "claim", "mined_from": call,
115
- "title": f"Claim verification ({'true' if truth else 'false'} claim), mined",
116
- "why": f"{'TRUE' if truth else 'FALSE'}, verified by the caller in the verdict on"
117
- f" {call} (model {record.get('model')}). Review before removing draft.",
118
- # Its files are the snapshot copied above: a repo key no install
119
- # maps, so build_prompt always reads the fixtures.
120
- "prompt": PROMPT.format(claim=claim), "repo": "fixtures",
121
- "attach": [f"snapshot/{name}" for name in files],
122
- "must_find": [{"any_of": [yes]}], "must_not_claim": [no],
123
- "score_in": "first_line", "min_answer_chars": 10}
124
- (d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n",
125
- encoding="utf-8")
126
- written.append(task_id)
127
- print(f"{len(written)} draft task(s) from {panel}: {', '.join(written)}")
128
- if size > BIG:
129
- print(f" note: {size:,} characters of fixtures; trim them to what the claims need"
130
- " before promoting, or free models will fail on size rather than judgement")
131
- print(" review each task.json, then remove \"draft\": true to put it in the suite")
132
- return written
133
-
134
-
135
- def main() -> None:
136
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
137
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
138
- ap.add_argument("panel", nargs="?", help="a gateway call id, e.g. p20261016-165636-afd6")
139
- args = ap.parse_args()
140
- if args.panel:
141
- draft(args.panel)
142
- return
143
- done = {c.rsplit(".", 1)[0] for c in mined()}
144
- rows = [(p, len(claims_for(p))) for p in calls()]
145
- rows = [(p, n) for p, n in rows if n and p not in done]
146
- if not rows:
147
- print("Nothing to mine: no judged gateway call carries --real/--false claims that are"
148
- " not already tasks.")
149
- for p, n in rows:
150
- print(f" {p}: {n} claim(s) python suite/mine.py {p}")
151
-
152
-
153
- if __name__ == "__main__":
154
- main()
1
+ """Turn judged bench-gateway calls into DRAFT claim-verification tasks.
2
+
3
+ python suite/mine.py # judged calls with claims not yet mined
4
+ python suite/mine.py p20261016-... # draft tasks from one call
5
+
6
+ THE SUITE GROWS FROM REAL WORK
7
+ ------------------------------
8
+ Every bench-gateway call logs its prompt — including the files, exactly as the
9
+ models saw them — and a verdict can record verified claims:
10
+
11
+ python metrics/offload.py verdict <call id> --useful \\
12
+ --real "<a finding it got right>" --false "<a claim it made that is untrue>"
13
+
14
+ Each claim becomes one task: the files as fixtures, the claim as a neutral
15
+ hypothesis, `VERDICT: TRUE` or `VERDICT: FALSE` on the first line. A --real
16
+ claim is true, a --false one is not, so both kinds accumulate. The code is the
17
+ version that was reviewed, frozen: a claim task tests verification, not
18
+ whether today's code still has the bug.
19
+
20
+ DRAFTS, NOT TASKS
21
+ -----------------
22
+ A mined task is written with "draft": true, and suite/run.py skips drafts
23
+ unless asked for by --task. Read it before removing the flag: is the claim
24
+ stated so the files alone decide it, with no model's name and no line number
25
+ that gives the answer away? Is the fixture small enough for free-tier models?
26
+ A claim the attached files cannot settle is a bad task, however true.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import pathlib
34
+ import re
35
+ import sys
36
+
37
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
38
+ sys.path.insert(0, str(ROOT / "metrics"))
39
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
40
+ TASKS = DATA / "suite" / "tasks"
41
+ CALLS = DATA / "runs" / "gateway" / "calls.jsonl"
42
+ sys.path.insert(0, str(ROOT / "metrics"))
43
+ import offload # noqa: E402
44
+
45
+ PROMPT = ("Hypothesis about the attached code:\n\n{claim}\n\n"
46
+ "Decide whether the hypothesis is TRUE or FALSE for the code exactly as attached, using"
47
+ " standard Python and standard-library behaviour. Your first line must be exactly"
48
+ " `VERDICT: TRUE` or `VERDICT: FALSE`. Then quote the line or lines that decide it and"
49
+ " explain in at most five sentences. Plain ASCII.")
50
+ HEADER = re.compile(r"^===== (?:FILE: )?(.+?)(?: =====)?$", re.M)
51
+ BIG = 40_000 # characters of fixtures; past this, free models start failing
52
+
53
+
54
+ def calls() -> dict[str, dict]:
55
+ out = {}
56
+ for line in CALLS.read_text(encoding="utf-8").splitlines() if CALLS.exists() else []:
57
+ try:
58
+ r = json.loads(line)
59
+ except ValueError:
60
+ continue
61
+ out[r["panel"]] = r
62
+ return out
63
+
64
+
65
+ def files_in(prompt: str) -> dict[str, str]:
66
+ """`===== path` sections of a logged prompt -> {path: text}. A section
67
+ whose name is not a file (a git diff, say) is kept under a safe name."""
68
+ marks = list(HEADER.finditer(prompt))
69
+ out = {}
70
+ for i, m in enumerate(marks):
71
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(prompt)
72
+ name = re.sub(r"[^\w.-]+", "-", m.group(1).strip()).strip("-") or f"part{i}"
73
+ out[name if "." in name else name + ".txt"] = prompt[m.end():end].strip("\n") + "\n"
74
+ return out
75
+
76
+
77
+ def claims_for(panel: str) -> list[tuple[str, bool, str]]:
78
+ """(claim, is_true, seat call id) from every judged seat of the call."""
79
+ seen, out = set(), []
80
+ for e in offload.entries():
81
+ if not str(e.get("call", "")).startswith(panel + "."):
82
+ continue
83
+ for text, truth in [(c, True) for c in e.get("real", [])] + [(c, False) for c in e.get("false", [])]:
84
+ if text.lower() not in seen:
85
+ seen.add(text.lower())
86
+ out.append((text, truth, e["call"]))
87
+ return out
88
+
89
+
90
+ def mined() -> set[str]:
91
+ return {json.loads(p.read_text(encoding="utf-8")).get("mined_from", "")
92
+ for p in TASKS.glob("*/task.json")}
93
+
94
+
95
+ def draft(panel: str) -> list[str]:
96
+ record = calls().get(panel)
97
+ if record is None:
98
+ sys.exit(f"no gateway call {panel!r} in {CALLS.relative_to(ROOT)}")
99
+ files = files_in(record["prompt"])
100
+ if not files:
101
+ sys.exit(f"{panel}: its prompt attached no `===== file` sections, so nothing can be frozen")
102
+ found = claims_for(panel)
103
+ if not found:
104
+ sys.exit(f"{panel}: no verdict records --real or --false claims for it yet")
105
+ size = sum(len(t) for t in files.values())
106
+ written = []
107
+ for n, (claim, truth, call) in enumerate(found, 1):
108
+ task_id = f"mined-{panel.removeprefix('p')}-{n}"
109
+ d = TASKS / task_id
110
+ (d / "fixtures").mkdir(parents=True, exist_ok=True)
111
+ for name, text in files.items():
112
+ (d / "fixtures" / name).write_text(text, encoding="utf-8")
113
+ yes, no = ("verdict: true", "verdict: false") if truth else ("verdict: false", "verdict: true")
114
+ spec = {"id": task_id, "draft": True, "shape": "claim", "mined_from": call,
115
+ "title": f"Claim verification ({'true' if truth else 'false'} claim), mined",
116
+ "why": f"{'TRUE' if truth else 'FALSE'}, verified by the caller in the verdict on"
117
+ f" {call} (model {record.get('model')}). Review before removing draft.",
118
+ # Its files are the snapshot copied above: a repo key no install
119
+ # maps, so build_prompt always reads the fixtures.
120
+ "prompt": PROMPT.format(claim=claim), "repo": "fixtures",
121
+ "attach": [f"snapshot/{name}" for name in files],
122
+ "must_find": [{"any_of": [yes]}], "must_not_claim": [no],
123
+ "score_in": "first_line", "min_answer_chars": 10}
124
+ (d / "task.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n",
125
+ encoding="utf-8")
126
+ written.append(task_id)
127
+ print(f"{len(written)} draft task(s) from {panel}: {', '.join(written)}")
128
+ if size > BIG:
129
+ print(f" note: {size:,} characters of fixtures; trim them to what the claims need"
130
+ " before promoting, or free models will fail on size rather than judgement")
131
+ print(" review each task.json, then remove \"draft\": true to put it in the suite")
132
+ return written
133
+
134
+
135
+ def main() -> None:
136
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
137
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
138
+ ap.add_argument("panel", nargs="?", help="a gateway call id, e.g. p20261016-165636-afd6")
139
+ args = ap.parse_args()
140
+ if args.panel:
141
+ draft(args.panel)
142
+ return
143
+ done = {c.rsplit(".", 1)[0] for c in mined()}
144
+ rows = [(p, len(claims_for(p))) for p in calls()]
145
+ rows = [(p, n) for p, n in rows if n and p not in done]
146
+ if not rows:
147
+ print("Nothing to mine: no judged gateway call carries --real/--false claims that are"
148
+ " not already tasks.")
149
+ for p, n in rows:
150
+ print(f" {p}: {n} claim(s) python suite/mine.py {p}")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
package/suite/pace.py CHANGED
@@ -1,145 +1,145 @@
1
- """Run suite tasks across the roster slowly, one call at a time, in the background.
2
-
3
- python suite/pace.py # every seeded-hard task, every model, 120 s apart
4
- python suite/pace.py --prefix claim- --gap 300
5
- python suite/pace.py --dry-run # list what would run, call nothing
6
-
7
- Started detached it works through the queue on its own and stops when done:
8
-
9
- Start-Process .venv\\Scripts\\python.exe -ArgumentList suite\\pace.py -WindowStyle Hidden # Windows
10
- nohup .venv/bin/python suite/pace.py > /dev/null 2>&1 & # macOS, Linux
11
-
12
- FREE TIERS LIMIT RATE, NOT TOTAL
13
- --------------------------------
14
- A full roster pass of ten seeded pairs is ~300 calls of 8-14K characters. Sent
15
- back to back it trips every provider's per-minute limit and the run fills
16
- with "rate limit" rows that say nothing about the models. Sent one at a time,
17
- `--gap` seconds apart and ROUND-ROBIN — task by task across every model, so
18
- one model is asked once per lap rather than twenty times in a row — each model
19
- sees a call roughly every (models x gap) seconds, far under any limit.
20
-
21
- A call that comes back rate-limited is deleted (it is not an answer) and its
22
- model is rested for `--rest` seconds before being asked again later; the rest
23
- of the queue carries on. Pairs that already have a saved answer are skipped,
24
- so the run can be stopped and started again at any time and picks up where it
25
- was. It logs to runs/pace-<date>.log and calls nothing that suite/run.py
26
- would not.
27
-
28
- Models on a retiring alert, ad hoc models and decision models are left out:
29
- the first are failing their probes, the second cost money, the third cannot
30
- answer a prompt.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import argparse
36
- import datetime as dt
37
- import json
38
- import os
39
- import pathlib
40
- import subprocess
41
- import sys
42
- import time
43
-
44
- ROOT = pathlib.Path(__file__).resolve().parent.parent
45
- sys.path.insert(0, str(ROOT / "metrics"))
46
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
47
- TASKS = DATA / "suite" / "tasks"
48
- sys.path.insert(0, str(ROOT / "suite"))
49
- sys.path.insert(0, str(ROOT / "metrics"))
50
- import run # noqa: E402
51
-
52
- PY = run.bench_python()
53
-
54
-
55
- def models() -> list[str]:
56
- try:
57
- import alerts
58
- retiring = {a.get("alias") for a in alerts.active().values() if a.get("kind") == "retiring"}
59
- except Exception: # noqa: BLE001
60
- retiring = set()
61
- return [m for m in run.available_models() if m not in retiring and m not in run.ad_hoc_models()]
62
-
63
-
64
- def answered(task: str, model: str) -> bool:
65
- """A saved file that is an answer. One that recorded a block (a rate
66
- limit, an error, a timeout) is not, so the pair is asked again."""
67
- out = TASKS / task / "output" / f"{model}.md"
68
- if not out.exists():
69
- return False
70
- spec = json.loads((TASKS / task / "task.json").read_text(encoding="utf-8"))
71
- return run.score(spec, out.read_text(encoding="utf-8", errors="replace"))["kind"] == "answered"
72
-
73
-
74
- def queue(prefix: str, roster: list[str]) -> list[tuple[str, str]]:
75
- """(task, model) pairs without an answer yet, task by task across models."""
76
- tasks = sorted(p.parent.name for p in TASKS.glob(f"{prefix}*/task.json")
77
- if not json.loads(p.read_text(encoding="utf-8")).get("draft"))
78
- return [(t, m) for t in tasks for m in roster if not answered(t, m)]
79
-
80
-
81
- def rate_limited(task: str, model: str) -> bool:
82
- out = TASKS / task / "output" / f"{model}.md"
83
- if not out.exists():
84
- return False
85
- blocked = run.score(json.loads((TASKS / task / "task.json").read_text(encoding="utf-8")),
86
- out.read_text(encoding="utf-8", errors="replace")).get("blocked") or ""
87
- return "rate limit" in blocked or "capacity" in blocked
88
-
89
-
90
- def main() -> None:
91
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
92
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
93
- ap.add_argument("--prefix", default="seeded-hard-", help="task directories starting with this")
94
- ap.add_argument("--gap", type=int, default=120, help="seconds between calls")
95
- ap.add_argument("--rest", type=int, default=1800, help="seconds a rate-limited model is rested")
96
- ap.add_argument("--timeout", type=int, default=420)
97
- ap.add_argument("--dry-run", action="store_true")
98
- args = ap.parse_args()
99
-
100
- roster = models()
101
- todo = queue(args.prefix, roster)
102
- log_path = DATA / "runs" / f"pace-{dt.date.today().isoformat()}.log"
103
-
104
- def log(msg: str) -> None:
105
- line = f"{dt.datetime.now():%H:%M:%S} {msg}"
106
- print(line, flush=True)
107
- if not args.dry_run:
108
- with log_path.open("a", encoding="utf-8") as fh:
109
- fh.write(line + "\n")
110
-
111
- eta = len(todo) * (args.gap + 60) / 3600
112
- log(f"{len(todo)} calls queued across {len(roster)} models, {args.gap}s apart"
113
- f" (about {eta:.1f} h): {', '.join(roster)}")
114
- if args.dry_run:
115
- for t, m in todo:
116
- print(f" {t} {m}")
117
- return
118
- rested: dict[str, float] = {}
119
- while todo:
120
- now = time.monotonic()
121
- pick = next(((t, m) for t, m in todo if rested.get(m, 0) <= now), None)
122
- if pick is None:
123
- # Only models with work left: a rest that already ended would make
124
- # this negative, and time.sleep raises on a negative wait.
125
- time.sleep(max(1, min(rested[m] for _, m in todo) - now + 1))
126
- continue
127
- todo.remove(pick)
128
- task, model = pick
129
- subprocess.run([str(PY), str(ROOT / "suite" / "run.py"), "--task", task, "--model", model,
130
- "--timeout", str(args.timeout)], cwd=str(ROOT), capture_output=True,
131
- env={**os.environ, "PYTHONIOENCODING": "utf-8"})
132
- if rate_limited(task, model):
133
- (TASKS / task / "output" / f"{model}.md").unlink()
134
- rested[model] = time.monotonic() + args.rest
135
- todo.append(pick)
136
- log(f"rate-limited {model} on {task}; resting it {args.rest}s, retried later")
137
- else:
138
- log(f"done {model} on {task} ({len(todo)} left)")
139
- if todo:
140
- time.sleep(args.gap)
141
- log("queue empty; finished")
142
-
143
-
144
- if __name__ == "__main__":
145
- main()
1
+ """Run suite tasks across the roster slowly, one call at a time, in the background.
2
+
3
+ python suite/pace.py # every seeded-hard task, every model, 120 s apart
4
+ python suite/pace.py --prefix claim- --gap 300
5
+ python suite/pace.py --dry-run # list what would run, call nothing
6
+
7
+ Started detached it works through the queue on its own and stops when done:
8
+
9
+ Start-Process .venv\\Scripts\\python.exe -ArgumentList suite\\pace.py -WindowStyle Hidden # Windows
10
+ nohup .venv/bin/python suite/pace.py > /dev/null 2>&1 & # macOS, Linux
11
+
12
+ FREE TIERS LIMIT RATE, NOT TOTAL
13
+ --------------------------------
14
+ A full roster pass of ten seeded pairs is ~300 calls of 8-14K characters. Sent
15
+ back to back it trips every provider's per-minute limit and the run fills
16
+ with "rate limit" rows that say nothing about the models. Sent one at a time,
17
+ `--gap` seconds apart and ROUND-ROBIN — task by task across every model, so
18
+ one model is asked once per lap rather than twenty times in a row — each model
19
+ sees a call roughly every (models x gap) seconds, far under any limit.
20
+
21
+ A call that comes back rate-limited is deleted (it is not an answer) and its
22
+ model is rested for `--rest` seconds before being asked again later; the rest
23
+ of the queue carries on. Pairs that already have a saved answer are skipped,
24
+ so the run can be stopped and started again at any time and picks up where it
25
+ was. It logs to runs/pace-<date>.log and calls nothing that suite/run.py
26
+ would not.
27
+
28
+ Models on a retiring alert, ad hoc models and decision models are left out:
29
+ the first are failing their probes, the second cost money, the third cannot
30
+ answer a prompt.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import datetime as dt
37
+ import json
38
+ import os
39
+ import pathlib
40
+ import subprocess
41
+ import sys
42
+ import time
43
+
44
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
45
+ sys.path.insert(0, str(ROOT / "metrics"))
46
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
47
+ TASKS = DATA / "suite" / "tasks"
48
+ sys.path.insert(0, str(ROOT / "suite"))
49
+ sys.path.insert(0, str(ROOT / "metrics"))
50
+ import run # noqa: E402
51
+
52
+ PY = run.bench_python()
53
+
54
+
55
+ def models() -> list[str]:
56
+ try:
57
+ import alerts
58
+ retiring = {a.get("alias") for a in alerts.active().values() if a.get("kind") == "retiring"}
59
+ except Exception: # noqa: BLE001
60
+ retiring = set()
61
+ return [m for m in run.available_models() if m not in retiring and m not in run.ad_hoc_models()]
62
+
63
+
64
+ def answered(task: str, model: str) -> bool:
65
+ """A saved file that is an answer. One that recorded a block (a rate
66
+ limit, an error, a timeout) is not, so the pair is asked again."""
67
+ out = TASKS / task / "output" / f"{model}.md"
68
+ if not out.exists():
69
+ return False
70
+ spec = json.loads((TASKS / task / "task.json").read_text(encoding="utf-8"))
71
+ return run.score(spec, out.read_text(encoding="utf-8", errors="replace"))["kind"] == "answered"
72
+
73
+
74
+ def queue(prefix: str, roster: list[str]) -> list[tuple[str, str]]:
75
+ """(task, model) pairs without an answer yet, task by task across models."""
76
+ tasks = sorted(p.parent.name for p in TASKS.glob(f"{prefix}*/task.json")
77
+ if not json.loads(p.read_text(encoding="utf-8")).get("draft"))
78
+ return [(t, m) for t in tasks for m in roster if not answered(t, m)]
79
+
80
+
81
+ def rate_limited(task: str, model: str) -> bool:
82
+ out = TASKS / task / "output" / f"{model}.md"
83
+ if not out.exists():
84
+ return False
85
+ blocked = run.score(json.loads((TASKS / task / "task.json").read_text(encoding="utf-8")),
86
+ out.read_text(encoding="utf-8", errors="replace")).get("blocked") or ""
87
+ return "rate limit" in blocked or "capacity" in blocked
88
+
89
+
90
+ def main() -> None:
91
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
92
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
93
+ ap.add_argument("--prefix", default="seeded-hard-", help="task directories starting with this")
94
+ ap.add_argument("--gap", type=int, default=120, help="seconds between calls")
95
+ ap.add_argument("--rest", type=int, default=1800, help="seconds a rate-limited model is rested")
96
+ ap.add_argument("--timeout", type=int, default=420)
97
+ ap.add_argument("--dry-run", action="store_true")
98
+ args = ap.parse_args()
99
+
100
+ roster = models()
101
+ todo = queue(args.prefix, roster)
102
+ log_path = DATA / "runs" / f"pace-{dt.date.today().isoformat()}.log"
103
+
104
+ def log(msg: str) -> None:
105
+ line = f"{dt.datetime.now():%H:%M:%S} {msg}"
106
+ print(line, flush=True)
107
+ if not args.dry_run:
108
+ with log_path.open("a", encoding="utf-8") as fh:
109
+ fh.write(line + "\n")
110
+
111
+ eta = len(todo) * (args.gap + 60) / 3600
112
+ log(f"{len(todo)} calls queued across {len(roster)} models, {args.gap}s apart"
113
+ f" (about {eta:.1f} h): {', '.join(roster)}")
114
+ if args.dry_run:
115
+ for t, m in todo:
116
+ print(f" {t} {m}")
117
+ return
118
+ rested: dict[str, float] = {}
119
+ while todo:
120
+ now = time.monotonic()
121
+ pick = next(((t, m) for t, m in todo if rested.get(m, 0) <= now), None)
122
+ if pick is None:
123
+ # Only models with work left: a rest that already ended would make
124
+ # this negative, and time.sleep raises on a negative wait.
125
+ time.sleep(max(1, min(rested[m] for _, m in todo) - now + 1))
126
+ continue
127
+ todo.remove(pick)
128
+ task, model = pick
129
+ subprocess.run([str(PY), str(ROOT / "suite" / "run.py"), "--task", task, "--model", model,
130
+ "--timeout", str(args.timeout)], cwd=str(ROOT), capture_output=True,
131
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"})
132
+ if rate_limited(task, model):
133
+ (TASKS / task / "output" / f"{model}.md").unlink()
134
+ rested[model] = time.monotonic() + args.rest
135
+ todo.append(pick)
136
+ log(f"rate-limited {model} on {task}; resting it {args.rest}s, retried later")
137
+ else:
138
+ log(f"done {model} on {task} ({len(todo)} left)")
139
+ if todo:
140
+ time.sleep(args.gap)
141
+ log("queue empty; finished")
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()