@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.
- package/AGENTS.md +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -271
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -275
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -155
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -72
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -712
- package/launcher.py +120 -0
- package/mcp_server.py +266 -0
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +295 -295
- package/metrics/client.py +394 -373
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -574
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -64
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -104
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +401 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -0
- package/scripts/test_mcp.py +178 -0
- package/scripts/test_merged.py +214 -193
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -386
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3662
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1040
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -424
- package/ui/bench.js +1756 -1555
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- 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()
|