@wendellresearch-ai/benchwarmers 0.2.0 → 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 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- 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 -394
- 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 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -106
- 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 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- 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 -425
- package/ui/bench.js +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/scripts/onboard.py
CHANGED
|
@@ -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
|
|
41
|
-
import
|
|
42
|
-
import
|
|
43
|
-
import
|
|
44
|
-
import
|
|
45
|
-
import
|
|
46
|
-
import
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
import
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
served_name_stripped = served_name
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
out
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
Open WebUI
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
#
|
|
130
|
-
# against
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
locked.update(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
f"
|
|
216
|
-
f"
|
|
217
|
-
f"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
print(" [
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
print("
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
print("
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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()
|