@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.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- 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})")
|