@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
@@ -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")