@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,331 +1,410 @@
1
- """Take a model from "served by an endpoint" to "usable, with evidence".
2
-
3
- python scripts/onboard.py "vendor/new-model:free"
4
- python scripts/onboard.py "vendor/new-model:free" --skip-suite
5
-
6
- Models arrive every few days. Without a process each one is registered by hand,
7
- half-tested, and then either trusted on no evidence or forgotten — and the
8
- router cannot recommend what it has no rows for. This does the whole sequence
9
- in one pass and writes down what it found.
10
-
11
- WHAT IT DOES, AND WHY IN THIS ORDER
12
- -----------------------------------
13
- 1. **Confirm the endpoint serves it.** Fails immediately if not, because every
14
- later step would otherwise produce a confusing failure instead of a clear
15
- one. `gpt-oss-120b` was registered under a name the endpoint never served
16
- and spent a whole 16-model suite run scoring 0/11 before anyone noticed.
17
- 2. **Derive the alias** by the standing rule: drop the vendor prefix (the
18
- connection tag already carries it), turn `:` into `-`. So
19
- `poolside/laguna-s-2.1:free` becomes `laguna-s-2.1-free`. Version numbers
20
- stay, because there will be a 2.2.
21
- 3. **Register and sync**, then, with an Open WebUI hub, refresh the provider
22
- tags so the connection behind it is known — which is what the router uses
23
- to keep a panel independent. Without one the endpoint is the connection.
24
- Then refresh the catalog, so its details (context, tools, vision) show now.
25
- 4. **Probe availability** before spending anything: two prompt sizes, two
26
- samples. A model that cannot answer "ready" will not answer a 46 KB task,
27
- and finding that out here costs four calls instead of six large ones.
28
- 5. **Run the internal suite**, the only tier entitled to rank. Not the floor
29
- tier: 164 calls for a contaminated number that cannot rank anyway.
30
-
31
- WHAT IT DELIBERATELY DOES NOT DO
32
- --------------------------------
33
- Judge. It reports what happened and where the model lands; whether to route
34
- work there is a decision with a person in it.
35
- """
36
-
37
- from __future__ import annotations
38
-
39
- import argparse
40
- import datetime as dt
41
- import json
42
- import pathlib
43
- import re
44
- import subprocess
45
- import sys
46
- import urllib.request
47
-
48
- ROOT = pathlib.Path(__file__).resolve().parent.parent
49
- sys.path.insert(0, str(ROOT / "metrics"))
50
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
51
- YAML = DATA / "config" / "extra-openai-models.yaml"
52
- sys.path.insert(0, str(ROOT / "metrics"))
53
- sys.path.insert(0, str(ROOT / "suite"))
54
- import endpoints # noqa: E402
55
- import decide # noqa: E402
56
- import locked # noqa: E402
57
- from run import bench_python # noqa: E402
58
-
59
- PY = bench_python()
60
-
61
-
62
- def alias_for(served_name: str, taken: dict[str, str] | None = None,
63
- strip_prefix: str = "") -> str:
64
- """The standing naming rule, plus disambiguation when it collides.
65
-
66
- `codestral-latest` is served by BOTH mistral and llm7-api, and the rule
67
- alone gives them the same alias — which would silently overwrite one
68
- registration with the other and mix two different endpoints' results into
69
- one row. When the name is already taken by a DIFFERENT served model, the
70
- connection is appended: `codestral-latest-llm7-api`.
71
-
72
- `strip_prefix` removes an Open WebUI connection prefix before the rule is
73
- applied, so `llm7.codestral-latest` is recognised as the same MODEL as
74
- mistral's and gets the collision suffix, rather than sailing past the check
75
- as a unique name and landing as the alias `llm7.codestral-latest`. It is
76
- explicit rather than sniffed, because a leading dotted segment is not
77
- reliably a prefix — `gpt-3.5-turbo` would lose its version to a guess.
78
- """
79
- if strip_prefix and served_name.startswith(strip_prefix):
80
- served_name_stripped = served_name[len(strip_prefix):]
81
- else:
82
- served_name_stripped = served_name
83
- base = served_name_stripped.split("/")[-1].replace(":", "-")
84
- if not taken or taken.get(base) in (None, served_name):
85
- return base
86
- tags = locked.read_json(DATA / "config" / "provider-tags.json") # none without Open WebUI
87
- tag = (tags.get(served_name) or "alt").split("/")[0]
88
- return f"{base}-{tag}"
89
-
90
-
91
- def existing_aliases(include_retired: bool = True) -> dict[str, str]:
92
- """alias -> the served name it is already pointing at.
93
-
94
- Retired aliases count as taken: a retired name is never reused, because
95
- the run notes and perf history still say it and would merge two models.
96
- """
97
- retired = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8")
98
- or "{}") if include_retired and (DATA / "config" / "retired.json").exists() else {}
99
- out = {a: r.get("served", "") for a, r in retired.items()}
100
- out.update({a: d["served"] for a, d in decide.load().items()})
101
- out.update({a: r.get("model_name", "") for a, r in endpoints.registrations().items()})
102
- return out
103
-
104
-
105
- def served_models(endpoint: str | None = None) -> dict[str, dict]:
106
- """served id -> its /models entry, from any endpoint in config/endpoints.json."""
107
- return endpoints.models(endpoint)
108
-
109
-
110
- def connection_prefix(entry: dict) -> str:
111
- """The prefix Open WebUI put in front of the upstream id, or "".
112
-
113
- Open WebUI keeps the upstream's own id at `openai.openai.id`, so
114
- `llm7.codestral-latest` carries `codestral-latest` and the prefix is the
115
- difference -- read, not typed by hand, and not guessed from a dot
116
- (`gpt-3.5-turbo` has one and no prefix). A plain OpenAI-compatible endpoint
117
- has no such field and adds no prefix, so "" is right there too.
118
- """
119
- served = entry.get("id", "")
120
- upstream = ((entry.get("openai") or {}).get("openai") or {}).get("id", "")
121
- if upstream and served != upstream and served.endswith(upstream):
122
- return served[:-len(upstream)]
123
- return ""
124
-
125
-
126
- def already_registered(served_name: str, endpoint: str) -> str | None:
127
- # Exact match against the parsed model_name VALUES, not a substring check
128
- # against the raw text. A substring check false-positives whenever one
129
- # served name is a prefix of another already registered -- `mercury-2`
130
- # against a file that already holds `model_name: mercury-2.5` reads as
131
- # "already registered" and silently skips writing the real entry, which
132
- # then fails every later step for a reason that looks like the model
133
- # being broken instead of never having been registered at all.
134
- # Per endpoint: the same id on a second endpoint is a second model.
135
- return served_name if any(r["served"] == served_name and r["endpoint"] == endpoint
136
- for r in endpoints.registry()) else None
137
-
138
-
139
- def register(alias: str, served_name: str, endpoint: str | None = None) -> None:
140
- with YAML.open("a", encoding="utf-8") as fh:
141
- fh.write(endpoints.yaml_block(alias, served_name, endpoint))
142
-
143
-
144
- def probe_answered(stdout: str, alias: str) -> bool:
145
- """Did perf/run.py print a row for this model with at least one answer?
146
- Looking for "0a/" counted a probe that crashed (no rows at all) as
147
- answered, and would have read "10a/" as nothing answered."""
148
- rows = (re.search(r"\b(\d+)a/\d+r/", line) for line in stdout.splitlines()
149
- if line.split()[:1] == [alias])
150
- return any(m and int(m.group(1)) > 0 for m in rows)
151
-
152
-
153
- def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
154
- return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
155
- capture_output=True, text=True, encoding="utf-8",
156
- errors="replace")
157
-
158
-
159
- def onboard_decision(name: str, endpoint: str, via: str, alias: str, path: str = "decisions") -> None:
160
- """Record a decision model: where its Decisions API answers and which
161
- question types it takes. Same five step markers, so the dashboard modal
162
- follows it like any other onboarding."""
163
- print(f" [2/5] decision model: answers through {via}'s Decisions API, not chat")
164
- types = decide.question_types(via, name, path)
165
- print(f" [3/5] question types: {', '.join(types)}")
166
- data = decide.load()
167
- data[alias] = {"served": name, "endpoint": endpoint, "via": via, "path": path,
168
- "question_types": types,
169
- "onboarded": dt.date.today().isoformat()}
170
- decide.FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
171
- locked.update(DATA / "config" / "model-roles.json",
172
- lambda roles: roles.setdefault("models", {}).__setitem__(alias, "decision"), ensure_ascii=False)
173
- print(f" [4/5] recorded as {alias} in config/decision-models.json, role decision")
174
- print(" [5/5] no chat probe or suite: they cannot test a decision model."
175
- f" Ask it with: python metrics/decide.py {alias} \"<text>\" --noul \"<question>\"")
176
- stamp = dt.date.today().isoformat()
177
- note = DATA / "runs" / f"onboard-{stamp}.md"
178
- with note.open("a", encoding="utf-8") as fh:
179
- fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
180
- f"Decision model, found on {endpoint}, answering through {via}'s Decisions API."
181
- f" Question types: {', '.join(types)}. Not in the llm"
182
- f" yaml and never a panel seat; its test is agreement with known verdicts.\n")
183
- print(f"\nwrote {note}")
184
-
185
-
186
- def main() -> None:
187
- # route.py's panel contains em dashes, and this script inherits Windows'
188
- # cp1252 stdout when run from a scheduled task or a plain console. Without
189
- # this the run completes, prints most of its report, and then dies on the
190
- # last few lines BEFORE writing the onboarding note -- losing the record of
191
- # a run that actually succeeded.
192
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
193
-
194
- ap = argparse.ArgumentParser(description=__doc__)
195
- ap.add_argument("served_name", help="the id the endpoint serves it under")
196
- ap.add_argument("--endpoint", default=None,
197
- help="a name from config/endpoints.json; the default endpoint if omitted")
198
- ap.add_argument("--skip-suite", action="store_true",
199
- help="register and probe only")
200
- args = ap.parse_args()
201
-
202
- name = args.served_name
203
- endpoint = args.endpoint or endpoints.default()
204
- # 1. served?
205
- try:
206
- live = served_models(endpoint)
207
- except Exception as exc: # noqa: BLE001
208
- sys.exit(f"cannot reach the {endpoint} endpoint: {exc}")
209
- alias = alias_for(name, existing_aliases(),
210
- connection_prefix(live.get(name, {})))
211
- print(f"onboarding {name} from {endpoint} -> alias {alias}\n")
212
- if name not in live:
213
- near = [s for s in live if alias.split("-")[0] in s][:5]
214
- sys.exit(f"{name} is not served.\n"
215
- f" Did you mean: {', '.join(near) if near else '(nothing similar)'}\n"
216
- f" Add it to {endpoint} first — registering a name the "
217
- f"endpoint does not serve is how a model scores 0/11 for a "
218
- f"fortnight before anyone looks.")
219
- print(" [1/5] served by the endpoint")
220
-
221
- # A decision model refuses chat, so registering it in the llm yaml, the
222
- # chat probe and the suite would only record failures against it. Some
223
- # listings say so (Inception's: supported_endpoints ["decisions"]); others
224
- # (OpenRouter's, via Open WebUI) say nothing, and then the chat refusal does.
225
- listed = live[name].get("supported_endpoints") or []
226
- decision_only = "decisions" in listed and "chat.completions" not in listed
227
- if decision_only or (not listed and decide.refuses_chat(endpoint, name)):
228
- # Called on the endpoint it was picked from, like any model.
229
- why, path = decide.probe(endpoint, name)
230
- if why is None:
231
- onboard_decision(name, endpoint, endpoint, alias, path)
232
- return
233
- # The passthrough hints are about Open WebUI; anywhere else a 404 is the provider's.
234
- owui = endpoints.hub(endpoint) == "openwebui"
235
- hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
236
- " in its environment.\n" if owui and "passthrough" in why.lower() else
237
- " Its connection has no /decisions under its base. An OpenRouter-hosted decision"
238
- " model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
239
- " pick it from openrouter (direct) instead.\n" if owui and " 404" in why and " 402" not in why else
240
- " This endpoint has no Decisions API at its decisions URL in config/endpoints.json.\n"
241
- if " 404" in why and " 402" not in why else
242
- " The provider wants paid usage for this model; the bench uses free models only.\n"
243
- if " 402" in why else
244
- " The provider refused the call; try again once its limit resets.\n")
245
- sys.exit(f" STOPPING: a decision model, but {endpoint}'s Decisions API did not answer:\n"
246
- f" {why}\n{hint}"
247
- " Not registered as a chat model, because every chat call to it would fail.")
248
-
249
- # 2/3. register
250
- if already_registered(name, endpoint):
251
- print(" [2/5] already registered — leaving the yaml alone")
252
- else:
253
- register(alias, name, endpoint)
254
- print(f" [2/5] registered as {alias}")
255
- run("scripts/sync_config.py", timeout=300)
256
- # Open WebUI's tags name the connection behind the hub; without a hub the
257
- # endpoint is the connection, and route.connection_of needs no tags.
258
- if endpoints.find_hub("openwebui"):
259
- run("scripts/provider_tags.py", timeout=300)
260
- import route
261
- connection = route.full_tag_of(alias)
262
- # Its details (context, output limit, tools, vision) now, not at the
263
- # catalog's next scheduled run: the catalog knows the connection serving
264
- # it, so it reads that provider's models.dev entry. Here rather than at the
265
- # end, so a model whose probe fails below has them too.
266
- catalog = run("metrics/catalog.py", timeout=300)
267
- details = ("details from the catalog" if catalog.returncode == 0
268
- else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
269
- print(f" [3/5] synced; connection is {connection}; {details}")
270
-
271
- # 4. availability
272
- print(" [4/5] probing availability (4 calls)...")
273
- probe = run("perf/run.py", "--model", alias, "-n", "2", "--timeout", "90",
274
- timeout=1200)
275
- answered = probe_answered(probe.stdout, alias)
276
- for line in probe.stdout.splitlines():
277
- if alias in line and ("tiny" in line or "large" in line):
278
- print(f" {line.strip()}")
279
- if not answered:
280
- print("\n STOPPING: it answered nothing. Registered so availability "
281
- "keeps watching it,\n but there is no point spending suite "
282
- "calls on a model that cannot reply.")
283
- return
284
-
285
- # 5. the tier that decides routing
286
- if args.skip_suite:
287
- print(" [5/5] suite skipped by request")
288
- else:
289
- print(" [5/5] running the internal suite...")
290
- try:
291
- suite = run("suite/run.py", "--model", alias, "--timeout", "300",
292
- timeout=3600)
293
- except subprocess.TimeoutExpired:
294
- # A slow model can need more than the hour for 52 tasks. Crashing
295
- # here lost the onboarding note of a model that is on the bench
296
- # and answering (step-5-preview, 2026-10-22); the answers saved so
297
- # far stay, and the scheduled passes (--next) ask only its gaps.
298
- suite = None
299
- print(" stopped after an hour; the scheduled suite passes fill in the rest")
300
- for line in (suite.stdout if suite else "").splitlines():
301
- if alias in line or line.startswith("## "):
302
- print(f" {line.strip()[:110]}")
303
-
304
- print("\nWhere it lands:")
305
- panel = run("metrics/route.py", timeout=300)
306
- for line in panel.stdout.splitlines()[:8]:
307
- print(f" {line}")
308
-
309
- stamp = dt.date.today().isoformat()
310
- note = DATA / "runs" / f"onboard-{stamp}.md"
311
- with note.open("a", encoding="utf-8") as fh:
312
- fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
313
- f"Connection: {connection}. "
314
- f"Availability probe: {'answered' if answered else 'NO ANSWER'}.\n\n"
315
- f"Suite scores are in the dated suite note. Whether to route "
316
- f"work here is a judgement, not an output of this script.\n")
317
- print(f"\nwrote {note}")
318
-
319
-
320
- def _selftest() -> None:
321
- row = "m1 tiny 2.1 3.0 40.0 {}a/0r/{}e/0t"
322
- assert probe_answered(row.format(2, 0), "m1")
323
- assert not probe_answered(row.format(0, 2), "m1") # nothing answered
324
- assert not probe_answered("Traceback (most recent call last):\n ...", "m1") # crashed
325
- assert probe_answered(row.format(10, 0), "m1") # "10a/" holds "0a/"
326
- assert not probe_answered(row.format(2, 0), "m") # another model's row
327
- print("ok")
328
-
329
-
330
- if __name__ == "__main__":
331
- _selftest() if "--selftest" in sys.argv else main()
1
+ """Take a model from "served by an endpoint" to "usable, with evidence".
2
+
3
+ python scripts/onboard.py "vendor/new-model:free"
4
+ python scripts/onboard.py "vendor/new-model:free" --skip-suite
5
+
6
+ Models arrive every few days. Without a process each one is registered by hand,
7
+ half-tested, and then either trusted on no evidence or forgotten — and the
8
+ router cannot recommend what it has no rows for. This does the whole sequence
9
+ in one pass and writes down what it found.
10
+
11
+ WHAT IT DOES, AND WHY IN THIS ORDER
12
+ -----------------------------------
13
+ 1. **Confirm the endpoint serves it.** Fails immediately if not, because every
14
+ later step would otherwise produce a confusing failure instead of a clear
15
+ one. `gpt-oss-120b` was registered under a name the endpoint never served
16
+ and spent a whole 16-model suite run scoring 0/11 before anyone noticed.
17
+ 2. **Derive the alias** by the standing rule: drop the vendor prefix (the
18
+ connection tag already carries it), turn `:` into `-`. So
19
+ `poolside/laguna-s-2.1:free` becomes `laguna-s-2.1-free`. Version numbers
20
+ stay, because there will be a 2.2.
21
+ 3. **Register and sync**, then, with an Open WebUI hub, refresh the provider
22
+ tags so the connection behind it is known — which is what the router uses
23
+ to keep a panel independent. Without one the endpoint is the connection.
24
+ Then refresh the catalog, so its details (context, tools, vision) show now.
25
+ 4. **Probe availability** before spending anything: two prompt sizes, two
26
+ samples. A model that cannot answer "ready" will not answer a 46 KB task,
27
+ and finding that out here costs four calls instead of six large ones.
28
+ 5. **Run the internal suite**, the only tier entitled to rank. Not the floor
29
+ tier: 164 calls for a contaminated number that cannot rank anyway.
30
+
31
+ WHAT IT DELIBERATELY DOES NOT DO
32
+ --------------------------------
33
+ Judge. It reports what happened and where the model lands; whether to route
34
+ work there is a decision with a person in it.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import csv
41
+ import datetime as dt
42
+ import json
43
+ import os
44
+ import pathlib
45
+ import re
46
+ import subprocess
47
+ import sys
48
+ import urllib.request
49
+
50
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
51
+ sys.path.insert(0, str(ROOT / "metrics"))
52
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
53
+ YAML = DATA / "config" / "extra-openai-models.yaml"
54
+ sys.path.insert(0, str(ROOT / "metrics"))
55
+ sys.path.insert(0, str(ROOT / "suite"))
56
+ import endpoints # noqa: E402
57
+ import decide # noqa: E402
58
+ import locked # noqa: E402
59
+ from run import bench_python # noqa: E402
60
+
61
+ PY = bench_python()
62
+
63
+
64
+ def alias_for(served_name: str, taken: dict[str, str] | None = None,
65
+ strip_prefix: str = "") -> str:
66
+ """The standing naming rule, plus disambiguation when it collides.
67
+
68
+ `codestral-latest` is served by BOTH mistral and llm7-api, and the rule
69
+ alone gives them the same alias — which would silently overwrite one
70
+ registration with the other and mix two different endpoints' results into
71
+ one row. When the name is already taken by a DIFFERENT served model, the
72
+ connection is appended: `codestral-latest-llm7-api`.
73
+
74
+ `strip_prefix` removes an Open WebUI connection prefix before the rule is
75
+ applied, so `llm7.codestral-latest` is recognised as the same MODEL as
76
+ mistral's and gets the collision suffix, rather than sailing past the check
77
+ as a unique name and landing as the alias `llm7.codestral-latest`. It is
78
+ explicit rather than sniffed, because a leading dotted segment is not
79
+ reliably a prefix — `gpt-3.5-turbo` would lose its version to a guess.
80
+ """
81
+ if strip_prefix and served_name.startswith(strip_prefix):
82
+ served_name_stripped = served_name[len(strip_prefix):]
83
+ else:
84
+ served_name_stripped = served_name
85
+ base = served_name_stripped.split("/")[-1].replace(":", "-")
86
+ if not taken or taken.get(base) in (None, served_name):
87
+ return base
88
+ tags = locked.read_json(DATA / "config" / "provider-tags.json") # none without Open WebUI
89
+ tag = (tags.get(served_name) or "alt").split("/")[0]
90
+ return f"{base}-{tag}"
91
+
92
+
93
+ def existing_aliases(include_retired: bool = True) -> dict[str, str]:
94
+ """alias -> the served name it is already pointing at.
95
+
96
+ Retired aliases count as taken: a retired name is never reused, because
97
+ the run notes and perf history still say it and would merge two models.
98
+ """
99
+ retired = json.loads((DATA / "config" / "retired.json").read_text(encoding="utf-8")
100
+ or "{}") if include_retired and (DATA / "config" / "retired.json").exists() else {}
101
+ out = {a: r.get("served", "") for a, r in retired.items()}
102
+ out.update({a: d["served"] for a, d in decide.load().items()})
103
+ out.update({a: r.get("model_name", "") for a, r in endpoints.registrations().items()})
104
+ return out
105
+
106
+
107
+ def served_models(endpoint: str | None = None) -> dict[str, dict]:
108
+ """served id -> its /models entry, from any endpoint in config/endpoints.json."""
109
+ return endpoints.models(endpoint)
110
+
111
+
112
+ def connection_prefix(entry: dict) -> str:
113
+ """The prefix Open WebUI put in front of the upstream id, or "".
114
+
115
+ Open WebUI keeps the upstream's own id at `openai.openai.id`, so
116
+ `llm7.codestral-latest` carries `codestral-latest` and the prefix is the
117
+ difference -- read, not typed by hand, and not guessed from a dot
118
+ (`gpt-3.5-turbo` has one and no prefix). A plain OpenAI-compatible endpoint
119
+ has no such field and adds no prefix, so "" is right there too.
120
+ """
121
+ served = entry.get("id", "")
122
+ upstream = ((entry.get("openai") or {}).get("openai") or {}).get("id", "")
123
+ if upstream and served != upstream and served.endswith(upstream):
124
+ return served[:-len(upstream)]
125
+ return ""
126
+
127
+
128
+ def already_registered(served_name: str, endpoint: str) -> str | None:
129
+ # Exact match against the parsed model_name VALUES, not a substring check
130
+ # against the raw text. A substring check false-positives whenever one
131
+ # served name is a prefix of another already registered -- `mercury-2`
132
+ # against a file that already holds `model_name: mercury-2.5` reads as
133
+ # "already registered" and silently skips writing the real entry, which
134
+ # then fails every later step for a reason that looks like the model
135
+ # being broken instead of never having been registered at all.
136
+ # Per endpoint: the same id on a second endpoint is a second model.
137
+ return served_name if any(r["served"] == served_name and r["endpoint"] == endpoint
138
+ for r in endpoints.registry()) else None
139
+
140
+
141
+ def register(alias: str, served_name: str, endpoint: str | None = None) -> None:
142
+ with YAML.open("a", encoding="utf-8") as fh:
143
+ fh.write(endpoints.yaml_block(alias, served_name, endpoint))
144
+
145
+
146
+ def probe_answered(stdout: str, alias: str) -> bool:
147
+ """Did perf/run.py print a row for this model with at least one answer?
148
+ Looking for "0a/" counted a probe that crashed (no rows at all) as
149
+ answered, and would have read "10a/" as nothing answered."""
150
+ rows = (re.search(r"\b(\d+)a/\d+r/", line) for line in stdout.splitlines()
151
+ if line.split()[:1] == [alias])
152
+ return any(m and int(m.group(1)) > 0 for m in rows)
153
+
154
+
155
+ ISSUES = DATA / "metrics" / "onboard-issues.json" # serve.onboard_issues reads it
156
+
157
+
158
+ def probe_failures(alias: str, since: str) -> list[dict]:
159
+ """The probe's failed rows for this model in perf/history.csv, written
160
+ at or after `since`: what the log used to call only "error"."""
161
+ path = DATA / "perf" / "history.csv"
162
+ if not path.exists():
163
+ return []
164
+ with path.open(encoding="utf-8", newline="") as fh:
165
+ return [r for r in csv.DictReader(fh) if r.get("model") == alias
166
+ and r.get("timestamp", "") >= since and int(r.get("answered") or 0) == 0]
167
+
168
+
169
+ def record_issue(alias: str, issue: dict | None) -> None:
170
+ """Note how this model's onboarding stopped, or (None) that it did not."""
171
+ locked.update(ISSUES, lambda d: d.__setitem__(alias, issue) if issue else d.pop(alias, None),
172
+ ensure_ascii=False)
173
+
174
+
175
+ IMAGE_NAMES = re.compile(r"(^|[/._-])(gpt-image|dall-e|imagen|flux|stable-diffusion|sdxl|sd3)", re.I)
176
+
177
+
178
+ def makes_images(alias: str, served_name: str) -> bool:
179
+ """An image generator: the catalog lists image among its outputs
180
+ (gpt-image-1.5 lists text too, a revised prompt), else, when the catalog
181
+ does not say, its name is an image family's. Such a model answers a chat
182
+ prompt with a picture or an error, so it must never fill a seat."""
183
+ import catalog
184
+ out = catalog.info(alias).get("output_modalities")
185
+ return "image" in out if out else bool(IMAGE_NAMES.search(served_name))
186
+
187
+
188
+ def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
189
+ return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
190
+ capture_output=True, text=True, encoding="utf-8",
191
+ errors="replace")
192
+
193
+
194
+ def onboard_decision(name: str, endpoint: str, via: str, alias: str, path: str = "decisions") -> None:
195
+ """Record a decision model: where its Decisions API answers and which
196
+ question types it takes. Same five step markers, so the dashboard modal
197
+ follows it like any other onboarding."""
198
+ print(f" [2/5] decision model: answers through {via}'s Decisions API, not chat")
199
+ types = decide.question_types(via, name, path)
200
+ print(f" [3/5] question types: {', '.join(types)}")
201
+ data = decide.load()
202
+ data[alias] = {"served": name, "endpoint": endpoint, "via": via, "path": path,
203
+ "question_types": types,
204
+ "onboarded": dt.date.today().isoformat()}
205
+ decide.FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
206
+ locked.update(DATA / "config" / "model-roles.json",
207
+ lambda roles: roles.setdefault("models", {}).__setitem__(alias, "decision"), ensure_ascii=False)
208
+ print(f" [4/5] recorded as {alias} in config/decision-models.json, role decision")
209
+ print(" [5/5] no chat probe or suite: they cannot test a decision model."
210
+ f" Ask it with: python metrics/decide.py {alias} \"<text>\" --noul \"<question>\"")
211
+ stamp = dt.date.today().isoformat()
212
+ note = DATA / "runs" / f"onboard-{stamp}.md"
213
+ with note.open("a", encoding="utf-8") as fh:
214
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
215
+ f"Decision model, found on {endpoint}, answering through {via}'s Decisions API."
216
+ f" Question types: {', '.join(types)}. Not in the llm"
217
+ f" yaml and never a panel seat; its test is agreement with known verdicts.\n")
218
+ print(f"\nwrote {note}")
219
+
220
+
221
+ def main() -> None:
222
+ # route.py's panel contains em dashes, and this script inherits Windows'
223
+ # cp1252 stdout when run from a scheduled task or a plain console. Without
224
+ # this the run completes, prints most of its report, and then dies on the
225
+ # last few lines BEFORE writing the onboarding note -- losing the record of
226
+ # a run that actually succeeded.
227
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
228
+
229
+ ap = argparse.ArgumentParser(description=__doc__)
230
+ ap.add_argument("served_name", help="the id the endpoint serves it under")
231
+ ap.add_argument("--endpoint", default=None,
232
+ help="a name from config/endpoints.json; the default endpoint if omitted")
233
+ ap.add_argument("--skip-suite", action="store_true",
234
+ help="register and probe only; the scheduled suite passes fill the suite in later")
235
+ ap.add_argument("--ad-hoc", action="store_true",
236
+ help="mark it ad hoc as it is registered: the bench never calls it on its own"
237
+ " (for a metered, capped or trial key)")
238
+ args = ap.parse_args()
239
+
240
+ name = args.served_name
241
+ endpoint = args.endpoint or endpoints.default()
242
+ # 1. served?
243
+ try:
244
+ live = served_models(endpoint)
245
+ except Exception as exc: # noqa: BLE001
246
+ sys.exit(f"cannot reach the {endpoint} endpoint: {exc}")
247
+ alias = alias_for(name, existing_aliases(),
248
+ connection_prefix(live.get(name, {})))
249
+ print(f"onboarding {name} from {endpoint} -> alias {alias}\n")
250
+ if name not in live:
251
+ near = [s for s in live if alias.split("-")[0] in s][:5]
252
+ sys.exit(f"{name} is not served.\n"
253
+ f" Did you mean: {', '.join(near) if near else '(nothing similar)'}\n"
254
+ f" Add it to {endpoint} first — registering a name the "
255
+ f"endpoint does not serve is how a model scores 0/11 for a "
256
+ f"fortnight before anyone looks.")
257
+ print(" [1/5] served by the endpoint")
258
+
259
+ # A decision model refuses chat, so registering it in the llm yaml, the
260
+ # chat probe and the suite would only record failures against it. Some
261
+ # listings say so (Inception's: supported_endpoints ["decisions"]); others
262
+ # (OpenRouter's, via Open WebUI) say nothing, and then the chat refusal does.
263
+ listed = live[name].get("supported_endpoints") or []
264
+ decision_only = "decisions" in listed and "chat.completions" not in listed
265
+ if decision_only or (not listed and decide.refuses_chat(endpoint, name)):
266
+ # Called on the endpoint it was picked from, like any model.
267
+ why, path = decide.probe(endpoint, name)
268
+ if why is None:
269
+ onboard_decision(name, endpoint, endpoint, alias, path)
270
+ return
271
+ # The passthrough hints are about Open WebUI; anywhere else a 404 is the provider's.
272
+ owui = endpoints.hub(endpoint) == "openwebui"
273
+ hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
274
+ " in its environment.\n" if owui and "passthrough" in why.lower() else
275
+ " Its connection has no /decisions under its base. An OpenRouter-hosted decision"
276
+ " model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
277
+ " pick it from openrouter (direct) instead.\n" if owui and " 404" in why and " 402" not in why else
278
+ " This endpoint has no Decisions API at its decisions URL in config/endpoints.json.\n"
279
+ if " 404" in why and " 402" not in why else
280
+ " The provider wants paid usage for this model; the bench uses free models only.\n"
281
+ if " 402" in why else
282
+ " The provider refused the call; try again once its limit resets.\n")
283
+ sys.exit(f" STOPPING: a decision model, but {endpoint}'s Decisions API did not answer:\n"
284
+ f" {why}\n{hint}"
285
+ " Not registered as a chat model, because every chat call to it would fail.")
286
+
287
+ # 2/3. register
288
+ if already_registered(name, endpoint):
289
+ print(" [2/5] already registered — leaving the yaml alone")
290
+ else:
291
+ register(alias, name, endpoint)
292
+ print(f" [2/5] registered as {alias}")
293
+ if args.ad_hoc:
294
+ # Before the probe, so no scheduled run can pick it up in between.
295
+ locked.update(DATA / "config" / "model-roles.json",
296
+ lambda roles: roles.__setitem__("ad_hoc", sorted({*roles.get("ad_hoc", []), alias})),
297
+ ensure_ascii=False)
298
+ print(" ad hoc: the bench never calls it on its own")
299
+ run("scripts/sync_config.py", timeout=300)
300
+ # Open WebUI's tags name the connection behind the hub; without a hub the
301
+ # endpoint is the connection, and route.connection_of needs no tags.
302
+ if endpoints.find_hub("openwebui"):
303
+ run("scripts/provider_tags.py", timeout=300)
304
+ import route
305
+ connection = route.full_tag_of(alias)
306
+ # Its details (context, output limit, tools, vision) now, not at the
307
+ # catalog's next scheduled run: the catalog knows the connection serving
308
+ # it, so it reads that provider's models.dev entry. Here rather than at the
309
+ # end, so a model whose probe fails below has them too.
310
+ catalog = run("metrics/catalog.py", timeout=300)
311
+ details = ("details from the catalog" if catalog.returncode == 0
312
+ else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
313
+ print(f" [3/5] synced; connection is {connection}; {details}")
314
+
315
+ # An image generator: role image, which no chat shape admits, so it never
316
+ # fills a seat or a collection; a chat probe and the suite would only
317
+ # record failures (and spend its price). A chat role set by hand wins:
318
+ # then it is onboarded as a chat model.
319
+ roles_file = DATA / "config" / "model-roles.json"
320
+ had = (locked.read_json(roles_file).get("models") or {}).get(alias)
321
+ if had in (None, "image") and makes_images(alias, name):
322
+ locked.update(roles_file, lambda roles: roles.setdefault("models", {}).__setitem__(alias, "image"),
323
+ ensure_ascii=False)
324
+ print(" [4/5] an image generator: role image, so it is never a seat;"
325
+ " change the role in its model popup if it also answers chat")
326
+ print(" [5/5] no chat probe or suite: they test text answers")
327
+ record_issue(alias, None)
328
+ stamp = dt.date.today().isoformat()
329
+ with (DATA / "runs" / f"onboard-{stamp}.md").open("a", encoding="utf-8") as fh:
330
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
331
+ f"Connection: {connection}. Image generator, role image:"
332
+ " never a seat, not probed, no suite.\n")
333
+ return
334
+
335
+ # 4. availability
336
+ print(" [4/5] probing availability (4 calls)...")
337
+ since = dt.datetime.now().isoformat(timespec="seconds")
338
+ probe = run("perf/run.py", "--model", alias, "-n", "2", "--timeout", "90",
339
+ timeout=1200)
340
+ answered = probe_answered(probe.stdout, alias)
341
+ for line in probe.stdout.splitlines():
342
+ if alias in line and ("tiny" in line or "large" in line):
343
+ print(f" {line.strip()}")
344
+ failed = [] if answered else probe_failures(alias, since)
345
+ for r in failed: # why, not only "error"
346
+ print(f" {r['prompt']}: {r.get('failure') or 'other'} — {(r.get('detail') or '').strip()[:240]}")
347
+ if not answered:
348
+ worst = failed[0] if failed else {}
349
+ record_issue(alias, {"at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
350
+ "served": name, "endpoint": endpoint, "step": "availability probe",
351
+ "failure": worst.get("failure") or "other",
352
+ "detail": (worst.get("detail") or probe.stdout.strip()[-400:]
353
+ or f"perf/run.py exited {probe.returncode}")[:1000],
354
+ "log": os.environ.get("BENCH_ONBOARD_LOG", "")})
355
+ print("\n STOPPING: it answered nothing. Registered so availability "
356
+ "keeps watching it,\n but there is no point spending suite "
357
+ "calls on a model that cannot reply.")
358
+ return
359
+ record_issue(alias, None)
360
+
361
+ # 5. the tier that decides routing
362
+ if args.skip_suite:
363
+ print(" [5/5] suite skipped by request")
364
+ else:
365
+ print(" [5/5] running the internal suite...")
366
+ try:
367
+ suite = run("suite/run.py", "--model", alias, "--timeout", "300",
368
+ timeout=3600)
369
+ except subprocess.TimeoutExpired:
370
+ # A slow model can need more than the hour for 52 tasks. Crashing
371
+ # here lost the onboarding note of a model that is on the bench
372
+ # and answering (step-5-preview, 2026-10-22); the answers saved so
373
+ # far stay, and the scheduled passes (--next) ask only its gaps.
374
+ suite = None
375
+ print(" stopped after an hour; the scheduled suite passes fill in the rest")
376
+ for line in (suite.stdout if suite else "").splitlines():
377
+ if alias in line or line.startswith("## "):
378
+ print(f" {line.strip()[:110]}")
379
+
380
+ print("\nWhere it lands:")
381
+ panel = run("metrics/route.py", timeout=300)
382
+ for line in panel.stdout.splitlines()[:8]:
383
+ print(f" {line}")
384
+
385
+ stamp = dt.date.today().isoformat()
386
+ note = DATA / "runs" / f"onboard-{stamp}.md"
387
+ with note.open("a", encoding="utf-8") as fh:
388
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
389
+ f"Connection: {connection}. "
390
+ f"Availability probe: {'answered' if answered else 'NO ANSWER'}.\n\n"
391
+ f"Suite scores are in the dated suite note. Whether to route "
392
+ f"work here is a judgement, not an output of this script.\n")
393
+ print(f"\nwrote {note}")
394
+
395
+
396
+ def _selftest() -> None:
397
+ row = "m1 tiny 2.1 3.0 40.0 {}a/0r/{}e/0t"
398
+ assert probe_answered(row.format(2, 0), "m1")
399
+ assert not probe_answered(row.format(0, 2), "m1") # nothing answered
400
+ assert not probe_answered("Traceback (most recent call last):\n ...", "m1") # crashed
401
+ assert probe_answered(row.format(10, 0), "m1") # "10a/" holds "0a/"
402
+ assert not probe_answered(row.format(2, 0), "m") # another model's row
403
+ # The name fallback, for a model the catalog says nothing about.
404
+ assert all(IMAGE_NAMES.search(s) for s in ("gpt-image-2.5-flare", "openai/dall-e-3", "black-forest-labs/flux.2-pro"))
405
+ assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
406
+ print("ok")
407
+
408
+
409
+ if __name__ == "__main__":
410
+ _selftest() if "--selftest" in sys.argv else main()