@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
package/scripts/check_themes.py
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
"""Every theme in ui/themes passes a contrast check, and defines every colour.
|
|
2
|
-
|
|
3
|
-
python scripts/check_themes.py
|
|
4
|
-
|
|
5
|
-
RELEASE-PLAN.md decision 7: themes are colours only, so a theme can break
|
|
6
|
-
nothing but legibility, and that is what this checks. Each pair below is text
|
|
7
|
-
the dashboard draws on a ground (ui/bench.css), held to WCAG AA: 4.5 for body
|
|
8
|
-
text, 3.0 for large or bold text and for non-text marks. Grounds that bench.css
|
|
9
|
-
mixes from these tokens (the chip grounds) are left to the eye.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import pathlib
|
|
15
|
-
import re
|
|
16
|
-
import sys
|
|
17
|
-
|
|
18
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
19
|
-
THEMES = ROOT / "ui" / "themes"
|
|
20
|
-
|
|
21
|
-
# Every colour token a theme must set (fonts, measure and radius come from
|
|
22
|
-
# the base theme's file, rainforest.css, and may be repeated).
|
|
23
|
-
REQUIRED = ("ink", "ink-2", "ink-deep", "paper", "paper-2", "band", "signal", "link", "muted",
|
|
24
|
-
"line", "brand", "primary", "primary-deep", "sky", "warn", "err")
|
|
25
|
-
|
|
26
|
-
# (text, ground, minimum ratio, where it appears)
|
|
27
|
-
PAIRS = [
|
|
28
|
-
("ink", "paper", 4.5, "body text"),
|
|
29
|
-
("ink", "paper-2", 4.5, "text in panels"),
|
|
30
|
-
("ink", "band", 4.5, "text on bands and chips"),
|
|
31
|
-
("muted", "paper", 4.5, "secondary text"),
|
|
32
|
-
("muted", "paper-2", 4.5, "secondary text in panels"),
|
|
33
|
-
("link", "paper", 4.5, "links"),
|
|
34
|
-
("link", "paper-2", 4.5, "links in panels"),
|
|
35
|
-
("err", "paper-2", 4.5, "error text"),
|
|
36
|
-
("warn", "paper-2", 4.5, "warning text"),
|
|
37
|
-
("paper", "ink", 4.5, "primary button"),
|
|
38
|
-
("on-err", "err", 4.5, "danger button"),
|
|
39
|
-
("log-ink", "log-ground", 4.5, "onboarding log"),
|
|
40
|
-
("ink-deep", "sky", 4.5, "'new' chip"),
|
|
41
|
-
("brand", "paper", 3.0, "meters, step marks, button shadow"),
|
|
42
|
-
# Only for a theme with a floor (--floor, a page ground drawn as a
|
|
43
|
-
# pattern): the header and sidebar text sit on it, held to its darkest board.
|
|
44
|
-
("ink", "floor-ground", 4.5, "header text on the floor"),
|
|
45
|
-
("muted", "floor-ground", 4.5, "secondary text on the floor"),
|
|
46
|
-
("link", "floor-ground", 4.5, "links on the floor"),
|
|
47
|
-
]
|
|
48
|
-
OPTIONAL = {"floor-ground"}
|
|
49
|
-
# What bench.css falls back to when a theme leaves these out.
|
|
50
|
-
FALLBACK = {"on-err": "#ffffff", "log-ink": "paper", "log-ground": "ink-deep"}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def tokens(path: pathlib.Path) -> dict[str, str]:
|
|
54
|
-
block = re.search(r":root\s*\{(.*?)\}", path.read_text(encoding="utf-8"), re.S)
|
|
55
|
-
return dict(re.findall(r"--([\w-]+)\s*:\s*([^;]+);", block.group(1))) if block else {}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def colour(theme: dict[str, str], name: str) -> str:
|
|
59
|
-
value = theme.get(name) or FALLBACK.get(name, "")
|
|
60
|
-
return colour(theme, value) if value in theme or value in FALLBACK else value.strip()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def luminance(hex_colour: str) -> float:
|
|
64
|
-
h = hex_colour.lstrip("#")
|
|
65
|
-
if len(h) == 3:
|
|
66
|
-
h = "".join(c * 2 for c in h)
|
|
67
|
-
channels = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
|
68
|
-
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
|
69
|
-
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def ratio(a: str, b: str) -> float:
|
|
73
|
-
la, lb = sorted((luminance(a), luminance(b)), reverse=True)
|
|
74
|
-
return (la + 0.05) / (lb + 0.05)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def check(name: str, theme: dict[str, str]) -> list[str]:
|
|
78
|
-
problems = [f"{name}: --{t} is not set" for t in REQUIRED if t not in theme]
|
|
79
|
-
for text, ground, need, where in PAIRS:
|
|
80
|
-
if ground in OPTIONAL and ground not in theme:
|
|
81
|
-
continue
|
|
82
|
-
a, b = colour(theme, text), colour(theme, ground)
|
|
83
|
-
if not (re.fullmatch(r"#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?", a or "")
|
|
84
|
-
and re.fullmatch(r"#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?", b or "")):
|
|
85
|
-
problems.append(f"{name}: {where}: --{text} or --{ground} is not a hex colour")
|
|
86
|
-
continue
|
|
87
|
-
if (r := ratio(a, b)) < need:
|
|
88
|
-
problems.append(f"{name}: {where}: --{text} on --{ground} is {r:.2f}:1, needs {need}")
|
|
89
|
-
return problems
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def main() -> int:
|
|
93
|
-
assert round(ratio("#000000", "#ffffff"), 1) == 21.0 and round(ratio("#777777", "#ffffff"), 2) == 4.48
|
|
94
|
-
files = sorted(THEMES.glob("*.css"))
|
|
95
|
-
problems = [p for f in files for p in check(f.stem, tokens(f))]
|
|
96
|
-
for p in problems:
|
|
97
|
-
print(p)
|
|
98
|
-
print(f"{len(files)} themes: " + (f"{len(problems)} problems" if problems else "all legible"))
|
|
99
|
-
return 1 if problems else 0
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if __name__ == "__main__":
|
|
103
|
-
sys.exit(main())
|
|
1
|
+
"""Every theme in ui/themes passes a contrast check, and defines every colour.
|
|
2
|
+
|
|
3
|
+
python scripts/check_themes.py
|
|
4
|
+
|
|
5
|
+
RELEASE-PLAN.md decision 7: themes are colours only, so a theme can break
|
|
6
|
+
nothing but legibility, and that is what this checks. Each pair below is text
|
|
7
|
+
the dashboard draws on a ground (ui/bench.css), held to WCAG AA: 4.5 for body
|
|
8
|
+
text, 3.0 for large or bold text and for non-text marks. Grounds that bench.css
|
|
9
|
+
mixes from these tokens (the chip grounds) are left to the eye.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
19
|
+
THEMES = ROOT / "ui" / "themes"
|
|
20
|
+
|
|
21
|
+
# Every colour token a theme must set (fonts, measure and radius come from
|
|
22
|
+
# the base theme's file, rainforest.css, and may be repeated).
|
|
23
|
+
REQUIRED = ("ink", "ink-2", "ink-deep", "paper", "paper-2", "band", "signal", "link", "muted",
|
|
24
|
+
"line", "brand", "primary", "primary-deep", "sky", "warn", "err")
|
|
25
|
+
|
|
26
|
+
# (text, ground, minimum ratio, where it appears)
|
|
27
|
+
PAIRS = [
|
|
28
|
+
("ink", "paper", 4.5, "body text"),
|
|
29
|
+
("ink", "paper-2", 4.5, "text in panels"),
|
|
30
|
+
("ink", "band", 4.5, "text on bands and chips"),
|
|
31
|
+
("muted", "paper", 4.5, "secondary text"),
|
|
32
|
+
("muted", "paper-2", 4.5, "secondary text in panels"),
|
|
33
|
+
("link", "paper", 4.5, "links"),
|
|
34
|
+
("link", "paper-2", 4.5, "links in panels"),
|
|
35
|
+
("err", "paper-2", 4.5, "error text"),
|
|
36
|
+
("warn", "paper-2", 4.5, "warning text"),
|
|
37
|
+
("paper", "ink", 4.5, "primary button"),
|
|
38
|
+
("on-err", "err", 4.5, "danger button"),
|
|
39
|
+
("log-ink", "log-ground", 4.5, "onboarding log"),
|
|
40
|
+
("ink-deep", "sky", 4.5, "'new' chip"),
|
|
41
|
+
("brand", "paper", 3.0, "meters, step marks, button shadow"),
|
|
42
|
+
# Only for a theme with a floor (--floor, a page ground drawn as a
|
|
43
|
+
# pattern): the header and sidebar text sit on it, held to its darkest board.
|
|
44
|
+
("ink", "floor-ground", 4.5, "header text on the floor"),
|
|
45
|
+
("muted", "floor-ground", 4.5, "secondary text on the floor"),
|
|
46
|
+
("link", "floor-ground", 4.5, "links on the floor"),
|
|
47
|
+
]
|
|
48
|
+
OPTIONAL = {"floor-ground"}
|
|
49
|
+
# What bench.css falls back to when a theme leaves these out.
|
|
50
|
+
FALLBACK = {"on-err": "#ffffff", "log-ink": "paper", "log-ground": "ink-deep"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tokens(path: pathlib.Path) -> dict[str, str]:
|
|
54
|
+
block = re.search(r":root\s*\{(.*?)\}", path.read_text(encoding="utf-8"), re.S)
|
|
55
|
+
return dict(re.findall(r"--([\w-]+)\s*:\s*([^;]+);", block.group(1))) if block else {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def colour(theme: dict[str, str], name: str) -> str:
|
|
59
|
+
value = theme.get(name) or FALLBACK.get(name, "")
|
|
60
|
+
return colour(theme, value) if value in theme or value in FALLBACK else value.strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def luminance(hex_colour: str) -> float:
|
|
64
|
+
h = hex_colour.lstrip("#")
|
|
65
|
+
if len(h) == 3:
|
|
66
|
+
h = "".join(c * 2 for c in h)
|
|
67
|
+
channels = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
|
68
|
+
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels]
|
|
69
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ratio(a: str, b: str) -> float:
|
|
73
|
+
la, lb = sorted((luminance(a), luminance(b)), reverse=True)
|
|
74
|
+
return (la + 0.05) / (lb + 0.05)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check(name: str, theme: dict[str, str]) -> list[str]:
|
|
78
|
+
problems = [f"{name}: --{t} is not set" for t in REQUIRED if t not in theme]
|
|
79
|
+
for text, ground, need, where in PAIRS:
|
|
80
|
+
if ground in OPTIONAL and ground not in theme:
|
|
81
|
+
continue
|
|
82
|
+
a, b = colour(theme, text), colour(theme, ground)
|
|
83
|
+
if not (re.fullmatch(r"#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?", a or "")
|
|
84
|
+
and re.fullmatch(r"#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?", b or "")):
|
|
85
|
+
problems.append(f"{name}: {where}: --{text} or --{ground} is not a hex colour")
|
|
86
|
+
continue
|
|
87
|
+
if (r := ratio(a, b)) < need:
|
|
88
|
+
problems.append(f"{name}: {where}: --{text} on --{ground} is {r:.2f}:1, needs {need}")
|
|
89
|
+
return problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
assert round(ratio("#000000", "#ffffff"), 1) == 21.0 and round(ratio("#777777", "#ffffff"), 2) == 4.48
|
|
94
|
+
files = sorted(THEMES.glob("*.css"))
|
|
95
|
+
problems = [p for f in files for p in check(f.stem, tokens(f))]
|
|
96
|
+
for p in problems:
|
|
97
|
+
print(p)
|
|
98
|
+
print(f"{len(files)} themes: " + (f"{len(problems)} problems" if problems else "all legible"))
|
|
99
|
+
return 1 if problems else 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
package/scripts/init.py
CHANGED
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
"""Set up a data folder for a new install, and point this checkout at it.
|
|
2
|
-
|
|
3
|
-
python scripts/init.py # the OS's per-user data folder
|
|
4
|
-
python scripts/init.py ~/bench-data # or one of your choosing
|
|
5
|
-
python scripts/init.py --endpoint openrouter https://openrouter.ai/api/v1 --key
|
|
6
|
-
|
|
7
|
-
RELEASE-PLAN.md Phase 4, `benchwarmers init`, the small version until the
|
|
8
|
-
package exists (Phase 3b brings the command). It:
|
|
9
|
-
|
|
10
|
-
1. copies `examples/` into the folder, never over a file already there, so
|
|
11
|
-
running it again on a folder in use changes nothing it holds;
|
|
12
|
-
2. makes the folder's layout (metrics/paths.py LAYOUT);
|
|
13
|
-
3. points this checkout at it (`.benchwarmers-home`), unless
|
|
14
|
-
BENCHWARMERS_HOME is set or the checkout already points elsewhere: then
|
|
15
|
-
it says what to change rather than repoint an install that has data;
|
|
16
|
-
4. with --endpoint NAME BASE_URL, adds a first endpoint by the dashboard's
|
|
17
|
-
own rules (serve.save_endpoint: saved only once BASE_URL/models answers).
|
|
18
|
-
NAME is also the key's name in the llm key store, and a key already
|
|
19
|
-
stored under it is used. --key asks for one, unechoed, and stores it
|
|
20
|
-
there; never a key on the command line, which lands in the shell's
|
|
21
|
-
history. Asked only with --key: on Windows a redirected stdin still
|
|
22
|
-
reads as a terminal, and getpass then waits on the console for ever.
|
|
23
|
-
|
|
24
|
-
The rest happens in the dashboard (`python serve.py`): sign in with a
|
|
25
|
-
provider or paste a key, onboard models, build a collection.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import argparse
|
|
31
|
-
import getpass
|
|
32
|
-
import os
|
|
33
|
-
import pathlib
|
|
34
|
-
import shutil
|
|
35
|
-
import sys
|
|
36
|
-
|
|
37
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
38
|
-
EXAMPLES = ROOT / "examples"
|
|
39
|
-
POINTER = ROOT / ".benchwarmers-home" # metrics/paths.py reads it
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def default_folder() -> pathlib.Path:
|
|
43
|
-
"""The OS's per-user data folder (RELEASE-PLAN section 2): never beside the
|
|
44
|
-
code, which a package upgrade replaces."""
|
|
45
|
-
if os.name == "nt":
|
|
46
|
-
return pathlib.Path(os.environ.get("LOCALAPPDATA") or pathlib.Path.home()) / "benchwarmers"
|
|
47
|
-
if sys.platform == "darwin":
|
|
48
|
-
return pathlib.Path.home() / "Library" / "Application Support" / "benchwarmers"
|
|
49
|
-
return pathlib.Path(os.environ.get("XDG_DATA_HOME") or pathlib.Path.home() / ".local" / "share") / "benchwarmers"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def point(folder: pathlib.Path, env: str) -> str:
|
|
53
|
-
"""Make this checkout use `folder`, or say why it was left alone."""
|
|
54
|
-
if env:
|
|
55
|
-
if pathlib.Path(env).expanduser().resolve() == folder:
|
|
56
|
-
return "BENCHWARMERS_HOME already names it."
|
|
57
|
-
return f"BENCHWARMERS_HOME is set to {env}, which wins: set it to {folder} to use this folder."
|
|
58
|
-
named = POINTER.read_text(encoding="utf-8").strip() if POINTER.exists() else ""
|
|
59
|
-
if named and pathlib.Path(named).expanduser().resolve() == folder:
|
|
60
|
-
return f"This checkout already points at it ({POINTER.name})."
|
|
61
|
-
if named:
|
|
62
|
-
return (f"This checkout points at {named} ({POINTER.name}) and was left so: to use {folder},"
|
|
63
|
-
f" put that path in {POINTER} or set BENCHWARMERS_HOME to it.")
|
|
64
|
-
if folder == ROOT:
|
|
65
|
-
return "This checkout is its own data folder."
|
|
66
|
-
POINTER.write_text(f"{folder}\n", encoding="utf-8")
|
|
67
|
-
return f"This checkout points at it now ({POINTER.name})."
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def run(folder, endpoint: tuple[str, str] | None = None, secret: str = "") -> tuple[bool, list[str]]:
|
|
71
|
-
"""(ok, what to tell the user)."""
|
|
72
|
-
folder = pathlib.Path(folder).expanduser().resolve()
|
|
73
|
-
env = os.environ.get("BENCHWARMERS_HOME", "").strip()
|
|
74
|
-
# Before metrics/ is imported: paths.py reads it once, and serve.save_endpoint
|
|
75
|
-
# below must write into this folder, whatever the checkout points at.
|
|
76
|
-
os.environ["BENCHWARMERS_HOME"] = str(folder)
|
|
77
|
-
sys.path[:0] = [str(ROOT), str(ROOT / "metrics")]
|
|
78
|
-
import paths
|
|
79
|
-
copied: list[str] = []
|
|
80
|
-
|
|
81
|
-
def copy_new(src, dst):
|
|
82
|
-
if not os.path.exists(dst):
|
|
83
|
-
shutil.copy2(src, dst)
|
|
84
|
-
copied.append(dst)
|
|
85
|
-
shutil.copytree(EXAMPLES, folder, dirs_exist_ok=True, copy_function=copy_new)
|
|
86
|
-
paths.make(folder)
|
|
87
|
-
said = [f"Data folder: {folder} ({len(copied)} starter file(s) copied, nothing overwritten).",
|
|
88
|
-
point(folder, env)]
|
|
89
|
-
ok = True
|
|
90
|
-
if endpoint:
|
|
91
|
-
import serve
|
|
92
|
-
if serve.DATA.resolve() != folder: # never into another install's data
|
|
93
|
-
return False, said + [f"Endpoint not added: this process already uses {serve.DATA}."]
|
|
94
|
-
name, base = endpoint
|
|
95
|
-
r = serve.save_endpoint(name, base, secret, who="init", new=True)
|
|
96
|
-
ok = r["ok"]
|
|
97
|
-
said.append(f"Endpoint {name} added: it lists {r['served']} models. {r.get('note', '')}".strip() if ok else
|
|
98
|
-
"Endpoint not added: " + "; ".join(r["errors"]) + ". Add it from the dashboard instead.")
|
|
99
|
-
port = os.environ.get("BENCH_PORT") or 8122
|
|
100
|
-
# From npm (bin/benchwarmers.js) the dashboard starts next by itself.
|
|
101
|
-
start = "the dashboard starts now: open" if os.environ.get("BENCHWARMERS_LAUNCHER") else "python serve.py, then open"
|
|
102
|
-
said.append(f"Next: {start} http://localhost:{port} to "
|
|
103
|
-
+ ("onboard models from it." if endpoint and ok else
|
|
104
|
-
"add an endpoint (sign in with a provider, or paste a key) and onboard models."))
|
|
105
|
-
return ok, said
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def main() -> int:
|
|
109
|
-
ap = argparse.ArgumentParser(description="Set up a data folder and point this checkout at it.")
|
|
110
|
-
ap.add_argument("folder", nargs="?", help=f"default: {default_folder()}")
|
|
111
|
-
ap.add_argument("--endpoint", nargs=2, metavar=("NAME", "BASE_URL"),
|
|
112
|
-
help="add a first OpenAI-compatible endpoint; NAME is also its key's name")
|
|
113
|
-
ap.add_argument("--key", action="store_true", help="ask for the endpoint's API key (unechoed)")
|
|
114
|
-
args = ap.parse_args()
|
|
115
|
-
if args.key and not args.endpoint:
|
|
116
|
-
ap.error("--key goes with --endpoint")
|
|
117
|
-
secret = getpass.getpass(f"API key for {args.endpoint[0]}: ").strip() if args.key else ""
|
|
118
|
-
ok, said = run(args.folder or default_folder(), args.endpoint, secret)
|
|
119
|
-
print("\n".join(said))
|
|
120
|
-
return 0 if ok else 1
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if __name__ == "__main__":
|
|
124
|
-
sys.exit(main())
|
|
1
|
+
"""Set up a data folder for a new install, and point this checkout at it.
|
|
2
|
+
|
|
3
|
+
python scripts/init.py # the OS's per-user data folder
|
|
4
|
+
python scripts/init.py ~/bench-data # or one of your choosing
|
|
5
|
+
python scripts/init.py --endpoint openrouter https://openrouter.ai/api/v1 --key
|
|
6
|
+
|
|
7
|
+
RELEASE-PLAN.md Phase 4, `benchwarmers init`, the small version until the
|
|
8
|
+
package exists (Phase 3b brings the command). It:
|
|
9
|
+
|
|
10
|
+
1. copies `examples/` into the folder, never over a file already there, so
|
|
11
|
+
running it again on a folder in use changes nothing it holds;
|
|
12
|
+
2. makes the folder's layout (metrics/paths.py LAYOUT);
|
|
13
|
+
3. points this checkout at it (`.benchwarmers-home`), unless
|
|
14
|
+
BENCHWARMERS_HOME is set or the checkout already points elsewhere: then
|
|
15
|
+
it says what to change rather than repoint an install that has data;
|
|
16
|
+
4. with --endpoint NAME BASE_URL, adds a first endpoint by the dashboard's
|
|
17
|
+
own rules (serve.save_endpoint: saved only once BASE_URL/models answers).
|
|
18
|
+
NAME is also the key's name in the llm key store, and a key already
|
|
19
|
+
stored under it is used. --key asks for one, unechoed, and stores it
|
|
20
|
+
there; never a key on the command line, which lands in the shell's
|
|
21
|
+
history. Asked only with --key: on Windows a redirected stdin still
|
|
22
|
+
reads as a terminal, and getpass then waits on the console for ever.
|
|
23
|
+
|
|
24
|
+
The rest happens in the dashboard (`python serve.py`): sign in with a
|
|
25
|
+
provider or paste a key, onboard models, build a collection.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import getpass
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import shutil
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
38
|
+
EXAMPLES = ROOT / "examples"
|
|
39
|
+
POINTER = ROOT / ".benchwarmers-home" # metrics/paths.py reads it
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_folder() -> pathlib.Path:
|
|
43
|
+
"""The OS's per-user data folder (RELEASE-PLAN section 2): never beside the
|
|
44
|
+
code, which a package upgrade replaces."""
|
|
45
|
+
if os.name == "nt":
|
|
46
|
+
return pathlib.Path(os.environ.get("LOCALAPPDATA") or pathlib.Path.home()) / "benchwarmers"
|
|
47
|
+
if sys.platform == "darwin":
|
|
48
|
+
return pathlib.Path.home() / "Library" / "Application Support" / "benchwarmers"
|
|
49
|
+
return pathlib.Path(os.environ.get("XDG_DATA_HOME") or pathlib.Path.home() / ".local" / "share") / "benchwarmers"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def point(folder: pathlib.Path, env: str) -> str:
|
|
53
|
+
"""Make this checkout use `folder`, or say why it was left alone."""
|
|
54
|
+
if env:
|
|
55
|
+
if pathlib.Path(env).expanduser().resolve() == folder:
|
|
56
|
+
return "BENCHWARMERS_HOME already names it."
|
|
57
|
+
return f"BENCHWARMERS_HOME is set to {env}, which wins: set it to {folder} to use this folder."
|
|
58
|
+
named = POINTER.read_text(encoding="utf-8").strip() if POINTER.exists() else ""
|
|
59
|
+
if named and pathlib.Path(named).expanduser().resolve() == folder:
|
|
60
|
+
return f"This checkout already points at it ({POINTER.name})."
|
|
61
|
+
if named:
|
|
62
|
+
return (f"This checkout points at {named} ({POINTER.name}) and was left so: to use {folder},"
|
|
63
|
+
f" put that path in {POINTER} or set BENCHWARMERS_HOME to it.")
|
|
64
|
+
if folder == ROOT:
|
|
65
|
+
return "This checkout is its own data folder."
|
|
66
|
+
POINTER.write_text(f"{folder}\n", encoding="utf-8")
|
|
67
|
+
return f"This checkout points at it now ({POINTER.name})."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(folder, endpoint: tuple[str, str] | None = None, secret: str = "") -> tuple[bool, list[str]]:
|
|
71
|
+
"""(ok, what to tell the user)."""
|
|
72
|
+
folder = pathlib.Path(folder).expanduser().resolve()
|
|
73
|
+
env = os.environ.get("BENCHWARMERS_HOME", "").strip()
|
|
74
|
+
# Before metrics/ is imported: paths.py reads it once, and serve.save_endpoint
|
|
75
|
+
# below must write into this folder, whatever the checkout points at.
|
|
76
|
+
os.environ["BENCHWARMERS_HOME"] = str(folder)
|
|
77
|
+
sys.path[:0] = [str(ROOT), str(ROOT / "metrics")]
|
|
78
|
+
import paths
|
|
79
|
+
copied: list[str] = []
|
|
80
|
+
|
|
81
|
+
def copy_new(src, dst):
|
|
82
|
+
if not os.path.exists(dst):
|
|
83
|
+
shutil.copy2(src, dst)
|
|
84
|
+
copied.append(dst)
|
|
85
|
+
shutil.copytree(EXAMPLES, folder, dirs_exist_ok=True, copy_function=copy_new)
|
|
86
|
+
paths.make(folder)
|
|
87
|
+
said = [f"Data folder: {folder} ({len(copied)} starter file(s) copied, nothing overwritten).",
|
|
88
|
+
point(folder, env)]
|
|
89
|
+
ok = True
|
|
90
|
+
if endpoint:
|
|
91
|
+
import serve
|
|
92
|
+
if serve.DATA.resolve() != folder: # never into another install's data
|
|
93
|
+
return False, said + [f"Endpoint not added: this process already uses {serve.DATA}."]
|
|
94
|
+
name, base = endpoint
|
|
95
|
+
r = serve.save_endpoint(name, base, secret, who="init", new=True)
|
|
96
|
+
ok = r["ok"]
|
|
97
|
+
said.append(f"Endpoint {name} added: it lists {r['served']} models. {r.get('note', '')}".strip() if ok else
|
|
98
|
+
"Endpoint not added: " + "; ".join(r["errors"]) + ". Add it from the dashboard instead.")
|
|
99
|
+
port = os.environ.get("BENCH_PORT") or 8122
|
|
100
|
+
# From npm (bin/benchwarmers.js) the dashboard starts next by itself.
|
|
101
|
+
start = "the dashboard starts now: open" if os.environ.get("BENCHWARMERS_LAUNCHER") else "python serve.py, then open"
|
|
102
|
+
said.append(f"Next: {start} http://localhost:{port} to "
|
|
103
|
+
+ ("onboard models from it." if endpoint and ok else
|
|
104
|
+
"add an endpoint (sign in with a provider, or paste a key) and onboard models."))
|
|
105
|
+
return ok, said
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> int:
|
|
109
|
+
ap = argparse.ArgumentParser(description="Set up a data folder and point this checkout at it.")
|
|
110
|
+
ap.add_argument("folder", nargs="?", help=f"default: {default_folder()}")
|
|
111
|
+
ap.add_argument("--endpoint", nargs=2, metavar=("NAME", "BASE_URL"),
|
|
112
|
+
help="add a first OpenAI-compatible endpoint; NAME is also its key's name")
|
|
113
|
+
ap.add_argument("--key", action="store_true", help="ask for the endpoint's API key (unechoed)")
|
|
114
|
+
args = ap.parse_args()
|
|
115
|
+
if args.key and not args.endpoint:
|
|
116
|
+
ap.error("--key goes with --endpoint")
|
|
117
|
+
secret = getpass.getpass(f"API key for {args.endpoint[0]}: ").strip() if args.key else ""
|
|
118
|
+
ok, said = run(args.folder or default_folder(), args.endpoint, secret)
|
|
119
|
+
print("\n".join(said))
|
|
120
|
+
return 0 if ok else 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
sys.exit(main())
|
package/scripts/list_served.py
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
"""List what Open WebUI actually serves, versus what the llm CLI can see.
|
|
2
|
-
|
|
3
|
-
The gap between those two is the thing this script exists for. A model can be
|
|
4
|
-
live in Open WebUI and completely invisible to the CLI, which reports it as
|
|
5
|
-
"not configured" — indistinguishable, from the outside, from not existing.
|
|
6
|
-
|
|
7
|
-
python scripts/list_served.py # everything, marked
|
|
8
|
-
python scripts/list_served.py nemotron # filter by substring
|
|
9
|
-
|
|
10
|
-
Output marks each served model:
|
|
11
|
-
|
|
12
|
-
[registered] in extra-openai-models.yaml, usable now
|
|
13
|
-
[ MISSING ] served but unregistered — add it to the yaml
|
|
14
|
-
|
|
15
|
-
The API key is read from the llm CLI's own `keys.json` and is never printed.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import json
|
|
21
|
-
import pathlib
|
|
22
|
-
import sys
|
|
23
|
-
import urllib.request
|
|
24
|
-
|
|
25
|
-
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "metrics"))
|
|
26
|
-
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
27
|
-
import endpoints # noqa: E402
|
|
28
|
-
LLM_DIR = endpoints.LLM_DIR
|
|
29
|
-
REPO_YAML = DATA / "config" / "extra-openai-models.yaml"
|
|
30
|
-
API_BASE = endpoints.base() # the default endpoint; the picker covers the rest
|
|
31
|
-
TIMEOUT = 30
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def registered_model_names() -> set[str]:
|
|
35
|
-
"""`model_name` values from the yaml — what the endpoint is asked for.
|
|
36
|
-
|
|
37
|
-
Parsed by hand rather than with PyYAML so this script has no dependency;
|
|
38
|
-
the file is a flat list of scalar keys and that is unlikely to change.
|
|
39
|
-
"""
|
|
40
|
-
text = REPO_YAML.read_text(encoding="utf-8") if REPO_YAML.exists() else ""
|
|
41
|
-
names = set()
|
|
42
|
-
for line in text.splitlines():
|
|
43
|
-
line = line.strip()
|
|
44
|
-
if line.startswith("model_name:"):
|
|
45
|
-
names.add(line.split(":", 1)[1].strip().strip('"').strip("'"))
|
|
46
|
-
return names
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def served() -> list[str]:
|
|
50
|
-
return sorted(endpoints.models(timeout=TIMEOUT))
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def main() -> int:
|
|
54
|
-
needle = sys.argv[1].lower() if len(sys.argv) > 1 else ""
|
|
55
|
-
try:
|
|
56
|
-
models = served()
|
|
57
|
-
except Exception as exc: # noqa: BLE001
|
|
58
|
-
print(f"could not reach {API_BASE}: {type(exc).__name__}: {exc}",
|
|
59
|
-
file=sys.stderr)
|
|
60
|
-
return 1
|
|
61
|
-
|
|
62
|
-
known = registered_model_names()
|
|
63
|
-
shown = [m for m in models if needle in m.lower()]
|
|
64
|
-
missing = 0
|
|
65
|
-
for name in shown:
|
|
66
|
-
mark = "[registered]" if name in known else "[ MISSING ]"
|
|
67
|
-
missing += name not in known
|
|
68
|
-
print(f" {mark} {name}")
|
|
69
|
-
|
|
70
|
-
print(f"\n{len(shown)} shown of {len(models)} served; {missing} unregistered")
|
|
71
|
-
if missing:
|
|
72
|
-
print(f"Add the missing ones to {REPO_YAML.name}, then:"
|
|
73
|
-
f"\n python scripts/sync_config.py")
|
|
74
|
-
return 0
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
raise SystemExit(main())
|
|
1
|
+
"""List what Open WebUI actually serves, versus what the llm CLI can see.
|
|
2
|
+
|
|
3
|
+
The gap between those two is the thing this script exists for. A model can be
|
|
4
|
+
live in Open WebUI and completely invisible to the CLI, which reports it as
|
|
5
|
+
"not configured" — indistinguishable, from the outside, from not existing.
|
|
6
|
+
|
|
7
|
+
python scripts/list_served.py # everything, marked
|
|
8
|
+
python scripts/list_served.py nemotron # filter by substring
|
|
9
|
+
|
|
10
|
+
Output marks each served model:
|
|
11
|
+
|
|
12
|
+
[registered] in extra-openai-models.yaml, usable now
|
|
13
|
+
[ MISSING ] served but unregistered — add it to the yaml
|
|
14
|
+
|
|
15
|
+
The API key is read from the llm CLI's own `keys.json` and is never printed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import pathlib
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "metrics"))
|
|
26
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
27
|
+
import endpoints # noqa: E402
|
|
28
|
+
LLM_DIR = endpoints.LLM_DIR
|
|
29
|
+
REPO_YAML = DATA / "config" / "extra-openai-models.yaml"
|
|
30
|
+
API_BASE = endpoints.base() # the default endpoint; the picker covers the rest
|
|
31
|
+
TIMEOUT = 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def registered_model_names() -> set[str]:
|
|
35
|
+
"""`model_name` values from the yaml — what the endpoint is asked for.
|
|
36
|
+
|
|
37
|
+
Parsed by hand rather than with PyYAML so this script has no dependency;
|
|
38
|
+
the file is a flat list of scalar keys and that is unlikely to change.
|
|
39
|
+
"""
|
|
40
|
+
text = REPO_YAML.read_text(encoding="utf-8") if REPO_YAML.exists() else ""
|
|
41
|
+
names = set()
|
|
42
|
+
for line in text.splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if line.startswith("model_name:"):
|
|
45
|
+
names.add(line.split(":", 1)[1].strip().strip('"').strip("'"))
|
|
46
|
+
return names
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def served() -> list[str]:
|
|
50
|
+
return sorted(endpoints.models(timeout=TIMEOUT))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
needle = sys.argv[1].lower() if len(sys.argv) > 1 else ""
|
|
55
|
+
try:
|
|
56
|
+
models = served()
|
|
57
|
+
except Exception as exc: # noqa: BLE001
|
|
58
|
+
print(f"could not reach {API_BASE}: {type(exc).__name__}: {exc}",
|
|
59
|
+
file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
known = registered_model_names()
|
|
63
|
+
shown = [m for m in models if needle in m.lower()]
|
|
64
|
+
missing = 0
|
|
65
|
+
for name in shown:
|
|
66
|
+
mark = "[registered]" if name in known else "[ MISSING ]"
|
|
67
|
+
missing += name not in known
|
|
68
|
+
print(f" {mark} {name}")
|
|
69
|
+
|
|
70
|
+
print(f"\n{len(shown)} shown of {len(models)} served; {missing} unregistered")
|
|
71
|
+
if missing:
|
|
72
|
+
print(f"Add the missing ones to {REPO_YAML.name}, then:"
|
|
73
|
+
f"\n python scripts/sync_config.py")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
raise SystemExit(main())
|