@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
@@ -0,0 +1,248 @@
1
+ """Pacing: the stored rate limits enforced before a call, not only shown.
2
+
3
+ python metrics/pacing.py <alias> # the limits that apply, and how much of each is used
4
+
5
+ RELEASE-PLAN Phase 2b ("limits enforced, not just shown"). Until now the
6
+ limits in config/connection-limits.json (the Endpoints dialog), a model's own
7
+ `limits` in config/model-overrides.json (its popup) and the limits
8
+ metrics/learn.py read out of providers' errors were display only. The
9
+ built-in client now asks `admit` before every call. When a window is used
10
+ up it waits, if the wait ends before the call's deadline, and otherwise
11
+ refuses with an error that names the limit, so nothing is sent that the
12
+ provider would refuse, and nothing is spent of a cap that does not come back
13
+ (the motivating case: a trial key at 20 a minute and 1,000 a month).
14
+
15
+ WHAT IS COUNTED
16
+ ---------------
17
+ - rpm, tpm: the last 60 s. rpd, tpd: the last 24 h, which never allows more
18
+ than a provider's calendar day would, however it counts its day.
19
+ rpmo (connection limits only): the calendar month in UTC, how trial keys
20
+ are reset.
21
+ - Requests are the ledger's `attempts` (metrics/ledger.py), retries included:
22
+ what the provider saw. Tokens are its in plus out.
23
+ - Calls in flight count too. A panel fires its seats at once, long before
24
+ any of them is in the ledger, so each admitted call is written to
25
+ runs/inflight.json until its ledger line exists. The check and the write
26
+ happen under one lock (runs/pacing.lock), which the gateway's seat threads
27
+ and the suite's processes share; an entry whose call outlived its own
28
+ deadline is dropped, so a killed process cannot hold a slot.
29
+ - A connection's limits count every model on it (a key's limits are the
30
+ key's); a model's own or learned limits count that model alone, its own
31
+ beating the learned (as everywhere: your values win).
32
+ - Quota records (metrics/quota.py): what a provider last said was left, less
33
+ the calls sent since, until its reset.
34
+
35
+ The catalog refresh's figures (FreeLLMAPI's listing) are not enforced: they
36
+ describe a typical free tier, not this key's.
37
+
38
+ THE REFUSAL IS A RATE LIMIT
39
+ ---------------------------
40
+ `Error: client failed: rate limit (not sent; ...): ...; try again in 41s`.
41
+ "client failed" and "rate limit" are markers (suite/run.py ERRORS and
42
+ CAPACITY), so it counts as a retryable capacity failure, never as the model
43
+ answering badly or going away; and learn.py reads "try again in 41s" as the
44
+ cooldown, so the gateway's explorer seats pass the model by meanwhile. The
45
+ text names no "limit N" figure, which learn.py would file as a new limit.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import datetime as dt
51
+ import json
52
+ import os
53
+ import pathlib
54
+ import sys
55
+ import time
56
+ import uuid
57
+
58
+ import ledger
59
+ import locked
60
+
61
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
62
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
63
+ LOCK = DATA / "runs" / "pacing"
64
+ INFLIGHT = DATA / "runs" / "inflight.json"
65
+ # axis -> (what it counts, the window in seconds or "month", how it reads)
66
+ AXES = {"rpm": ("requests", 60, "requests per minute"), "rpd": ("requests", 86400, "requests per day"),
67
+ "tpm": ("tokens", 60, "tokens per minute"), "tpd": ("tokens", 86400, "tokens per day"),
68
+ "rpmo": ("requests", "month", "requests a calendar month (UTC)")}
69
+ MARGIN_S = 2.0 # a wait must leave this long for the call itself
70
+ POLL_S = 5.0 # re-check at least this often while waiting
71
+
72
+
73
+ def rules(alias: str, connection: str) -> list[dict]:
74
+ """The limits that apply: {scope, axis, limit, source}."""
75
+ import catalog
76
+ import learn
77
+ conn = catalog.connection_limits().get(connection) or {}
78
+ mine = (catalog.overrides().get(alias) or {}).get("limits") or {}
79
+ seen = (learn.learned().get(alias) or {}).get("limits") or {}
80
+ out = []
81
+
82
+ def ok(n) -> bool:
83
+ return isinstance(n, int) and not isinstance(n, bool) and n > 0
84
+ for axis in AXES:
85
+ if ok(n := conn.get(axis)):
86
+ out.append({"scope": "connection", "axis": axis, "limit": n,
87
+ "source": f"you, for {connection} (config/connection-limits.json)"})
88
+ if axis == "rpmo":
89
+ continue
90
+ if ok(n := mine.get(axis)):
91
+ out.append({"scope": "alias", "axis": axis, "limit": n,
92
+ "source": "you, for this model (config/model-overrides.json)"})
93
+ elif ok(n := (seen.get(axis) or {}).get("value")):
94
+ out.append({"scope": "alias", "axis": axis, "limit": n,
95
+ "source": f"learned {str(seen[axis].get('at', ''))[:10]} from the provider"})
96
+ return out
97
+
98
+
99
+ def _wait_text(s: float) -> str:
100
+ """learn.py's spelling (s, m or h), so it reads the cooldown back."""
101
+ return f"{s:.0f}s" if s < 120 else f"{s / 60:.0f}m" if s < 7200 else f"{s / 3600:.0f}h"
102
+
103
+
104
+ def _inflight(now: float) -> dict:
105
+ return {k: v for k, v in locked.read_json(INFLIGHT).items()
106
+ if isinstance(v, dict) and (v.get("until") or 0) > now}
107
+
108
+
109
+ def _blocked(rs: list[dict], records: list[dict], alias: str, connection: str, est: int,
110
+ now: float, rows: list[dict], flying: dict) -> tuple[float, str] | None:
111
+ """(when a slot frees, why) for the window that frees last, or None when
112
+ every window has room for this call."""
113
+ worst = None
114
+
115
+ def mine(e: dict, scope: str) -> bool:
116
+ return e.get("connection") == connection if scope == "connection" else e.get("alias") == alias
117
+ for r in rs:
118
+ what, span, reads = AXES[r["axis"]]
119
+ since = ledger.month_start(now) if span == "month" else now - span
120
+ events = sorted(
121
+ [(e["t"], (e.get("attempts") or 0) if what == "requests"
122
+ else (e.get("tokens_in") or 0) + (e.get("tokens_out") or 0))
123
+ for e in rows if e["t"] >= since and mine(e, r["scope"])]
124
+ + [(f["t"], 1 if what == "requests" else f.get("tokens") or 0)
125
+ for f in flying.values() if mine(f, r["scope"])])
126
+ used = sum(w for _, w in events)
127
+ # A prompt larger than the whole window waits for an empty one, then goes:
128
+ # the estimate is rough, and the provider has the last word.
129
+ need = 1 if what == "requests" else min(max(est, 1), r["limit"])
130
+ if used + need <= r["limit"]:
131
+ continue
132
+ if span == "month":
133
+ frees = ledger.next_month(now)
134
+ else:
135
+ frees, gone = now, 0
136
+ for t, w in events: # oldest first, until enough have aged out
137
+ gone += w
138
+ frees = t + span
139
+ if used - gone + need <= r["limit"]:
140
+ break
141
+ whose = connection if r["scope"] == "connection" else alias
142
+ why = (f"{whose} allows {r['limit']:,} {reads} ({r['source']}) and {used:,} "
143
+ f"{'were' if what == 'requests' else 'tokens were'} used"
144
+ f"{' this month' if span == 'month' else ''}")
145
+ if worst is None or frees > worst[0]:
146
+ worst = (frees, why)
147
+ for q in records:
148
+ what = "tokens" if "tokens" in q.get("metric", "") else "requests"
149
+ if not isinstance(q.get("remaining"), int):
150
+ continue
151
+ try:
152
+ seen = _ts(q["observed_at"])
153
+ reset = _ts(q["reset_at"])
154
+ except (KeyError, TypeError, ValueError):
155
+ continue
156
+ used = sum((e.get("attempts") or 0) if what == "requests"
157
+ else (e.get("tokens_in") or 0) + (e.get("tokens_out") or 0)
158
+ for e in rows if e["t"] >= seen and e.get("alias") == alias)
159
+ used += sum(1 if what == "requests" else f.get("tokens") or 0
160
+ for f in flying.values() if f.get("alias") == alias)
161
+ if q["remaining"] - used > 0:
162
+ continue
163
+ limit = f" of {q['limit']:,}" if isinstance(q.get("limit"), int) else ""
164
+ window = "" if q.get("window") in ("unknown", "retry-after") else f" a {q['window']}"
165
+ why = (f"{connection} said {max(q['remaining'], 0):,}{limit} {q['metric']}{window} were left for {alias}"
166
+ f" ({q.get('source')}, {_ago(now - seen)})")
167
+ if worst is None or reset > worst[0]:
168
+ worst = (reset, why)
169
+ return worst
170
+
171
+
172
+ def _ts(iso: str) -> float:
173
+ return dt.datetime.fromisoformat(iso).timestamp()
174
+
175
+
176
+ def _ago(s: float) -> str:
177
+ return "just now" if s < 60 else f"{s / 60:.0f} min ago" if s < 7200 else f"{s / 3600:.0f} h ago"
178
+
179
+
180
+ def admit(alias: str, connection: str, est_tokens: int, left: float, sleep=time.sleep) -> tuple[str | None, str]:
181
+ """Wait for room, or refuse. Returns (ticket, refusal): a ticket to pass
182
+ to `release` once the call's ledger line is written (None when no limit
183
+ applies, so there is nothing to release), or the refusal text."""
184
+ import quota
185
+ rs = rules(alias, connection)
186
+ end = time.time() + left # wall clock: the in-flight entry's expiry, read by other processes
187
+ stop = time.monotonic() + left # the caller's deadline, which a clock change cannot move
188
+ while True:
189
+ records = quota.live(connection, alias)
190
+ if not rs and not records:
191
+ return None, ""
192
+ spans = [AXES[r["axis"]][1] for r in rs]
193
+ since = min([ledger.month_start(time.time()) if s == "month" else time.time() - s for s in spans]
194
+ + [_ts(q["observed_at"]) for q in records if q.get("observed_at")])
195
+ with locked.lock(LOCK):
196
+ now = time.time()
197
+ rows = list(ledger.rows(since))
198
+ flying = _inflight(now)
199
+ block = _blocked(rs, records, alias, connection, est_tokens, now, rows, flying)
200
+ if block is None:
201
+ ticket = uuid.uuid4().hex
202
+ flying[ticket] = {"alias": alias, "connection": connection, "t": now, "tokens": est_tokens,
203
+ "until": end + 60, "pid": os.getpid()}
204
+ locked.write_atomic(INFLIGHT, json.dumps(flying, indent=1) + "\n")
205
+ return ticket, ""
206
+ frees, why = block
207
+ wait = max(frees - time.time(), 0.05)
208
+ if time.monotonic() + wait > stop - MARGIN_S:
209
+ return None, (f"Error: client failed: rate limit (not sent; paced by the bench): {why};"
210
+ f" try again in {_wait_text(wait)}")
211
+ sleep(min(wait, POLL_S))
212
+
213
+
214
+ def release(ticket: str | None) -> None:
215
+ """The call's ledger line is written: it no longer counts as in flight."""
216
+ if not ticket:
217
+ return
218
+ with locked.lock(LOCK):
219
+ flying = _inflight(time.time()) # stale entries go too
220
+ flying.pop(ticket, None)
221
+ locked.write_atomic(INFLIGHT, json.dumps(flying, indent=1) + "\n")
222
+
223
+
224
+ def main() -> None:
225
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
226
+ if len(sys.argv) != 2:
227
+ print(__doc__.split("\n\n")[0])
228
+ return
229
+ sys.path.insert(0, str(ROOT / "suite"))
230
+ import route
231
+ alias = sys.argv[1]
232
+ conn = route.connection_of(alias)
233
+ now = time.time()
234
+ rows = list(ledger.rows(ledger.month_start(now) - 86400))
235
+ rs = rules(alias, conn)
236
+ if not rs:
237
+ print(f"{alias} ({conn}): no limit is set or learned, so nothing paces it")
238
+ for r in rs:
239
+ what, span, reads = AXES[r["axis"]]
240
+ since = ledger.month_start(now) if span == "month" else now - span
241
+ used = sum((e.get("attempts") or 0) if what == "requests" else
242
+ (e.get("tokens_in") or 0) + (e.get("tokens_out") or 0) for e in rows if e["t"] >= since and
243
+ (e.get("connection") == conn if r["scope"] == "connection" else e.get("alias") == alias))
244
+ print(f" {r['axis']:<5} {used:>9,} of {r['limit']:<9,} {reads} ({r['source']})")
245
+
246
+
247
+ if __name__ == "__main__":
248
+ main()
package/metrics/paths.py CHANGED
@@ -1,60 +1,60 @@
1
- """Where this install keeps its data: one answer for every module.
2
-
3
- python metrics/paths.py # the data folder in use, and why
4
-
5
- RELEASE-PLAN.md Phase 1, code and data apart. Everything that is an install's
6
- own state (config/, metrics/*.json*, perf/history.csv, runs/, suite/tasks/ with
7
- the saved answers) is read and written under DATA, never beside the code, so
8
- the same code can serve the public repository and the owner's instance.
9
-
10
- DATA is, in order:
11
- 1. BENCHWARMERS_HOME, when set;
12
- 2. the folder named in `.benchwarmers-home` at the top of the checkout (one
13
- line; gitignored). The owner's instance points at C:\\benchwarmers-data
14
- this way, which every process started from this checkout sees (the dashboard,
15
- the scheduled tasks, the suite), without an environment variable;
16
- 3. the checkout itself, the layout before Phase 1.
17
- ponytail: (3) keeps a fresh checkout working until `benchwarmers init`
18
- exists (Phase 4); then the default becomes the OS's per-user data folder.
19
-
20
- Inside DATA the layout is the checkout's own (`config/endpoints.json`,
21
- `metrics/offloads.jsonl`, `suite/tasks/<id>/output/`), so moving an install's
22
- data is a copy, not a rewrite.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import os
28
- import pathlib
29
-
30
- ROOT = pathlib.Path(__file__).resolve().parent.parent
31
- POINTER = ROOT / ".benchwarmers-home"
32
-
33
-
34
- def home() -> tuple[pathlib.Path, str]:
35
- """(the data folder, how it was chosen)."""
36
- if env := os.environ.get("BENCHWARMERS_HOME", "").strip():
37
- return pathlib.Path(env).expanduser(), "BENCHWARMERS_HOME"
38
- try:
39
- named = POINTER.read_text(encoding="utf-8").strip()
40
- except OSError:
41
- named = ""
42
- if named:
43
- return pathlib.Path(named).expanduser(), f"{POINTER.name}"
44
- return ROOT, "the checkout (no BENCHWARMERS_HOME or .benchwarmers-home)"
45
-
46
-
47
- DATA, WHY = home()
48
- LAYOUT = ("config", "metrics", "perf", "runs", "suite/tasks")
49
-
50
-
51
- def make(folder: pathlib.Path = DATA) -> None:
52
- """The layout, empty. Writers that append (the change log, onboarding's log
53
- and notes, the perf history) expect their folder to exist, which on a fresh
54
- install it does not; serve.py and scripts/init.py make it once instead."""
55
- for sub in LAYOUT:
56
- (folder / sub).mkdir(parents=True, exist_ok=True)
57
-
58
-
59
- if __name__ == "__main__":
60
- print(f"{DATA} ({WHY})")
1
+ """Where this install keeps its data: one answer for every module.
2
+
3
+ python metrics/paths.py # the data folder in use, and why
4
+
5
+ RELEASE-PLAN.md Phase 1, code and data apart. Everything that is an install's
6
+ own state (config/, metrics/*.json*, perf/history.csv, runs/, suite/tasks/ with
7
+ the saved answers) is read and written under DATA, never beside the code, so
8
+ the same code can serve the public repository and the owner's instance.
9
+
10
+ DATA is, in order:
11
+ 1. BENCHWARMERS_HOME, when set;
12
+ 2. the folder named in `.benchwarmers-home` at the top of the checkout (one
13
+ line; gitignored). The owner's instance points at C:\\benchwarmers-data
14
+ this way, which every process started from this checkout sees (the dashboard,
15
+ the scheduled tasks, the suite), without an environment variable;
16
+ 3. the checkout itself, the layout before Phase 1.
17
+ ponytail: (3) keeps a fresh checkout working until `benchwarmers init`
18
+ exists (Phase 4); then the default becomes the OS's per-user data folder.
19
+
20
+ Inside DATA the layout is the checkout's own (`config/endpoints.json`,
21
+ `metrics/offloads.jsonl`, `suite/tasks/<id>/output/`), so moving an install's
22
+ data is a copy, not a rewrite.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import pathlib
29
+
30
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
31
+ POINTER = ROOT / ".benchwarmers-home"
32
+
33
+
34
+ def home() -> tuple[pathlib.Path, str]:
35
+ """(the data folder, how it was chosen)."""
36
+ if env := os.environ.get("BENCHWARMERS_HOME", "").strip():
37
+ return pathlib.Path(env).expanduser(), "BENCHWARMERS_HOME"
38
+ try:
39
+ named = POINTER.read_text(encoding="utf-8").strip()
40
+ except OSError:
41
+ named = ""
42
+ if named:
43
+ return pathlib.Path(named).expanduser(), f"{POINTER.name}"
44
+ return ROOT, "the checkout (no BENCHWARMERS_HOME or .benchwarmers-home)"
45
+
46
+
47
+ DATA, WHY = home()
48
+ LAYOUT = ("config", "metrics", "perf", "runs", "suite/tasks")
49
+
50
+
51
+ def make(folder: pathlib.Path = DATA) -> None:
52
+ """The layout, empty. Writers that append (the change log, onboarding's log
53
+ and notes, the perf history) expect their folder to exist, which on a fresh
54
+ install it does not; serve.py and scripts/init.py make it once instead."""
55
+ for sub in LAYOUT:
56
+ (folder / sub).mkdir(parents=True, exist_ok=True)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ print(f"{DATA} ({WHY})")