@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,331 +1,401 @@
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")
235
+ args = ap.parse_args()
236
+
237
+ name = args.served_name
238
+ endpoint = args.endpoint or endpoints.default()
239
+ # 1. served?
240
+ try:
241
+ live = served_models(endpoint)
242
+ except Exception as exc: # noqa: BLE001
243
+ sys.exit(f"cannot reach the {endpoint} endpoint: {exc}")
244
+ alias = alias_for(name, existing_aliases(),
245
+ connection_prefix(live.get(name, {})))
246
+ print(f"onboarding {name} from {endpoint} -> alias {alias}\n")
247
+ if name not in live:
248
+ near = [s for s in live if alias.split("-")[0] in s][:5]
249
+ sys.exit(f"{name} is not served.\n"
250
+ f" Did you mean: {', '.join(near) if near else '(nothing similar)'}\n"
251
+ f" Add it to {endpoint} first — registering a name the "
252
+ f"endpoint does not serve is how a model scores 0/11 for a "
253
+ f"fortnight before anyone looks.")
254
+ print(" [1/5] served by the endpoint")
255
+
256
+ # A decision model refuses chat, so registering it in the llm yaml, the
257
+ # chat probe and the suite would only record failures against it. Some
258
+ # listings say so (Inception's: supported_endpoints ["decisions"]); others
259
+ # (OpenRouter's, via Open WebUI) say nothing, and then the chat refusal does.
260
+ listed = live[name].get("supported_endpoints") or []
261
+ decision_only = "decisions" in listed and "chat.completions" not in listed
262
+ if decision_only or (not listed and decide.refuses_chat(endpoint, name)):
263
+ # Called on the endpoint it was picked from, like any model.
264
+ why, path = decide.probe(endpoint, name)
265
+ if why is None:
266
+ onboard_decision(name, endpoint, endpoint, alias, path)
267
+ return
268
+ # The passthrough hints are about Open WebUI; anywhere else a 404 is the provider's.
269
+ owui = endpoints.hub(endpoint) == "openwebui"
270
+ hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
271
+ " in its environment.\n" if owui and "passthrough" in why.lower() else
272
+ " Its connection has no /decisions under its base. An OpenRouter-hosted decision"
273
+ " model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
274
+ " pick it from openrouter (direct) instead.\n" if owui and " 404" in why and " 402" not in why else
275
+ " This endpoint has no Decisions API at its decisions URL in config/endpoints.json.\n"
276
+ if " 404" in why and " 402" not in why else
277
+ " The provider wants paid usage for this model; the bench uses free models only.\n"
278
+ if " 402" in why else
279
+ " The provider refused the call; try again once its limit resets.\n")
280
+ sys.exit(f" STOPPING: a decision model, but {endpoint}'s Decisions API did not answer:\n"
281
+ f" {why}\n{hint}"
282
+ " Not registered as a chat model, because every chat call to it would fail.")
283
+
284
+ # 2/3. register
285
+ if already_registered(name, endpoint):
286
+ print(" [2/5] already registered — leaving the yaml alone")
287
+ else:
288
+ register(alias, name, endpoint)
289
+ print(f" [2/5] registered as {alias}")
290
+ run("scripts/sync_config.py", timeout=300)
291
+ # Open WebUI's tags name the connection behind the hub; without a hub the
292
+ # endpoint is the connection, and route.connection_of needs no tags.
293
+ if endpoints.find_hub("openwebui"):
294
+ run("scripts/provider_tags.py", timeout=300)
295
+ import route
296
+ connection = route.full_tag_of(alias)
297
+ # Its details (context, output limit, tools, vision) now, not at the
298
+ # catalog's next scheduled run: the catalog knows the connection serving
299
+ # it, so it reads that provider's models.dev entry. Here rather than at the
300
+ # end, so a model whose probe fails below has them too.
301
+ catalog = run("metrics/catalog.py", timeout=300)
302
+ details = ("details from the catalog" if catalog.returncode == 0
303
+ else f"catalog refresh failed (exit {catalog.returncode}); details at its next run")
304
+ print(f" [3/5] synced; connection is {connection}; {details}")
305
+
306
+ # An image generator: role image, which no chat shape admits, so it never
307
+ # fills a seat or a collection; a chat probe and the suite would only
308
+ # record failures (and spend its price). A chat role set by hand wins:
309
+ # then it is onboarded as a chat model.
310
+ roles_file = DATA / "config" / "model-roles.json"
311
+ had = (locked.read_json(roles_file).get("models") or {}).get(alias)
312
+ if had in (None, "image") and makes_images(alias, name):
313
+ locked.update(roles_file, lambda roles: roles.setdefault("models", {}).__setitem__(alias, "image"),
314
+ ensure_ascii=False)
315
+ print(" [4/5] an image generator: role image, so it is never a seat;"
316
+ " change the role in its model popup if it also answers chat")
317
+ print(" [5/5] no chat probe or suite: they test text answers")
318
+ record_issue(alias, None)
319
+ stamp = dt.date.today().isoformat()
320
+ with (DATA / "runs" / f"onboard-{stamp}.md").open("a", encoding="utf-8") as fh:
321
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
322
+ f"Connection: {connection}. Image generator, role image:"
323
+ " never a seat, not probed, no suite.\n")
324
+ return
325
+
326
+ # 4. availability
327
+ print(" [4/5] probing availability (4 calls)...")
328
+ since = dt.datetime.now().isoformat(timespec="seconds")
329
+ probe = run("perf/run.py", "--model", alias, "-n", "2", "--timeout", "90",
330
+ timeout=1200)
331
+ answered = probe_answered(probe.stdout, alias)
332
+ for line in probe.stdout.splitlines():
333
+ if alias in line and ("tiny" in line or "large" in line):
334
+ print(f" {line.strip()}")
335
+ failed = [] if answered else probe_failures(alias, since)
336
+ for r in failed: # why, not only "error"
337
+ print(f" {r['prompt']}: {r.get('failure') or 'other'} — {(r.get('detail') or '').strip()[:240]}")
338
+ if not answered:
339
+ worst = failed[0] if failed else {}
340
+ record_issue(alias, {"at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
341
+ "served": name, "endpoint": endpoint, "step": "availability probe",
342
+ "failure": worst.get("failure") or "other",
343
+ "detail": (worst.get("detail") or probe.stdout.strip()[-400:]
344
+ or f"perf/run.py exited {probe.returncode}")[:1000],
345
+ "log": os.environ.get("BENCH_ONBOARD_LOG", "")})
346
+ print("\n STOPPING: it answered nothing. Registered so availability "
347
+ "keeps watching it,\n but there is no point spending suite "
348
+ "calls on a model that cannot reply.")
349
+ return
350
+ record_issue(alias, None)
351
+
352
+ # 5. the tier that decides routing
353
+ if args.skip_suite:
354
+ print(" [5/5] suite skipped by request")
355
+ else:
356
+ print(" [5/5] running the internal suite...")
357
+ try:
358
+ suite = run("suite/run.py", "--model", alias, "--timeout", "300",
359
+ timeout=3600)
360
+ except subprocess.TimeoutExpired:
361
+ # A slow model can need more than the hour for 52 tasks. Crashing
362
+ # here lost the onboarding note of a model that is on the bench
363
+ # and answering (step-5-preview, 2026-10-22); the answers saved so
364
+ # far stay, and the scheduled passes (--next) ask only its gaps.
365
+ suite = None
366
+ print(" stopped after an hour; the scheduled suite passes fill in the rest")
367
+ for line in (suite.stdout if suite else "").splitlines():
368
+ if alias in line or line.startswith("## "):
369
+ print(f" {line.strip()[:110]}")
370
+
371
+ print("\nWhere it lands:")
372
+ panel = run("metrics/route.py", timeout=300)
373
+ for line in panel.stdout.splitlines()[:8]:
374
+ print(f" {line}")
375
+
376
+ stamp = dt.date.today().isoformat()
377
+ note = DATA / "runs" / f"onboard-{stamp}.md"
378
+ with note.open("a", encoding="utf-8") as fh:
379
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
380
+ f"Connection: {connection}. "
381
+ f"Availability probe: {'answered' if answered else 'NO ANSWER'}.\n\n"
382
+ f"Suite scores are in the dated suite note. Whether to route "
383
+ f"work here is a judgement, not an output of this script.\n")
384
+ print(f"\nwrote {note}")
385
+
386
+
387
+ def _selftest() -> None:
388
+ row = "m1 tiny 2.1 3.0 40.0 {}a/0r/{}e/0t"
389
+ assert probe_answered(row.format(2, 0), "m1")
390
+ assert not probe_answered(row.format(0, 2), "m1") # nothing answered
391
+ assert not probe_answered("Traceback (most recent call last):\n ...", "m1") # crashed
392
+ assert probe_answered(row.format(10, 0), "m1") # "10a/" holds "0a/"
393
+ assert not probe_answered(row.format(2, 0), "m") # another model's row
394
+ # The name fallback, for a model the catalog says nothing about.
395
+ assert all(IMAGE_NAMES.search(s) for s in ("gpt-image-2.5-flare", "openai/dall-e-3", "black-forest-labs/flux.2-pro"))
396
+ assert not any(IMAGE_NAMES.search(s) for s in ("gpt-5.2", "reflux-7b", "gemini-3-pro"))
397
+ print("ok")
398
+
399
+
400
+ if __name__ == "__main__":
401
+ _selftest() if "--selftest" in sys.argv else main()