@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.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  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 +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -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/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. 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())