@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/alerts.py
CHANGED
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
"""Things that went wrong and are still wrong, where they will be seen.
|
|
2
|
-
|
|
3
|
-
python metrics/alerts.py # what is currently wrong
|
|
4
|
-
|
|
5
|
-
The scheduled checks already find problems. Until now they announced them by
|
|
6
|
-
exiting non-zero, which Windows records and nobody reads — a model could be
|
|
7
|
-
retired for a fortnight and the only trace would be a number in Task
|
|
8
|
-
Scheduler. Work that reports only to a log nobody opens has not reported.
|
|
9
|
-
|
|
10
|
-
KEYED AND SELF-CLEARING, NOT AN APPEND LOG
|
|
11
|
-
------------------------------------------
|
|
12
|
-
An alert has a stable `key`, so a daily check that keeps finding the same
|
|
13
|
-
retired model updates one row rather than writing thirty. The first sighting is
|
|
14
|
-
kept, because "missing since Tuesday" is the useful part — a model absent for a
|
|
15
|
-
day may be an outage and one absent for a week is gone.
|
|
16
|
-
|
|
17
|
-
**A check that finds nothing CLEARS its alerts.** Otherwise the file only ever
|
|
18
|
-
grows and the dashboard turns into a wall of stale red that everyone learns to
|
|
19
|
-
ignore, which is worse than no alerting at all. `sweep()` exists so a caller
|
|
20
|
-
can say "these are all the problems I found" and have the rest retired
|
|
21
|
-
automatically.
|
|
22
|
-
|
|
23
|
-
WHAT IS NOT AN ALERT
|
|
24
|
-
--------------------
|
|
25
|
-
Anything transient. A rate limit, a timeout, one failed call — those are
|
|
26
|
-
`capacity` and they resolve themselves. An alert is for a state that will not
|
|
27
|
-
fix itself and that somebody has to decide about: a model retired upstream, a
|
|
28
|
-
registration pointing at a name that does not exist, a subscription that no
|
|
29
|
-
longer covers what it did.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
from __future__ import annotations
|
|
33
|
-
|
|
34
|
-
import datetime as dt
|
|
35
|
-
import pathlib
|
|
36
|
-
|
|
37
|
-
import locked
|
|
38
|
-
|
|
39
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
40
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
41
|
-
FILE = DATA / "metrics" / "alerts.json"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _load() -> dict:
|
|
45
|
-
return locked.read_json(FILE)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _update(change):
|
|
49
|
-
"""Read-change-write under a lock: the gateway's seat threads, the
|
|
50
|
-
scheduled checks and the dashboard all raise and clear alerts."""
|
|
51
|
-
return locked.update(FILE, change, sort_keys=True)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def raise_alert(key: str, kind: str, message: str, action: str = "",
|
|
55
|
-
**extra) -> None:
|
|
56
|
-
"""Record a problem, keeping the date it was first seen.
|
|
57
|
-
|
|
58
|
-
`extra` carries what the dashboard needs to ACT on it rather than just
|
|
59
|
-
show it: `alias` for a one-model action, `aliases` for retire-all.
|
|
60
|
-
"""
|
|
61
|
-
today = dt.date.today().isoformat()
|
|
62
|
-
|
|
63
|
-
def put(data: dict) -> None:
|
|
64
|
-
data[key] = {
|
|
65
|
-
"kind": kind,
|
|
66
|
-
"message": message,
|
|
67
|
-
"action": action,
|
|
68
|
-
"first_seen": data.get(key, {}).get("first_seen", today),
|
|
69
|
-
"last_seen": today,
|
|
70
|
-
**extra,
|
|
71
|
-
}
|
|
72
|
-
_update(put)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def clear(key: str) -> bool:
|
|
76
|
-
return _update(lambda data: data.pop(key, None) is not None)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
|
|
80
|
-
"""Retire every alert under `prefix` that the caller did not re-raise.
|
|
81
|
-
|
|
82
|
-
This is what stops the file growing for ever. A check reports the complete
|
|
83
|
-
set of problems it found, and anything it used to find but no longer does
|
|
84
|
-
is resolved — a model that came back should not leave a red row behind it.
|
|
85
|
-
"""
|
|
86
|
-
def retire(data: dict) -> list[str]:
|
|
87
|
-
gone = [k for k in data
|
|
88
|
-
if k.startswith(prefix) and k not in still_wrong]
|
|
89
|
-
for key in gone:
|
|
90
|
-
del data[key]
|
|
91
|
-
return gone
|
|
92
|
-
return _update(retire)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def active() -> dict:
|
|
96
|
-
return _load()
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def main() -> None:
|
|
100
|
-
data = active()
|
|
101
|
-
if not data:
|
|
102
|
-
print("Nothing wrong.")
|
|
103
|
-
return
|
|
104
|
-
today = dt.date.today()
|
|
105
|
-
print(f"{len(data)} open:\n")
|
|
106
|
-
for key, a in sorted(data.items(),
|
|
107
|
-
key=lambda kv: kv[1]["first_seen"]):
|
|
108
|
-
days = (today - dt.date.fromisoformat(a["first_seen"])).days
|
|
109
|
-
age = "today" if days == 0 else f"{days}d"
|
|
110
|
-
print(f" [{a['kind']}] {a['message']}")
|
|
111
|
-
print(f" first seen {a['first_seen']} ({age})"
|
|
112
|
-
+ (f" — {a['action']}" if a["action"] else ""))
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if __name__ == "__main__":
|
|
116
|
-
main()
|
|
1
|
+
"""Things that went wrong and are still wrong, where they will be seen.
|
|
2
|
+
|
|
3
|
+
python metrics/alerts.py # what is currently wrong
|
|
4
|
+
|
|
5
|
+
The scheduled checks already find problems. Until now they announced them by
|
|
6
|
+
exiting non-zero, which Windows records and nobody reads — a model could be
|
|
7
|
+
retired for a fortnight and the only trace would be a number in Task
|
|
8
|
+
Scheduler. Work that reports only to a log nobody opens has not reported.
|
|
9
|
+
|
|
10
|
+
KEYED AND SELF-CLEARING, NOT AN APPEND LOG
|
|
11
|
+
------------------------------------------
|
|
12
|
+
An alert has a stable `key`, so a daily check that keeps finding the same
|
|
13
|
+
retired model updates one row rather than writing thirty. The first sighting is
|
|
14
|
+
kept, because "missing since Tuesday" is the useful part — a model absent for a
|
|
15
|
+
day may be an outage and one absent for a week is gone.
|
|
16
|
+
|
|
17
|
+
**A check that finds nothing CLEARS its alerts.** Otherwise the file only ever
|
|
18
|
+
grows and the dashboard turns into a wall of stale red that everyone learns to
|
|
19
|
+
ignore, which is worse than no alerting at all. `sweep()` exists so a caller
|
|
20
|
+
can say "these are all the problems I found" and have the rest retired
|
|
21
|
+
automatically.
|
|
22
|
+
|
|
23
|
+
WHAT IS NOT AN ALERT
|
|
24
|
+
--------------------
|
|
25
|
+
Anything transient. A rate limit, a timeout, one failed call — those are
|
|
26
|
+
`capacity` and they resolve themselves. An alert is for a state that will not
|
|
27
|
+
fix itself and that somebody has to decide about: a model retired upstream, a
|
|
28
|
+
registration pointing at a name that does not exist, a subscription that no
|
|
29
|
+
longer covers what it did.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import datetime as dt
|
|
35
|
+
import pathlib
|
|
36
|
+
|
|
37
|
+
import locked
|
|
38
|
+
|
|
39
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
40
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
41
|
+
FILE = DATA / "metrics" / "alerts.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load() -> dict:
|
|
45
|
+
return locked.read_json(FILE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _update(change):
|
|
49
|
+
"""Read-change-write under a lock: the gateway's seat threads, the
|
|
50
|
+
scheduled checks and the dashboard all raise and clear alerts."""
|
|
51
|
+
return locked.update(FILE, change, sort_keys=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def raise_alert(key: str, kind: str, message: str, action: str = "",
|
|
55
|
+
**extra) -> None:
|
|
56
|
+
"""Record a problem, keeping the date it was first seen.
|
|
57
|
+
|
|
58
|
+
`extra` carries what the dashboard needs to ACT on it rather than just
|
|
59
|
+
show it: `alias` for a one-model action, `aliases` for retire-all.
|
|
60
|
+
"""
|
|
61
|
+
today = dt.date.today().isoformat()
|
|
62
|
+
|
|
63
|
+
def put(data: dict) -> None:
|
|
64
|
+
data[key] = {
|
|
65
|
+
"kind": kind,
|
|
66
|
+
"message": message,
|
|
67
|
+
"action": action,
|
|
68
|
+
"first_seen": data.get(key, {}).get("first_seen", today),
|
|
69
|
+
"last_seen": today,
|
|
70
|
+
**extra,
|
|
71
|
+
}
|
|
72
|
+
_update(put)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clear(key: str) -> bool:
|
|
76
|
+
return _update(lambda data: data.pop(key, None) is not None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sweep(prefix: str, still_wrong: set[str]) -> list[str]:
|
|
80
|
+
"""Retire every alert under `prefix` that the caller did not re-raise.
|
|
81
|
+
|
|
82
|
+
This is what stops the file growing for ever. A check reports the complete
|
|
83
|
+
set of problems it found, and anything it used to find but no longer does
|
|
84
|
+
is resolved — a model that came back should not leave a red row behind it.
|
|
85
|
+
"""
|
|
86
|
+
def retire(data: dict) -> list[str]:
|
|
87
|
+
gone = [k for k in data
|
|
88
|
+
if k.startswith(prefix) and k not in still_wrong]
|
|
89
|
+
for key in gone:
|
|
90
|
+
del data[key]
|
|
91
|
+
return gone
|
|
92
|
+
return _update(retire)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def active() -> dict:
|
|
96
|
+
return _load()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
data = active()
|
|
101
|
+
if not data:
|
|
102
|
+
print("Nothing wrong.")
|
|
103
|
+
return
|
|
104
|
+
today = dt.date.today()
|
|
105
|
+
print(f"{len(data)} open:\n")
|
|
106
|
+
for key, a in sorted(data.items(),
|
|
107
|
+
key=lambda kv: kv[1]["first_seen"]):
|
|
108
|
+
days = (today - dt.date.fromisoformat(a["first_seen"])).days
|
|
109
|
+
age = "today" if days == 0 else f"{days}d"
|
|
110
|
+
print(f" [{a['kind']}] {a['message']}")
|
|
111
|
+
print(f" first seen {a['first_seen']} ({age})"
|
|
112
|
+
+ (f" — {a['action']}" if a["action"] else ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
package/metrics/aliases.py
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
"""One place that knows an alias's current name.
|
|
2
|
-
|
|
3
|
-
`config/alias-history.json` maps an old alias to the name it became, because
|
|
4
|
-
aliases get renamed (`laguna-xs` became `laguna-xs-2.1` when a 2.2 was
|
|
5
|
-
coming) and every record here is keyed by alias: suite outputs are named
|
|
6
|
-
`<alias>.md`, offloads log `"model": "<alias>"`, the perf history has a
|
|
7
|
-
`model` column, the run notes quote the alias in a table. The notes are
|
|
8
|
-
narrative and are never retro-edited; the rest is data and is never rewritten
|
|
9
|
-
either — instead every reader maps through this, so a renamed model keeps
|
|
10
|
-
its whole record under its new name.
|
|
11
|
-
|
|
12
|
-
Chains are followed (a -> b -> c resolves a to c) and cycles are refused.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import pathlib
|
|
19
|
-
|
|
20
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
21
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
22
|
-
HISTORY = DATA / "config" / "alias-history.json"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def history() -> dict[str, str]:
|
|
26
|
-
try:
|
|
27
|
-
return json.loads(HISTORY.read_text(encoding="utf-8"))
|
|
28
|
-
except (OSError, ValueError):
|
|
29
|
-
return {}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def current(alias: str, table: dict[str, str] | None = None) -> str:
|
|
33
|
-
"""The name this alias goes by today."""
|
|
34
|
-
table = history() if table is None else table
|
|
35
|
-
seen = {alias}
|
|
36
|
-
while alias in table:
|
|
37
|
-
alias = table[alias]
|
|
38
|
-
if alias in seen:
|
|
39
|
-
raise ValueError(f"alias history loops at {alias!r}")
|
|
40
|
-
seen.add(alias)
|
|
41
|
-
return alias
|
|
1
|
+
"""One place that knows an alias's current name.
|
|
2
|
+
|
|
3
|
+
`config/alias-history.json` maps an old alias to the name it became, because
|
|
4
|
+
aliases get renamed (`laguna-xs` became `laguna-xs-2.1` when a 2.2 was
|
|
5
|
+
coming) and every record here is keyed by alias: suite outputs are named
|
|
6
|
+
`<alias>.md`, offloads log `"model": "<alias>"`, the perf history has a
|
|
7
|
+
`model` column, the run notes quote the alias in a table. The notes are
|
|
8
|
+
narrative and are never retro-edited; the rest is data and is never rewritten
|
|
9
|
+
either — instead every reader maps through this, so a renamed model keeps
|
|
10
|
+
its whole record under its new name.
|
|
11
|
+
|
|
12
|
+
Chains are followed (a -> b -> c resolves a to c) and cycles are refused.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import pathlib
|
|
19
|
+
|
|
20
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
21
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
22
|
+
HISTORY = DATA / "config" / "alias-history.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def history() -> dict[str, str]:
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(HISTORY.read_text(encoding="utf-8"))
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current(alias: str, table: dict[str, str] | None = None) -> str:
|
|
33
|
+
"""The name this alias goes by today."""
|
|
34
|
+
table = history() if table is None else table
|
|
35
|
+
seen = {alias}
|
|
36
|
+
while alias in table:
|
|
37
|
+
alias = table[alias]
|
|
38
|
+
if alias in seen:
|
|
39
|
+
raise ValueError(f"alias history loops at {alias!r}")
|
|
40
|
+
seen.add(alias)
|
|
41
|
+
return alias
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
"""Bench-gateway collections: named groups of models a caller asks for by name.
|
|
2
|
-
|
|
3
|
-
config/collections.json {"core": {"members": [...], "shape": "",
|
|
4
|
-
"description": "...", "tags": ["general"],
|
|
5
|
-
"reply": "all", "merger": ""}}
|
|
6
|
-
config/gateway.json {"max_calls": 5, "max_explorers": 2}
|
|
7
|
-
|
|
8
|
-
A request for `bench/core` runs every member, plus explorer seats that
|
|
9
|
-
`route.py` fills by staleness so the roster keeps getting evidence even when
|
|
10
|
-
the same collection is called every day:
|
|
11
|
-
|
|
12
|
-
explorers = min(max_explorers, max_calls - members)
|
|
13
|
-
|
|
14
|
-
`shape` (review, design, …) decides which models may fill those explorer
|
|
15
|
-
seats; empty means a decision model labels each prompt, or `general` without
|
|
16
|
-
one. `reply` is what the caller reads: `all` (every answer, the default) or
|
|
17
|
-
`merged` (one answer a `merger` model writes from them; empty means chosen by
|
|
18
|
-
verdict record, gateway.py). A request may override it with a `:merged` or
|
|
19
|
-
`:all` suffix on the model name, or `"bench": {"reply": ...}` in the body.
|
|
20
|
-
Named `bench_collections` because `collections` would shadow the standard
|
|
21
|
-
library for everything importing from metrics/.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import json
|
|
27
|
-
import pathlib
|
|
28
|
-
import re
|
|
29
|
-
|
|
30
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
31
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
32
|
-
FILE = DATA / "config" / "collections.json"
|
|
33
|
-
SETTINGS_FILE = DATA / "config" / "gateway.json"
|
|
34
|
-
DEFAULTS = {"max_calls": 5, "max_explorers": 2}
|
|
35
|
-
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
36
|
-
PREFIX = "bench/"
|
|
37
|
-
REPLIES = ("all", "merged")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _read(path: pathlib.Path) -> dict:
|
|
41
|
-
try:
|
|
42
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
-
except (OSError, ValueError):
|
|
44
|
-
return {}
|
|
45
|
-
return data if isinstance(data, dict) else {}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def unreadable(path: pathlib.Path) -> bool:
|
|
49
|
-
"""True if the file exists but is not a JSON object. A writer must refuse
|
|
50
|
-
then: `_read` would hand it {} and the save would wipe the file."""
|
|
51
|
-
if not path.exists():
|
|
52
|
-
return False
|
|
53
|
-
try:
|
|
54
|
-
return not isinstance(json.loads(path.read_text(encoding="utf-8")), dict)
|
|
55
|
-
except (OSError, ValueError):
|
|
56
|
-
return True
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def load() -> dict[str, dict]:
|
|
60
|
-
return _read(FILE)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def settings() -> dict:
|
|
64
|
-
return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def explorers_for(members: int, s: dict | None = None) -> int:
|
|
68
|
-
s = s or settings()
|
|
69
|
-
return max(0, min(s["max_explorers"], s["max_calls"] - members))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
TAG = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def described(name: str, c: dict, s: dict | None = None) -> dict:
|
|
76
|
-
"""What a caller needs to choose a collection: purpose, tags and make-up.
|
|
77
|
-
Served on GET /v1/models, so an orchestrator can pick by task."""
|
|
78
|
-
members = c.get("members", [])
|
|
79
|
-
out = {"description": c.get("description") or c.get("note", ""),
|
|
80
|
-
"tags": c.get("tags", []), "members": members, "shape": c.get("shape", ""),
|
|
81
|
-
"explorers": explorers_for(len(members), s),
|
|
82
|
-
"reply": c.get("reply") or "all", "merger": c.get("merger", "")}
|
|
83
|
-
# What the members can handle together (metrics/catalog.py): the smallest
|
|
84
|
-
# context window is what the gateway refuses above.
|
|
85
|
-
try:
|
|
86
|
-
import catalog
|
|
87
|
-
out["together"] = catalog.consolidate(members)
|
|
88
|
-
except Exception: # noqa: BLE001
|
|
89
|
-
out["together"] = None
|
|
90
|
-
return out
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def resolve(model: str | None) -> str | None:
|
|
94
|
-
"""The collection a request's `model` names: `bench/core` or `core`; None
|
|
95
|
-
when it names nothing here. There is no default: a caller that did not
|
|
96
|
-
choose is told what each collection is for and asked to choose."""
|
|
97
|
-
name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
|
|
98
|
-
return name if name and name in load() else None
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def split_reply(model: str) -> tuple[str, str]:
|
|
102
|
-
"""`bench/core:merged` -> ("bench/core", "merged"), for clients that can
|
|
103
|
-
only pick a model name. No known suffix -> (model, ""): a collection name
|
|
104
|
-
cannot hold ':', so anything else stays and is refused as unknown."""
|
|
105
|
-
base, sep, mode = model.rpartition(":")
|
|
106
|
-
return (base, mode) if sep and mode in REPLIES else (model, "")
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def reply_errors(reply: str, merger: str, known) -> list[str]:
|
|
110
|
-
"""What is wrong with a collection's reply mode and merger. `known`: the
|
|
111
|
-
chat models on the bench; the merger is any of them, member or not."""
|
|
112
|
-
errors = []
|
|
113
|
-
if reply not in REPLIES:
|
|
114
|
-
errors.append(f"reply must be one of {', '.join(REPLIES)}")
|
|
115
|
-
if merger and merger not in known:
|
|
116
|
-
errors.append(f"merger: {merger!r} is not a chat model on the bench; leave it empty to choose by verdicts")
|
|
117
|
-
return errors
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def choose_message(model: str | None) -> str:
|
|
121
|
-
"""The refusal for a request that names no collection, or the wrong one:
|
|
122
|
-
every collection with its description and tags, so the caller can choose."""
|
|
123
|
-
asked = f"{model!r} is not a collection here." if model else "No collection was named."
|
|
124
|
-
rows = [f"- {PREFIX}{n}: {c.get('description') or 'no description'}"
|
|
125
|
-
+ (f" [{', '.join(c.get('tags', []))}]" if c.get("tags") else "")
|
|
126
|
-
for n, c in sorted(load().items())]
|
|
127
|
-
return (f"{asked} Set \"model\" to the collection that fits the task"
|
|
128
|
-
" (GET /v1/models lists them with description and tags):\n"
|
|
129
|
-
+ ("\n".join(rows) or "- none yet: create one in the dashboard's Collections dialog"))
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if __name__ == "__main__":
|
|
133
|
-
# DEFAULTS, not settings(): the install's gateway.json must not decide the check.
|
|
134
|
-
assert [explorers_for(n, DEFAULTS) for n in range(1, 7)] == [2, 2, 2, 1, 0, 0]
|
|
135
|
-
assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
|
|
136
|
-
assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
|
|
137
|
-
assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
|
|
138
|
-
assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
|
|
139
|
-
assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
|
|
140
|
-
assert len(reply_errors("", "m", [])) == 2
|
|
141
|
-
print("ok")
|
|
1
|
+
"""Bench-gateway collections: named groups of models a caller asks for by name.
|
|
2
|
+
|
|
3
|
+
config/collections.json {"core": {"members": [...], "shape": "",
|
|
4
|
+
"description": "...", "tags": ["general"],
|
|
5
|
+
"reply": "all", "merger": ""}}
|
|
6
|
+
config/gateway.json {"max_calls": 5, "max_explorers": 2}
|
|
7
|
+
|
|
8
|
+
A request for `bench/core` runs every member, plus explorer seats that
|
|
9
|
+
`route.py` fills by staleness so the roster keeps getting evidence even when
|
|
10
|
+
the same collection is called every day:
|
|
11
|
+
|
|
12
|
+
explorers = min(max_explorers, max_calls - members)
|
|
13
|
+
|
|
14
|
+
`shape` (review, design, …) decides which models may fill those explorer
|
|
15
|
+
seats; empty means a decision model labels each prompt, or `general` without
|
|
16
|
+
one. `reply` is what the caller reads: `all` (every answer, the default) or
|
|
17
|
+
`merged` (one answer a `merger` model writes from them; empty means chosen by
|
|
18
|
+
verdict record, gateway.py). A request may override it with a `:merged` or
|
|
19
|
+
`:all` suffix on the model name, or `"bench": {"reply": ...}` in the body.
|
|
20
|
+
Named `bench_collections` because `collections` would shadow the standard
|
|
21
|
+
library for everything importing from metrics/.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import pathlib
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
31
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
32
|
+
FILE = DATA / "config" / "collections.json"
|
|
33
|
+
SETTINGS_FILE = DATA / "config" / "gateway.json"
|
|
34
|
+
DEFAULTS = {"max_calls": 5, "max_explorers": 2}
|
|
35
|
+
NAME = re.compile(r"^[a-z0-9][a-z0-9-]{1,31}$")
|
|
36
|
+
PREFIX = "bench/"
|
|
37
|
+
REPLIES = ("all", "merged")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read(path: pathlib.Path) -> dict:
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return {}
|
|
45
|
+
return data if isinstance(data, dict) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unreadable(path: pathlib.Path) -> bool:
|
|
49
|
+
"""True if the file exists but is not a JSON object. A writer must refuse
|
|
50
|
+
then: `_read` would hand it {} and the save would wipe the file."""
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return False
|
|
53
|
+
try:
|
|
54
|
+
return not isinstance(json.loads(path.read_text(encoding="utf-8")), dict)
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load() -> dict[str, dict]:
|
|
60
|
+
return _read(FILE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def settings() -> dict:
|
|
64
|
+
return {**DEFAULTS, **{k: v for k, v in _read(SETTINGS_FILE).items() if k in DEFAULTS}}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def explorers_for(members: int, s: dict | None = None) -> int:
|
|
68
|
+
s = s or settings()
|
|
69
|
+
return max(0, min(s["max_explorers"], s["max_calls"] - members))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
TAG = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def described(name: str, c: dict, s: dict | None = None) -> dict:
|
|
76
|
+
"""What a caller needs to choose a collection: purpose, tags and make-up.
|
|
77
|
+
Served on GET /v1/models, so an orchestrator can pick by task."""
|
|
78
|
+
members = c.get("members", [])
|
|
79
|
+
out = {"description": c.get("description") or c.get("note", ""),
|
|
80
|
+
"tags": c.get("tags", []), "members": members, "shape": c.get("shape", ""),
|
|
81
|
+
"explorers": explorers_for(len(members), s),
|
|
82
|
+
"reply": c.get("reply") or "all", "merger": c.get("merger", "")}
|
|
83
|
+
# What the members can handle together (metrics/catalog.py): the smallest
|
|
84
|
+
# context window is what the gateway refuses above.
|
|
85
|
+
try:
|
|
86
|
+
import catalog
|
|
87
|
+
out["together"] = catalog.consolidate(members)
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
out["together"] = None
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve(model: str | None) -> str | None:
|
|
94
|
+
"""The collection a request's `model` names: `bench/core` or `core`; None
|
|
95
|
+
when it names nothing here. There is no default: a caller that did not
|
|
96
|
+
choose is told what each collection is for and asked to choose."""
|
|
97
|
+
name = (model or "").strip().removeprefix(PREFIX) # "bench/" alone names nothing
|
|
98
|
+
return name if name and name in load() else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def split_reply(model: str) -> tuple[str, str]:
|
|
102
|
+
"""`bench/core:merged` -> ("bench/core", "merged"), for clients that can
|
|
103
|
+
only pick a model name. No known suffix -> (model, ""): a collection name
|
|
104
|
+
cannot hold ':', so anything else stays and is refused as unknown."""
|
|
105
|
+
base, sep, mode = model.rpartition(":")
|
|
106
|
+
return (base, mode) if sep and mode in REPLIES else (model, "")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def reply_errors(reply: str, merger: str, known) -> list[str]:
|
|
110
|
+
"""What is wrong with a collection's reply mode and merger. `known`: the
|
|
111
|
+
chat models on the bench; the merger is any of them, member or not."""
|
|
112
|
+
errors = []
|
|
113
|
+
if reply not in REPLIES:
|
|
114
|
+
errors.append(f"reply must be one of {', '.join(REPLIES)}")
|
|
115
|
+
if merger and merger not in known:
|
|
116
|
+
errors.append(f"merger: {merger!r} is not a chat model on the bench; leave it empty to choose by verdicts")
|
|
117
|
+
return errors
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def choose_message(model: str | None) -> str:
|
|
121
|
+
"""The refusal for a request that names no collection, or the wrong one:
|
|
122
|
+
every collection with its description and tags, so the caller can choose."""
|
|
123
|
+
asked = f"{model!r} is not a collection here." if model else "No collection was named."
|
|
124
|
+
rows = [f"- {PREFIX}{n}: {c.get('description') or 'no description'}"
|
|
125
|
+
+ (f" [{', '.join(c.get('tags', []))}]" if c.get("tags") else "")
|
|
126
|
+
for n, c in sorted(load().items())]
|
|
127
|
+
return (f"{asked} Set \"model\" to the collection that fits the task"
|
|
128
|
+
" (GET /v1/models lists them with description and tags):\n"
|
|
129
|
+
+ ("\n".join(rows) or "- none yet: create one in the dashboard's Collections dialog"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
# DEFAULTS, not settings(): the install's gateway.json must not decide the check.
|
|
134
|
+
assert [explorers_for(n, DEFAULTS) for n in range(1, 7)] == [2, 2, 2, 1, 0, 0]
|
|
135
|
+
assert explorers_for(3, {"max_calls": 4, "max_explorers": 2}) == 1
|
|
136
|
+
assert resolve("bench/") is None and resolve("bench/no-such") is None and resolve("") is None
|
|
137
|
+
assert split_reply("bench/core:merged") == ("bench/core", "merged") and split_reply("core:all") == ("core", "all")
|
|
138
|
+
assert split_reply("bench/core") == ("bench/core", "") and split_reply("bench/core:x") == ("bench/core:x", "")
|
|
139
|
+
assert not reply_errors("merged", "", []) and not reply_errors("all", "m", ["m"])
|
|
140
|
+
assert len(reply_errors("", "m", [])) == 2
|
|
141
|
+
print("ok")
|