@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,286 +1,286 @@
1
- """Take a model from "added to Open WebUI" 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 refresh the provider tags so the connection is
22
- known — which is what the router uses to keep a panel independent.
23
- 4. **Probe availability** before spending anything: two prompt sizes, two
24
- samples. A model that cannot answer "ready" will not answer a 46 KB task,
25
- and finding that out here costs four calls instead of six large ones.
26
- 5. **Run the internal suite**, the only tier entitled to rank. Not the floor
27
- tier: 164 calls for a contaminated number that cannot rank anyway.
28
-
29
- WHAT IT DELIBERATELY DOES NOT DO
30
- --------------------------------
31
- Judge. It reports what happened and where the model lands; whether to route
32
- work there is a decision with a person in it.
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import argparse
38
- import datetime as dt
39
- import json
40
- import pathlib
41
- import subprocess
42
- import sys
43
- import urllib.request
44
-
45
- ROOT = pathlib.Path(__file__).resolve().parent.parent
46
- YAML = ROOT / "config" / "extra-openai-models.yaml"
47
- PY = ROOT / ".venv" / "Scripts" / "python.exe"
48
- sys.path.insert(0, str(ROOT / "metrics"))
49
- import endpoints # noqa: E402
50
- import decide # noqa: E402
51
-
52
-
53
- def alias_for(served_name: str, taken: dict[str, str] | None = None,
54
- strip_prefix: str = "") -> str:
55
- """The standing naming rule, plus disambiguation when it collides.
56
-
57
- `codestral-latest` is served by BOTH mistral and llm7-api, and the rule
58
- alone gives them the same alias — which would silently overwrite one
59
- registration with the other and mix two different endpoints' results into
60
- one row. When the name is already taken by a DIFFERENT served model, the
61
- connection is appended: `codestral-latest-llm7-api`.
62
-
63
- `strip_prefix` removes an Open WebUI connection prefix before the rule is
64
- applied, so `llm7.codestral-latest` is recognised as the same MODEL as
65
- mistral's and gets the collision suffix, rather than sailing past the check
66
- as a unique name and landing as the alias `llm7.codestral-latest`. It is
67
- explicit rather than sniffed, because a leading dotted segment is not
68
- reliably a prefix — `gpt-3.5-turbo` would lose its version to a guess.
69
- """
70
- if strip_prefix and served_name.startswith(strip_prefix):
71
- served_name_stripped = served_name[len(strip_prefix):]
72
- else:
73
- served_name_stripped = served_name
74
- base = served_name_stripped.split("/")[-1].replace(":", "-")
75
- if not taken or taken.get(base) in (None, served_name):
76
- return base
77
- tags = json.loads((ROOT / "config" / "provider-tags.json")
78
- .read_text(encoding="utf-8"))
79
- tag = (tags.get(served_name) or "alt").split("/")[0]
80
- return f"{base}-{tag}"
81
-
82
-
83
- def existing_aliases(include_retired: bool = True) -> dict[str, str]:
84
- """alias -> the served name it is already pointing at.
85
-
86
- Retired aliases count as taken: a retired name is never reused, because
87
- the run notes and perf history still say it and would merge two models.
88
- """
89
- import re
90
- retired = json.loads((ROOT / "config" / "retired.json").read_text(encoding="utf-8")
91
- or "{}") if include_retired and (ROOT / "config" / "retired.json").exists() else {}
92
- out, current = {a: r.get("served", "") for a, r in retired.items()}, None
93
- out.update({a: d["served"] for a, d in decide.load().items()})
94
- for line in YAML.read_text(encoding="utf-8").splitlines():
95
- if (m := re.match(r"\s*-?\s*model_id:\s*(.+)", line)):
96
- current = m.group(1).strip().strip("\"'")
97
- elif (m := re.match(r"\s*model_name:\s*(.+)", line)):
98
- out[current] = m.group(1).strip().strip("\"'")
99
- return out
100
-
101
-
102
- def served_models(endpoint: str | None = None) -> dict[str, dict]:
103
- """served id -> its /models entry, from any endpoint in config/endpoints.json."""
104
- return endpoints.models(endpoint)
105
-
106
-
107
- def connection_prefix(entry: dict) -> str:
108
- """The prefix Open WebUI put in front of the upstream id, or "".
109
-
110
- Open WebUI keeps the upstream's own id at `openai.openai.id`, so
111
- `llm7.codestral-latest` carries `codestral-latest` and the prefix is the
112
- difference -- read, not typed by hand, and not guessed from a dot
113
- (`gpt-3.5-turbo` has one and no prefix). A plain OpenAI-compatible endpoint
114
- has no such field and adds no prefix, so "" is right there too.
115
- """
116
- served = entry.get("id", "")
117
- upstream = ((entry.get("openai") or {}).get("openai") or {}).get("id", "")
118
- if upstream and served != upstream and served.endswith(upstream):
119
- return served[:-len(upstream)]
120
- return ""
121
-
122
-
123
- def already_registered(served_name: str, endpoint: str) -> str | None:
124
- # Exact match against the parsed model_name VALUES, not a substring check
125
- # against the raw text. A substring check false-positives whenever one
126
- # served name is a prefix of another already registered -- `mercury-2`
127
- # against a file that already holds `model_name: mercury-2.5` reads as
128
- # "already registered" and silently skips writing the real entry, which
129
- # then fails every later step for a reason that looks like the model
130
- # being broken instead of never having been registered at all.
131
- # Per endpoint: the same id on a second endpoint is a second model.
132
- return served_name if any(r["served"] == served_name and r["endpoint"] == endpoint
133
- for r in endpoints.registry()) else None
134
-
135
-
136
- def register(alias: str, served_name: str, endpoint: str | None = None) -> None:
137
- with YAML.open("a", encoding="utf-8") as fh:
138
- fh.write(endpoints.yaml_block(alias, served_name, endpoint))
139
-
140
-
141
- def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
142
- return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
143
- capture_output=True, text=True, encoding="utf-8",
144
- errors="replace")
145
-
146
-
147
- def onboard_decision(name: str, endpoint: str, via: str, alias: str) -> None:
148
- """Record a decision model: where its Decisions API answers and which
149
- question types it takes. Same five step markers, so the dashboard modal
150
- follows it like any other onboarding."""
151
- print(f" [2/5] decision model: answers through {via}'s Decisions API, not chat")
152
- types = decide.question_types(via, name)
153
- print(f" [3/5] question types: {', '.join(types)}")
154
- data = decide.load()
155
- data[alias] = {"served": name, "endpoint": endpoint, "via": via, "question_types": types,
156
- "onboarded": dt.date.today().isoformat()}
157
- decide.FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
158
- roles_path = ROOT / "config" / "model-roles.json"
159
- roles = json.loads(roles_path.read_text(encoding="utf-8"))
160
- roles.setdefault("models", {})[alias] = "decision"
161
- roles_path.write_text(json.dumps(roles, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
162
- print(f" [4/5] recorded as {alias} in config/decision-models.json, role decision")
163
- print(" [5/5] no chat probe or suite: they cannot test a decision model."
164
- f" Ask it with: python metrics/decide.py {alias} \"<text>\" --noul \"<question>\"")
165
- stamp = dt.date.today().isoformat()
166
- note = ROOT / "runs" / f"onboard-{stamp}.md"
167
- with note.open("a", encoding="utf-8") as fh:
168
- fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
169
- f"Decision model, found on {endpoint}, answering through {via}'s Decisions API."
170
- f" Question types: {', '.join(types)}. Not in the llm"
171
- f" yaml and never a panel seat; its test is agreement with known verdicts.\n")
172
- print(f"\nwrote {note}")
173
-
174
-
175
- def main() -> None:
176
- # route.py's panel contains em dashes, and this script inherits Windows'
177
- # cp1252 stdout when run from a scheduled task or a plain console. Without
178
- # this the run completes, prints most of its report, and then dies on the
179
- # last few lines BEFORE writing the onboarding note -- losing the record of
180
- # a run that actually succeeded.
181
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
182
-
183
- ap = argparse.ArgumentParser(description=__doc__)
184
- ap.add_argument("served_name", help="the id the endpoint serves it under")
185
- ap.add_argument("--endpoint", default=None,
186
- help="a name from config/endpoints.json; Open WebUI by default")
187
- ap.add_argument("--skip-suite", action="store_true",
188
- help="register and probe only")
189
- args = ap.parse_args()
190
-
191
- name = args.served_name
192
- endpoint = args.endpoint or endpoints.default()
193
- # 1. served?
194
- try:
195
- live = served_models(endpoint)
196
- except Exception as exc: # noqa: BLE001
197
- sys.exit(f"cannot reach the {endpoint} endpoint: {exc}")
198
- alias = alias_for(name, existing_aliases(),
199
- connection_prefix(live.get(name, {})))
200
- print(f"onboarding {name} from {endpoint} -> alias {alias}\n")
201
- if name not in live:
202
- near = [s for s in live if alias.split("-")[0] in s][:5]
203
- sys.exit(f"{name} is not served.\n"
204
- f" Did you mean: {', '.join(near) if near else '(nothing similar)'}\n"
205
- f" Add it to {endpoint} first — registering a name the "
206
- f"endpoint does not serve is how a model scores 0/11 for a "
207
- f"fortnight before anyone looks.")
208
- print(" [1/5] served by the endpoint")
209
-
210
- # A decision model refuses chat, so registering it in the llm yaml, the
211
- # chat probe and the suite would only record failures against it. Some
212
- # listings say so (Inception's: supported_endpoints ["decisions"]); others
213
- # (OpenRouter's, via Open WebUI) say nothing, and then the chat refusal does.
214
- listed = live[name].get("supported_endpoints") or []
215
- decision_only = "decisions" in listed and "chat.completions" not in listed
216
- if decision_only or (not listed and decide.refuses_chat(endpoint, name)):
217
- # Called on the endpoint it was picked from, like any model.
218
- why = decide.probe(endpoint, name)
219
- if why is None:
220
- onboard_decision(name, endpoint, endpoint, alias)
221
- return
222
- hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
223
- " in its environment.\n" if "passthrough" in why.lower() else
224
- " Its connection has no /decisions under its base. An OpenRouter-hosted decision"
225
- " model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
226
- " pick it from openrouter (direct) instead.\n" if " 404" in why else
227
- " The provider refused the call; try again once its limit resets.\n")
228
- sys.exit(f" STOPPING: a decision model, but {endpoint}'s Decisions API did not answer:\n"
229
- f" {why}\n{hint}"
230
- " Not registered as a chat model, because every chat call to it would fail.")
231
-
232
- # 2/3. register
233
- if already_registered(name, endpoint):
234
- print(" [2/5] already registered — leaving the yaml alone")
235
- else:
236
- register(alias, name, endpoint)
237
- print(f" [2/5] registered as {alias}")
238
- run("scripts/sync_config.py", timeout=300)
239
- run("scripts/provider_tags.py", timeout=300)
240
- tags = json.loads((ROOT / "config" / "provider-tags.json")
241
- .read_text(encoding="utf-8"))
242
- print(f" [3/5] synced; connection is {tags.get(name, '?')}")
243
-
244
- # 4. availability
245
- print(" [4/5] probing availability (4 calls)...")
246
- probe = run("perf/run.py", "--model", alias, "-n", "2", "--timeout", "90",
247
- timeout=1200)
248
- answered = "0a/" not in probe.stdout
249
- for line in probe.stdout.splitlines():
250
- if alias in line and ("tiny" in line or "large" in line):
251
- print(f" {line.strip()}")
252
- if not answered:
253
- print("\n STOPPING: it answered nothing. Registered so availability "
254
- "keeps watching it,\n but there is no point spending suite "
255
- "calls on a model that cannot reply.")
256
- return
257
-
258
- # 5. the tier that decides routing
259
- if args.skip_suite:
260
- print(" [5/5] suite skipped by request")
261
- else:
262
- print(" [5/5] running the internal suite...")
263
- suite = run("suite/run.py", "--model", alias, "--timeout", "300",
264
- timeout=3600)
265
- for line in suite.stdout.splitlines():
266
- if alias in line or line.startswith("## "):
267
- print(f" {line.strip()[:110]}")
268
-
269
- print("\nWhere it lands:")
270
- panel = run("metrics/route.py", timeout=300)
271
- for line in panel.stdout.splitlines()[:8]:
272
- print(f" {line}")
273
-
274
- stamp = dt.date.today().isoformat()
275
- note = ROOT / "runs" / f"onboard-{stamp}.md"
276
- with note.open("a", encoding="utf-8") as fh:
277
- fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
278
- f"Connection: {tags.get(name, '?')}. "
279
- f"Availability probe: {'answered' if answered else 'NO ANSWER'}.\n\n"
280
- f"Suite scores are in the dated suite note. Whether to route "
281
- f"work here is a judgement, not an output of this script.\n")
282
- print(f"\nwrote {note}")
283
-
284
-
285
- if __name__ == "__main__":
286
- main()
1
+ """Take a model from "added to Open WebUI" 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 refresh the provider tags so the connection is
22
+ known — which is what the router uses to keep a panel independent.
23
+ 4. **Probe availability** before spending anything: two prompt sizes, two
24
+ samples. A model that cannot answer "ready" will not answer a 46 KB task,
25
+ and finding that out here costs four calls instead of six large ones.
26
+ 5. **Run the internal suite**, the only tier entitled to rank. Not the floor
27
+ tier: 164 calls for a contaminated number that cannot rank anyway.
28
+
29
+ WHAT IT DELIBERATELY DOES NOT DO
30
+ --------------------------------
31
+ Judge. It reports what happened and where the model lands; whether to route
32
+ work there is a decision with a person in it.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import datetime as dt
39
+ import json
40
+ import pathlib
41
+ import subprocess
42
+ import sys
43
+ import urllib.request
44
+
45
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
46
+ YAML = ROOT / "config" / "extra-openai-models.yaml"
47
+ PY = ROOT / ".venv" / "Scripts" / "python.exe"
48
+ sys.path.insert(0, str(ROOT / "metrics"))
49
+ import endpoints # noqa: E402
50
+ import decide # noqa: E402
51
+
52
+
53
+ def alias_for(served_name: str, taken: dict[str, str] | None = None,
54
+ strip_prefix: str = "") -> str:
55
+ """The standing naming rule, plus disambiguation when it collides.
56
+
57
+ `codestral-latest` is served by BOTH mistral and llm7-api, and the rule
58
+ alone gives them the same alias — which would silently overwrite one
59
+ registration with the other and mix two different endpoints' results into
60
+ one row. When the name is already taken by a DIFFERENT served model, the
61
+ connection is appended: `codestral-latest-llm7-api`.
62
+
63
+ `strip_prefix` removes an Open WebUI connection prefix before the rule is
64
+ applied, so `llm7.codestral-latest` is recognised as the same MODEL as
65
+ mistral's and gets the collision suffix, rather than sailing past the check
66
+ as a unique name and landing as the alias `llm7.codestral-latest`. It is
67
+ explicit rather than sniffed, because a leading dotted segment is not
68
+ reliably a prefix — `gpt-3.5-turbo` would lose its version to a guess.
69
+ """
70
+ if strip_prefix and served_name.startswith(strip_prefix):
71
+ served_name_stripped = served_name[len(strip_prefix):]
72
+ else:
73
+ served_name_stripped = served_name
74
+ base = served_name_stripped.split("/")[-1].replace(":", "-")
75
+ if not taken or taken.get(base) in (None, served_name):
76
+ return base
77
+ tags = json.loads((ROOT / "config" / "provider-tags.json")
78
+ .read_text(encoding="utf-8"))
79
+ tag = (tags.get(served_name) or "alt").split("/")[0]
80
+ return f"{base}-{tag}"
81
+
82
+
83
+ def existing_aliases(include_retired: bool = True) -> dict[str, str]:
84
+ """alias -> the served name it is already pointing at.
85
+
86
+ Retired aliases count as taken: a retired name is never reused, because
87
+ the run notes and perf history still say it and would merge two models.
88
+ """
89
+ import re
90
+ retired = json.loads((ROOT / "config" / "retired.json").read_text(encoding="utf-8")
91
+ or "{}") if include_retired and (ROOT / "config" / "retired.json").exists() else {}
92
+ out, current = {a: r.get("served", "") for a, r in retired.items()}, None
93
+ out.update({a: d["served"] for a, d in decide.load().items()})
94
+ for line in YAML.read_text(encoding="utf-8").splitlines():
95
+ if (m := re.match(r"\s*-?\s*model_id:\s*(.+)", line)):
96
+ current = m.group(1).strip().strip("\"'")
97
+ elif (m := re.match(r"\s*model_name:\s*(.+)", line)):
98
+ out[current] = m.group(1).strip().strip("\"'")
99
+ return out
100
+
101
+
102
+ def served_models(endpoint: str | None = None) -> dict[str, dict]:
103
+ """served id -> its /models entry, from any endpoint in config/endpoints.json."""
104
+ return endpoints.models(endpoint)
105
+
106
+
107
+ def connection_prefix(entry: dict) -> str:
108
+ """The prefix Open WebUI put in front of the upstream id, or "".
109
+
110
+ Open WebUI keeps the upstream's own id at `openai.openai.id`, so
111
+ `llm7.codestral-latest` carries `codestral-latest` and the prefix is the
112
+ difference -- read, not typed by hand, and not guessed from a dot
113
+ (`gpt-3.5-turbo` has one and no prefix). A plain OpenAI-compatible endpoint
114
+ has no such field and adds no prefix, so "" is right there too.
115
+ """
116
+ served = entry.get("id", "")
117
+ upstream = ((entry.get("openai") or {}).get("openai") or {}).get("id", "")
118
+ if upstream and served != upstream and served.endswith(upstream):
119
+ return served[:-len(upstream)]
120
+ return ""
121
+
122
+
123
+ def already_registered(served_name: str, endpoint: str) -> str | None:
124
+ # Exact match against the parsed model_name VALUES, not a substring check
125
+ # against the raw text. A substring check false-positives whenever one
126
+ # served name is a prefix of another already registered -- `mercury-2`
127
+ # against a file that already holds `model_name: mercury-2.5` reads as
128
+ # "already registered" and silently skips writing the real entry, which
129
+ # then fails every later step for a reason that looks like the model
130
+ # being broken instead of never having been registered at all.
131
+ # Per endpoint: the same id on a second endpoint is a second model.
132
+ return served_name if any(r["served"] == served_name and r["endpoint"] == endpoint
133
+ for r in endpoints.registry()) else None
134
+
135
+
136
+ def register(alias: str, served_name: str, endpoint: str | None = None) -> None:
137
+ with YAML.open("a", encoding="utf-8") as fh:
138
+ fh.write(endpoints.yaml_block(alias, served_name, endpoint))
139
+
140
+
141
+ def run(*args: str, timeout: int = 3600) -> subprocess.CompletedProcess:
142
+ return subprocess.run([str(PY), *args], cwd=str(ROOT), timeout=timeout,
143
+ capture_output=True, text=True, encoding="utf-8",
144
+ errors="replace")
145
+
146
+
147
+ def onboard_decision(name: str, endpoint: str, via: str, alias: str) -> None:
148
+ """Record a decision model: where its Decisions API answers and which
149
+ question types it takes. Same five step markers, so the dashboard modal
150
+ follows it like any other onboarding."""
151
+ print(f" [2/5] decision model: answers through {via}'s Decisions API, not chat")
152
+ types = decide.question_types(via, name)
153
+ print(f" [3/5] question types: {', '.join(types)}")
154
+ data = decide.load()
155
+ data[alias] = {"served": name, "endpoint": endpoint, "via": via, "question_types": types,
156
+ "onboarded": dt.date.today().isoformat()}
157
+ decide.FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
158
+ roles_path = ROOT / "config" / "model-roles.json"
159
+ roles = json.loads(roles_path.read_text(encoding="utf-8"))
160
+ roles.setdefault("models", {})[alias] = "decision"
161
+ roles_path.write_text(json.dumps(roles, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
162
+ print(f" [4/5] recorded as {alias} in config/decision-models.json, role decision")
163
+ print(" [5/5] no chat probe or suite: they cannot test a decision model."
164
+ f" Ask it with: python metrics/decide.py {alias} \"<text>\" --noul \"<question>\"")
165
+ stamp = dt.date.today().isoformat()
166
+ note = ROOT / "runs" / f"onboard-{stamp}.md"
167
+ with note.open("a", encoding="utf-8") as fh:
168
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
169
+ f"Decision model, found on {endpoint}, answering through {via}'s Decisions API."
170
+ f" Question types: {', '.join(types)}. Not in the llm"
171
+ f" yaml and never a panel seat; its test is agreement with known verdicts.\n")
172
+ print(f"\nwrote {note}")
173
+
174
+
175
+ def main() -> None:
176
+ # route.py's panel contains em dashes, and this script inherits Windows'
177
+ # cp1252 stdout when run from a scheduled task or a plain console. Without
178
+ # this the run completes, prints most of its report, and then dies on the
179
+ # last few lines BEFORE writing the onboarding note -- losing the record of
180
+ # a run that actually succeeded.
181
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
182
+
183
+ ap = argparse.ArgumentParser(description=__doc__)
184
+ ap.add_argument("served_name", help="the id the endpoint serves it under")
185
+ ap.add_argument("--endpoint", default=None,
186
+ help="a name from config/endpoints.json; Open WebUI by default")
187
+ ap.add_argument("--skip-suite", action="store_true",
188
+ help="register and probe only")
189
+ args = ap.parse_args()
190
+
191
+ name = args.served_name
192
+ endpoint = args.endpoint or endpoints.default()
193
+ # 1. served?
194
+ try:
195
+ live = served_models(endpoint)
196
+ except Exception as exc: # noqa: BLE001
197
+ sys.exit(f"cannot reach the {endpoint} endpoint: {exc}")
198
+ alias = alias_for(name, existing_aliases(),
199
+ connection_prefix(live.get(name, {})))
200
+ print(f"onboarding {name} from {endpoint} -> alias {alias}\n")
201
+ if name not in live:
202
+ near = [s for s in live if alias.split("-")[0] in s][:5]
203
+ sys.exit(f"{name} is not served.\n"
204
+ f" Did you mean: {', '.join(near) if near else '(nothing similar)'}\n"
205
+ f" Add it to {endpoint} first — registering a name the "
206
+ f"endpoint does not serve is how a model scores 0/11 for a "
207
+ f"fortnight before anyone looks.")
208
+ print(" [1/5] served by the endpoint")
209
+
210
+ # A decision model refuses chat, so registering it in the llm yaml, the
211
+ # chat probe and the suite would only record failures against it. Some
212
+ # listings say so (Inception's: supported_endpoints ["decisions"]); others
213
+ # (OpenRouter's, via Open WebUI) say nothing, and then the chat refusal does.
214
+ listed = live[name].get("supported_endpoints") or []
215
+ decision_only = "decisions" in listed and "chat.completions" not in listed
216
+ if decision_only or (not listed and decide.refuses_chat(endpoint, name)):
217
+ # Called on the endpoint it was picked from, like any model.
218
+ why = decide.probe(endpoint, name)
219
+ if why is None:
220
+ onboard_decision(name, endpoint, endpoint, alias)
221
+ return
222
+ hint = (" Open WebUI relays decisions only with ENABLE_OPENAI_API_PASSTHROUGH=True"
223
+ " in its environment.\n" if "passthrough" in why.lower() else
224
+ " Its connection has no /decisions under its base. An OpenRouter-hosted decision"
225
+ " model cannot go through Open WebUI (its Decisions API is outside /api/v1);"
226
+ " pick it from openrouter (direct) instead.\n" if " 404" in why else
227
+ " The provider refused the call; try again once its limit resets.\n")
228
+ sys.exit(f" STOPPING: a decision model, but {endpoint}'s Decisions API did not answer:\n"
229
+ f" {why}\n{hint}"
230
+ " Not registered as a chat model, because every chat call to it would fail.")
231
+
232
+ # 2/3. register
233
+ if already_registered(name, endpoint):
234
+ print(" [2/5] already registered — leaving the yaml alone")
235
+ else:
236
+ register(alias, name, endpoint)
237
+ print(f" [2/5] registered as {alias}")
238
+ run("scripts/sync_config.py", timeout=300)
239
+ run("scripts/provider_tags.py", timeout=300)
240
+ tags = json.loads((ROOT / "config" / "provider-tags.json")
241
+ .read_text(encoding="utf-8"))
242
+ print(f" [3/5] synced; connection is {tags.get(name, '?')}")
243
+
244
+ # 4. availability
245
+ print(" [4/5] probing availability (4 calls)...")
246
+ probe = run("perf/run.py", "--model", alias, "-n", "2", "--timeout", "90",
247
+ timeout=1200)
248
+ answered = "0a/" not in probe.stdout
249
+ for line in probe.stdout.splitlines():
250
+ if alias in line and ("tiny" in line or "large" in line):
251
+ print(f" {line.strip()}")
252
+ if not answered:
253
+ print("\n STOPPING: it answered nothing. Registered so availability "
254
+ "keeps watching it,\n but there is no point spending suite "
255
+ "calls on a model that cannot reply.")
256
+ return
257
+
258
+ # 5. the tier that decides routing
259
+ if args.skip_suite:
260
+ print(" [5/5] suite skipped by request")
261
+ else:
262
+ print(" [5/5] running the internal suite...")
263
+ suite = run("suite/run.py", "--model", alias, "--timeout", "300",
264
+ timeout=3600)
265
+ for line in suite.stdout.splitlines():
266
+ if alias in line or line.startswith("## "):
267
+ print(f" {line.strip()[:110]}")
268
+
269
+ print("\nWhere it lands:")
270
+ panel = run("metrics/route.py", timeout=300)
271
+ for line in panel.stdout.splitlines()[:8]:
272
+ print(f" {line}")
273
+
274
+ stamp = dt.date.today().isoformat()
275
+ note = ROOT / "runs" / f"onboard-{stamp}.md"
276
+ with note.open("a", encoding="utf-8") as fh:
277
+ fh.write(f"\n## Onboarded `{alias}` ({name}) — {stamp}\n\n"
278
+ f"Connection: {tags.get(name, '?')}. "
279
+ f"Availability probe: {'answered' if answered else 'NO ANSWER'}.\n\n"
280
+ f"Suite scores are in the dated suite note. Whether to route "
281
+ f"work here is a judgement, not an output of this script.\n")
282
+ print(f"\nwrote {note}")
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()