@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,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())
@@ -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())