@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -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 +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -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 +410 -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 +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- 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 +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -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 +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/route.py
CHANGED
|
@@ -1,579 +1,581 @@
|
|
|
1
|
-
"""Who to ask, and who to ask as well — a panel, not a winner.
|
|
2
|
-
|
|
3
|
-
python metrics/route.py # today's panel (shape: general)
|
|
4
|
-
python metrics/route.py --shape review # code review: code models eligible too
|
|
5
|
-
python metrics/route.py --shape design # ADR / process / prose: general models only
|
|
6
|
-
|
|
7
|
-
A MODEL HAS A ROLE, A TASK HAS A SHAPE
|
|
8
|
-
--------------------------------------
|
|
9
|
-
Not every model is the same kind of thing. `codestral` and `north-mini-code`
|
|
10
|
-
are code-completion models: sharp on "list every place X happens" and "is
|
|
11
|
-
this function wrong", and they answer a design or process question with
|
|
12
|
-
boilerplate — one gave six generic findings on a packet of ADR text and none
|
|
13
|
-
survived. `inkling` and `kimi` are reasoning models that can do all of it.
|
|
14
|
-
Seating them identically wastes the explorer's call and, worse, records a
|
|
15
|
-
miss against a model that was never the right tool.
|
|
16
|
-
|
|
17
|
-
So `config/model-roles.json` gives each alias a ROLE (general, code, image)
|
|
18
|
-
and each task SHAPE a list of roles it accepts. `--shape` picks the shape;
|
|
19
|
-
with none given the task is `general`, which admits general models only.
|
|
20
|
-
Pass `--shape review`, `code` or `enumeration` and the code models are back
|
|
21
|
-
in the pool, explorers included. A role is not a score and does not exile
|
|
22
|
-
anyone: a code model is still explored, on code-shaped work. `--with` seats
|
|
23
|
-
anything regardless, with a note, because an explicit request outranks a
|
|
24
|
-
heuristic.
|
|
25
|
-
|
|
26
|
-
CONNECTIONS ARE NOT EVENLY STOCKED
|
|
27
|
-
----------------------------------
|
|
28
|
-
Six carry registered models and the distribution is lopsided: nvidia-api and
|
|
29
|
-
openrouter hold 17 of the 23 available between them, while groq has one and
|
|
30
|
-
mistral one usable of four. So only the PRIMARY and SECOND are required to sit
|
|
31
|
-
on different connections — that pair is the backup relationship, and a backup
|
|
32
|
-
behind the same rate limit is not one. The explorers may share, because
|
|
33
|
-
forcing a fourth distinct connection would push both probes onto the thin ones
|
|
34
|
-
and under-explore exactly where most models live.
|
|
35
|
-
|
|
36
|
-
`inkling-free` is tagged `openrouter` and that is correct for this purpose. It
|
|
37
|
-
is registered on the direct `openrouter` endpoint (config/endpoints.json), so it
|
|
38
|
-
is independent in TRANSPORT — an Open WebUI outage would not touch it — but it
|
|
39
|
-
uses the same OpenRouter key and shares `free-models-per-day` with every other
|
|
40
|
-
`:free` model. Attribution opens the agentic gate and does not lift a rate
|
|
41
|
-
limit, which was tested. Shared quota is what matters here, so it counts as
|
|
42
|
-
openrouter.
|
|
43
|
-
|
|
44
|
-
WHY A PANEL AND NOT A PICK
|
|
45
|
-
--------------------------
|
|
46
|
-
These models are free or flat-rate, so asking three costs about what asking
|
|
47
|
-
one costs. The binding constraint is not money, it is that **a second opinion
|
|
48
|
-
from a model that fails the same way is not a second opinion.** So the panel is
|
|
49
|
-
chosen for INDEPENDENCE: the second seat goes to a different connection from
|
|
50
|
-
the first, because models behind one proxy share a rate limit, an outage and
|
|
51
|
-
often a vendor.
|
|
52
|
-
|
|
53
|
-
THE FEEDBACK LOOP THIS EXISTS TO BREAK
|
|
54
|
-
--------------------------------------
|
|
55
|
-
A router that only picks proven models is self-reinforcing and quietly wrong: a
|
|
56
|
-
model that stops being asked stops producing evidence, so its record never
|
|
57
|
-
improves and it is never asked again. It gets phased out by SILENCE rather than
|
|
58
|
-
by being bad. Nothing in the data would show this happening — the unused model
|
|
59
|
-
simply has no rows.
|
|
60
|
-
|
|
61
|
-
So the last TWO seats are EXPLORERS, chosen by staleness rather than by
|
|
62
|
-
score: whichever available models have gone longest without real work. They
|
|
63
|
-
are structural slots, not good intentions, and they cost two extra free calls.
|
|
64
|
-
|
|
65
|
-
Two rather than one because coverage is the constraint — 17 of 23 available
|
|
66
|
-
models had never been given real work. And an explorer is not a wasted seat:
|
|
67
|
-
it answers the same question as everyone else, so it is a fourth opinion as
|
|
68
|
-
well as a probe.
|
|
69
|
-
|
|
70
|
-
WHAT DISQUALIFIES A MODEL
|
|
71
|
-
-------------------------
|
|
72
|
-
Availability, and fit. `structural` (it is not there) and `entitlement` (this
|
|
73
|
-
account may not call it) remove a model; a prompt it cannot read removes it
|
|
74
|
-
for that prompt; a role the task's shape does not accept removes it for that
|
|
75
|
-
task. A poor score never does. A model that did badly once on one task shape
|
|
76
|
-
has earned less confidence, not exile.
|
|
77
|
-
|
|
78
|
-
Cost, separately. A model listed under `ad_hoc` in config/model-roles.json is
|
|
79
|
-
expensive or tightly capped and is never seated automatically
|
|
80
|
-
only way onto
|
|
81
|
-
exactly the model nobody uses casually.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
import
|
|
87
|
-
|
|
88
|
-
import
|
|
89
|
-
import
|
|
90
|
-
import
|
|
91
|
-
import
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
served
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
The
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
acc[
|
|
266
|
-
acc[
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
`
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
"
|
|
330
|
-
"
|
|
331
|
-
"
|
|
332
|
-
"
|
|
333
|
-
"
|
|
334
|
-
"
|
|
335
|
-
"
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
#
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
and r["
|
|
385
|
-
and
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
"
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
#
|
|
472
|
-
#
|
|
473
|
-
#
|
|
474
|
-
#
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
"
|
|
492
|
-
"
|
|
493
|
-
"
|
|
494
|
-
"
|
|
495
|
-
"
|
|
496
|
-
"
|
|
497
|
-
"
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
#
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
print(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
print(describe(p["
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
print(f"\n
|
|
548
|
-
|
|
549
|
-
print("
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
for
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
print("
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
print("\
|
|
567
|
-
"
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
"
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
"
|
|
574
|
-
|
|
575
|
-
"
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1
|
+
"""Who to ask, and who to ask as well — a panel, not a winner.
|
|
2
|
+
|
|
3
|
+
python metrics/route.py # today's panel (shape: general)
|
|
4
|
+
python metrics/route.py --shape review # code review: code models eligible too
|
|
5
|
+
python metrics/route.py --shape design # ADR / process / prose: general models only
|
|
6
|
+
|
|
7
|
+
A MODEL HAS A ROLE, A TASK HAS A SHAPE
|
|
8
|
+
--------------------------------------
|
|
9
|
+
Not every model is the same kind of thing. `codestral` and `north-mini-code`
|
|
10
|
+
are code-completion models: sharp on "list every place X happens" and "is
|
|
11
|
+
this function wrong", and they answer a design or process question with
|
|
12
|
+
boilerplate — one gave six generic findings on a packet of ADR text and none
|
|
13
|
+
survived. `inkling` and `kimi` are reasoning models that can do all of it.
|
|
14
|
+
Seating them identically wastes the explorer's call and, worse, records a
|
|
15
|
+
miss against a model that was never the right tool.
|
|
16
|
+
|
|
17
|
+
So `config/model-roles.json` gives each alias a ROLE (general, code, image)
|
|
18
|
+
and each task SHAPE a list of roles it accepts. `--shape` picks the shape;
|
|
19
|
+
with none given the task is `general`, which admits general models only.
|
|
20
|
+
Pass `--shape review`, `code` or `enumeration` and the code models are back
|
|
21
|
+
in the pool, explorers included. A role is not a score and does not exile
|
|
22
|
+
anyone: a code model is still explored, on code-shaped work. `--with` seats
|
|
23
|
+
anything regardless, with a note, because an explicit request outranks a
|
|
24
|
+
heuristic.
|
|
25
|
+
|
|
26
|
+
CONNECTIONS ARE NOT EVENLY STOCKED
|
|
27
|
+
----------------------------------
|
|
28
|
+
Six carry registered models and the distribution is lopsided: nvidia-api and
|
|
29
|
+
openrouter hold 17 of the 23 available between them, while groq has one and
|
|
30
|
+
mistral one usable of four. So only the PRIMARY and SECOND are required to sit
|
|
31
|
+
on different connections — that pair is the backup relationship, and a backup
|
|
32
|
+
behind the same rate limit is not one. The explorers may share, because
|
|
33
|
+
forcing a fourth distinct connection would push both probes onto the thin ones
|
|
34
|
+
and under-explore exactly where most models live.
|
|
35
|
+
|
|
36
|
+
`inkling-free` is tagged `openrouter` and that is correct for this purpose. It
|
|
37
|
+
is registered on the direct `openrouter` endpoint (config/endpoints.json), so it
|
|
38
|
+
is independent in TRANSPORT — an Open WebUI outage would not touch it — but it
|
|
39
|
+
uses the same OpenRouter key and shares `free-models-per-day` with every other
|
|
40
|
+
`:free` model. Attribution opens the agentic gate and does not lift a rate
|
|
41
|
+
limit, which was tested. Shared quota is what matters here, so it counts as
|
|
42
|
+
openrouter.
|
|
43
|
+
|
|
44
|
+
WHY A PANEL AND NOT A PICK
|
|
45
|
+
--------------------------
|
|
46
|
+
These models are free or flat-rate, so asking three costs about what asking
|
|
47
|
+
one costs. The binding constraint is not money, it is that **a second opinion
|
|
48
|
+
from a model that fails the same way is not a second opinion.** So the panel is
|
|
49
|
+
chosen for INDEPENDENCE: the second seat goes to a different connection from
|
|
50
|
+
the first, because models behind one proxy share a rate limit, an outage and
|
|
51
|
+
often a vendor.
|
|
52
|
+
|
|
53
|
+
THE FEEDBACK LOOP THIS EXISTS TO BREAK
|
|
54
|
+
--------------------------------------
|
|
55
|
+
A router that only picks proven models is self-reinforcing and quietly wrong: a
|
|
56
|
+
model that stops being asked stops producing evidence, so its record never
|
|
57
|
+
improves and it is never asked again. It gets phased out by SILENCE rather than
|
|
58
|
+
by being bad. Nothing in the data would show this happening — the unused model
|
|
59
|
+
simply has no rows.
|
|
60
|
+
|
|
61
|
+
So the last TWO seats are EXPLORERS, chosen by staleness rather than by
|
|
62
|
+
score: whichever available models have gone longest without real work. They
|
|
63
|
+
are structural slots, not good intentions, and they cost two extra free calls.
|
|
64
|
+
|
|
65
|
+
Two rather than one because coverage is the constraint — 17 of 23 available
|
|
66
|
+
models had never been given real work. And an explorer is not a wasted seat:
|
|
67
|
+
it answers the same question as everyone else, so it is a fourth opinion as
|
|
68
|
+
well as a probe.
|
|
69
|
+
|
|
70
|
+
WHAT DISQUALIFIES A MODEL
|
|
71
|
+
-------------------------
|
|
72
|
+
Availability, and fit. `structural` (it is not there) and `entitlement` (this
|
|
73
|
+
account may not call it) remove a model; a prompt it cannot read removes it
|
|
74
|
+
for that prompt; a role the task's shape does not accept removes it for that
|
|
75
|
+
task. A poor score never does. A model that did badly once on one task shape
|
|
76
|
+
has earned less confidence, not exile.
|
|
77
|
+
|
|
78
|
+
Cost, separately. A model listed under `ad_hoc` in config/model-roles.json is
|
|
79
|
+
expensive or tightly capped and is never seated automatically: `--with` is the
|
|
80
|
+
only way onto this router's panel. Staleness would otherwise pick it first,
|
|
81
|
+
since it is exactly the model nobody uses casually. A collection that lists it
|
|
82
|
+
as a member (or merger) still calls it on every request; that is a choice made
|
|
83
|
+
by name, like `--with`.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import argparse
|
|
89
|
+
import csv
|
|
90
|
+
import datetime as dt
|
|
91
|
+
import json
|
|
92
|
+
import pathlib
|
|
93
|
+
import sys
|
|
94
|
+
|
|
95
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
96
|
+
from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
|
|
97
|
+
sys.path.insert(0, str(ROOT / "suite"))
|
|
98
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
99
|
+
|
|
100
|
+
from aliases import current as current_alias # noqa: E402
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _json(path: pathlib.Path) -> dict:
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, ValueError):
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def connection_of(alias: str) -> str:
|
|
111
|
+
"""The connection a model is reached through, trimmed to the host (what
|
|
112
|
+
shares a rate limit): see full_tag_of."""
|
|
113
|
+
return full_tag_of(alias).split("/")[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Connections already PAID for, so a spare call there costs nothing extra:
|
|
117
|
+
# `preferred` in config/connections.json. Used as a TIEBREAK only — never to
|
|
118
|
+
# inflate a score, because the evidence is the evidence. When two models are
|
|
119
|
+
# within noise of each other, the one already paid for wins.
|
|
120
|
+
def preferred_connections() -> set[str]:
|
|
121
|
+
import endpoints
|
|
122
|
+
return set(endpoints.connections().get("preferred") or ())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def full_tag_of(alias: str) -> str:
|
|
126
|
+
"""The connection tag INCLUDING the vendor suffix.
|
|
127
|
+
|
|
128
|
+
`connection_of` trims to the host because that is what shares a rate
|
|
129
|
+
limit; this keeps `<proxy>/<vendor>` whole, because that is what a
|
|
130
|
+
subscription is bought for.
|
|
131
|
+
|
|
132
|
+
Its Open WebUI tag (scripts/provider_tags.py), else the endpoint it is
|
|
133
|
+
registered on: a model on its own endpoint, or any model on an install
|
|
134
|
+
with no Open WebUI to tag anything, needs no tags file. Never a hub's
|
|
135
|
+
name, else "?": a hub fronts many connections, and an untagged model
|
|
136
|
+
behind it (Open WebUI stopped listing it) is not on a connection called
|
|
137
|
+
`openwebui` with every other untagged one.
|
|
138
|
+
"""
|
|
139
|
+
from run import _registered_names
|
|
140
|
+
import endpoints
|
|
141
|
+
# Tags are keyed by the served name. A decision model is not in the llm
|
|
142
|
+
# yaml, so its served name comes from config/decision-models.json.
|
|
143
|
+
served = (_registered_names().get(alias)
|
|
144
|
+
or (_json(DATA / "config" / "decision-models.json").get(alias) or {}).get("served")
|
|
145
|
+
or alias)
|
|
146
|
+
tag = _json(DATA / "config" / "provider-tags.json").get(served)
|
|
147
|
+
if not tag:
|
|
148
|
+
name = endpoints.endpoint_name(endpoints.registrations().get(alias) or {})
|
|
149
|
+
tag = name if name and not endpoints.hub(name) else None
|
|
150
|
+
return tag or "?"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def family_of(alias: str) -> str:
|
|
154
|
+
"""The underlying MODEL, ignoring which connection serves it.
|
|
155
|
+
|
|
156
|
+
The same model can arrive on two connections — `Inkling` on llm7-api is
|
|
157
|
+
`thinkingmachines/inkling:free` on openrouter — and everything else in
|
|
158
|
+
this repo treats them as separate, correctly: they have different rate
|
|
159
|
+
limits, different quotas and can behave differently (quantisation, token
|
|
160
|
+
caps, injected prompts), so scoring and availability must stay apart.
|
|
161
|
+
|
|
162
|
+
Routing is the exception. Seating one model in two chairs looks like a
|
|
163
|
+
second opinion and is not one: it will agree with itself for reasons that
|
|
164
|
+
have nothing to do with being right. A panel's whole value is independent
|
|
165
|
+
failure.
|
|
166
|
+
|
|
167
|
+
Normalised by dropping the vendor prefix, the `:free` suffix, case and
|
|
168
|
+
separators — so `thinkingmachines/inkling:free` and `Inkling` collide,
|
|
169
|
+
while `glm-5.2` and `glm-5.3` do not, because those really are different
|
|
170
|
+
models.
|
|
171
|
+
"""
|
|
172
|
+
from run import _registered_names
|
|
173
|
+
served = _registered_names().get(alias, alias)
|
|
174
|
+
base = served.split("/")[-1].split(":")[0]
|
|
175
|
+
return base.lower().replace("-", "").replace("_", "").replace(".", "")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _roles() -> dict:
|
|
179
|
+
import model_roles # roles and shapes shipped, the install's on top
|
|
180
|
+
return model_roles.load()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def role_of(alias: str) -> str:
|
|
184
|
+
"""What the model is for: general, code or image. Unlisted means general."""
|
|
185
|
+
return _roles().get("models", {}).get(alias, "general")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def roles_for_shape(shape: str | None) -> list[str]:
|
|
189
|
+
"""The roles a task shape admits. Unknown shapes are refused rather than
|
|
190
|
+
silently treated as general — a typo would otherwise widen or narrow the
|
|
191
|
+
panel with no sign that it had. ValueError, not SystemExit: the gateway
|
|
192
|
+
calls this from a request thread, which SystemExit killed without a
|
|
193
|
+
reply; it answers a ValueError with a 400."""
|
|
194
|
+
shapes = _roles().get("shapes", {"general": ["general"]})
|
|
195
|
+
shape = shape or "general"
|
|
196
|
+
if shape not in shapes:
|
|
197
|
+
raise ValueError(f"unknown shape {shape!r}; known: {', '.join(sorted(shapes))}")
|
|
198
|
+
return list(shapes[shape])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def size_capability() -> dict[str, bool]:
|
|
202
|
+
"""alias -> can it take a LARGE prompt, judged from the perf history.
|
|
203
|
+
|
|
204
|
+
Derived, not declared. The perf tier already measures two prompt sizes, so
|
|
205
|
+
a model that answers "ready" and fails an 18 KB summary has said what its
|
|
206
|
+
limit is without anyone writing it down: `qwen3.8-27b` refuses at 7,000
|
|
207
|
+
tokens a minute and `laguna-s-2.1-free` fails the same way.
|
|
208
|
+
|
|
209
|
+
That matters because most of our real tasks are large — `satellite-writes`
|
|
210
|
+
is 46 KB — so recommending a model that cannot read the prompt wastes a
|
|
211
|
+
seat on the panel.
|
|
212
|
+
"""
|
|
213
|
+
path = DATA / "perf" / "history.csv"
|
|
214
|
+
if not path.exists():
|
|
215
|
+
return {}
|
|
216
|
+
import csv as _csv
|
|
217
|
+
latest: dict[tuple[str, str], dict] = {}
|
|
218
|
+
with path.open(encoding="utf-8", newline="") as fh:
|
|
219
|
+
for row in _csv.DictReader(fh):
|
|
220
|
+
latest[(current_alias(row["model"]), row["prompt"])] = row
|
|
221
|
+
out: dict[str, bool] = {}
|
|
222
|
+
for (model, prompt), row in latest.items():
|
|
223
|
+
if prompt != "large":
|
|
224
|
+
continue
|
|
225
|
+
out[model] = int(row.get("answered") or 0) > 0
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def suite_recall() -> dict[str, tuple[int, int, int]]:
|
|
230
|
+
"""alias -> (found, of, tasks), scored from the SAVED OUTPUTS.
|
|
231
|
+
|
|
232
|
+
Not from the run notes. Parsing those inherits every misclassification ever
|
|
233
|
+
written down: `gpt-oss-120b-cloud` was recorded 0/5, 0/3, 0/2 and 0/11 in a
|
|
234
|
+
run where it was misregistered and returning "Model not found" in four
|
|
235
|
+
seconds, and this router read those as a capability score — the exact
|
|
236
|
+
conflation the rest of this repo exists to prevent.
|
|
237
|
+
|
|
238
|
+
The outputs are the data and the notes are the narrative. Re-scoring the
|
|
239
|
+
outputs applies today's classifier to the original answers, so a fix to the
|
|
240
|
+
scorer retroactively corrects the routing rather than leaving a wrong
|
|
241
|
+
number fossilised in a markdown table.
|
|
242
|
+
"""
|
|
243
|
+
import json as _json
|
|
244
|
+
from run import registered_models, score
|
|
245
|
+
|
|
246
|
+
live = set(registered_models())
|
|
247
|
+
out: dict[str, list[int]] = {}
|
|
248
|
+
for task_dir in sorted((DATA / "suite" / "tasks").glob("*/task.json")):
|
|
249
|
+
spec = _json.loads(task_dir.read_text(encoding="utf-8"))
|
|
250
|
+
# Claim-verification tasks are a true/false verdict, where a guess
|
|
251
|
+
# scores half: folding them into finding-recall would blur the one
|
|
252
|
+
# number that picks the primary. They are reported on their own, as
|
|
253
|
+
# are seeded and real-bug (rebench) FAULT/NO FAULT tasks.
|
|
254
|
+
if spec.get("shape") in ("claim", "seeded", "rebench") or spec.get("draft"):
|
|
255
|
+
continue
|
|
256
|
+
for answer in sorted((task_dir.parent / "output").glob("*.md")):
|
|
257
|
+
alias = current_alias(answer.stem) # a renamed model keeps its record
|
|
258
|
+
if alias not in live:
|
|
259
|
+
continue
|
|
260
|
+
result = score(spec, answer.read_text(encoding="utf-8",
|
|
261
|
+
errors="replace"))
|
|
262
|
+
# A model that never answered has no score, in either direction.
|
|
263
|
+
if result["kind"] != "answered":
|
|
264
|
+
continue
|
|
265
|
+
acc = out.setdefault(alias, [0, 0, 0])
|
|
266
|
+
acc[0] += result["found"]
|
|
267
|
+
acc[1] += result["of"]
|
|
268
|
+
acc[2] += 1
|
|
269
|
+
return {m: tuple(v) for m, v in out.items()}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def panel(shape: str | None = None, large: bool = True,
|
|
273
|
+
nominated: list[str] | None = None,
|
|
274
|
+
members: list[str] | None = None, explorers: int = 2,
|
|
275
|
+
avoid: set[str] | None = None) -> dict:
|
|
276
|
+
"""`large=True` means the task is a big one — most of ours are.
|
|
277
|
+
|
|
278
|
+
`members` is a bench-gateway COLLECTION: models the caller always runs. With
|
|
279
|
+
it, the primary and second seats are not chosen (the collection is the
|
|
280
|
+
exploitation), and only `explorers` (0-2) seats are filled by staleness,
|
|
281
|
+
never with a member or a member's family. `avoid` keeps models out of
|
|
282
|
+
those automatic picks (the gateway passes the ones on a retiring alert, so
|
|
283
|
+
a seat is not spent on a model already known to be failing); a nomination
|
|
284
|
+
still seats one.
|
|
285
|
+
|
|
286
|
+
`nominated` names models the operator wants on the panel today, and they
|
|
287
|
+
take EXPLORER seats — never the primary or the second.
|
|
288
|
+
|
|
289
|
+
That placement is the whole design. The first two seats are the
|
|
290
|
+
independence pair: the primary is exploitation and the second is the
|
|
291
|
+
backup that must not fail the same way, so a hand-picked model displacing
|
|
292
|
+
either would quietly remove the guarantee those seats exist to give. An
|
|
293
|
+
explorer seat is already "a model we have little evidence about, seated to
|
|
294
|
+
get some" — which is exactly what wanting to try something new IS. So a
|
|
295
|
+
nomination is not a new kind of seat, it is the same seat filled by
|
|
296
|
+
judgement instead of by staleness, and no fifth chair is needed.
|
|
297
|
+
|
|
298
|
+
Only ONE of the two explorer seats is taken per nomination, and the
|
|
299
|
+
remainder still go to the stalest available models. That matters: if
|
|
300
|
+
nominating switched off automatic exploration entirely, a habit of
|
|
301
|
+
nominating a favourite would rebuild the exact feedback loop the explorer
|
|
302
|
+
slots were built to break, and it would do it invisibly.
|
|
303
|
+
"""
|
|
304
|
+
from run import ad_hoc_models, available_models
|
|
305
|
+
from offload import summary
|
|
306
|
+
|
|
307
|
+
# AD HOC models get a record but no automatic seat — not primary, second,
|
|
308
|
+
# explorer or reserve. They are the expensive or capped ones, and an
|
|
309
|
+
# explorer seat chosen by staleness would pick exactly them, since nobody
|
|
310
|
+
# uses them casually. `--with` is the only way onto the panel.
|
|
311
|
+
avail = available_models(include_ad_hoc=True)
|
|
312
|
+
held_ad_hoc = sorted(ad_hoc_models() & set(avail))
|
|
313
|
+
recall = suite_recall()
|
|
314
|
+
used = summary()
|
|
315
|
+
sizes = size_capability()
|
|
316
|
+
today = dt.date.today()
|
|
317
|
+
|
|
318
|
+
# The record for EVERY available model, built before any exclusion, so a
|
|
319
|
+
# model seated by nomination over an exclusion still shows its real
|
|
320
|
+
# evidence rather than a blank.
|
|
321
|
+
record: dict[str, dict] = {}
|
|
322
|
+
for alias in avail:
|
|
323
|
+
found, of, tasks = recall.get(alias, (0, 0, 0))
|
|
324
|
+
u = used.get(alias, {})
|
|
325
|
+
last = u.get("last_date")
|
|
326
|
+
stale = ((today - dt.date.fromisoformat(last)).days
|
|
327
|
+
if last else 9999) # never used: maximally stale
|
|
328
|
+
record[alias] = {
|
|
329
|
+
"model": alias,
|
|
330
|
+
"connection": connection_of(alias),
|
|
331
|
+
"full_tag": full_tag_of(alias),
|
|
332
|
+
"family": family_of(alias),
|
|
333
|
+
"found": found, "of": of, "tasks": tasks,
|
|
334
|
+
"recall": found / of if of else None,
|
|
335
|
+
"offloads": u.get("offloads", 0),
|
|
336
|
+
"helped": u.get("useful", 0), "judged": u.get("judged", 0),
|
|
337
|
+
"stale_days": stale,
|
|
338
|
+
}
|
|
339
|
+
avail = [m for m in avail if m not in held_ad_hoc]
|
|
340
|
+
|
|
341
|
+
# A MODEL THAT CANNOT READ THE PROMPT IS A WASTED SEAT. Judged from the
|
|
342
|
+
# perf tier's two prompt sizes rather than from a hardcoded table.
|
|
343
|
+
excluded_for_size = []
|
|
344
|
+
if large:
|
|
345
|
+
excluded_for_size = [m for m in avail if sizes.get(m) is False]
|
|
346
|
+
avail = [m for m in avail if sizes.get(m) is not False]
|
|
347
|
+
|
|
348
|
+
# A MODEL THAT IS NOT FOR THIS KIND OF TASK IS A WASTED SEAT TOO — and a
|
|
349
|
+
# miss recorded against it teaches the roster nothing true. Declared in
|
|
350
|
+
# config/model-roles.json, chosen by --shape; general is the default.
|
|
351
|
+
admitted = roles_for_shape(shape)
|
|
352
|
+
excluded_for_role = [m for m in avail if role_of(m) not in admitted]
|
|
353
|
+
avail = [m for m in avail if role_of(m) in admitted]
|
|
354
|
+
|
|
355
|
+
rows = [record[alias] for alias in avail]
|
|
356
|
+
|
|
357
|
+
# PAID connections break ties and nothing more. The evidence is the
|
|
358
|
+
# evidence; a subscription only decides who wins when two models are level.
|
|
359
|
+
preferred = preferred_connections()
|
|
360
|
+
|
|
361
|
+
def paid(r):
|
|
362
|
+
return 1 if r["full_tag"] in preferred else 0
|
|
363
|
+
|
|
364
|
+
# PRIMARY: most verified findings on our own code. Absolute rather than a
|
|
365
|
+
# ratio, so a perfect score on one task does not outrank near-perfect on
|
|
366
|
+
# five.
|
|
367
|
+
scored = [r for r in rows if r["of"]]
|
|
368
|
+
primary = max(scored, key=lambda r: (r["found"], r["recall"] or 0, paid(r)),
|
|
369
|
+
default=None)
|
|
370
|
+
|
|
371
|
+
# SECOND OPINION: rotated among CONTENDERS, not simply the runner-up.
|
|
372
|
+
# Taking the runner-up had the same feedback loop as taking the leader, one
|
|
373
|
+
# rank down — one model would hold the seat for ever while its evidence
|
|
374
|
+
# went stale. Only the PRIMARY is fixed; that seat is exploitation.
|
|
375
|
+
#
|
|
376
|
+
# NO CONNECTION CONSTRAINT ANY MORE. Requiring a different connection was
|
|
377
|
+
# a preventive proxy for "do not both fail at once", and it distorted the
|
|
378
|
+
# choice: nvidia-api and openrouter hold 17 of 23 available models, and
|
|
379
|
+
# most new models arrive on nvidia, so excluding a whole connection to
|
|
380
|
+
# satisfy a rule meant systematically under-exercising the busiest one.
|
|
381
|
+
# The RESERVE below handles the real failure reactively instead.
|
|
382
|
+
contenders = [
|
|
383
|
+
r for r in scored
|
|
384
|
+
if primary and r["model"] != primary["model"]
|
|
385
|
+
and r["family"] != primary["family"] # not the same model twice
|
|
386
|
+
and r["tasks"] >= 2 # one task is not a record
|
|
387
|
+
and (r["recall"] or 0) >= 0.85 * (primary["recall"] or 0)
|
|
388
|
+
]
|
|
389
|
+
second = max(contenders,
|
|
390
|
+
key=lambda r: (r["stale_days"], paid(r), r["found"]),
|
|
391
|
+
default=None)
|
|
392
|
+
if second is None:
|
|
393
|
+
second = max((r for r in scored
|
|
394
|
+
if primary and r["model"] != primary["model"]),
|
|
395
|
+
key=lambda r: (r["found"], paid(r)), default=None)
|
|
396
|
+
|
|
397
|
+
# TWO EXPLORERS: longest since real work, score ignored. The slot that
|
|
398
|
+
# stops the router eating its own tail. Two because coverage is the
|
|
399
|
+
# constraint, and an explorer answers the same question as everyone else —
|
|
400
|
+
# a fourth opinion as well as a probe.
|
|
401
|
+
if members is not None:
|
|
402
|
+
primary = second = None
|
|
403
|
+
chosen = {r["model"] for r in (primary, second) if r} | set(members or [])
|
|
404
|
+
# Families already seated. The same model on a second connection is not a
|
|
405
|
+
# second opinion, however different its alias looks.
|
|
406
|
+
seated_families = ({r["family"] for r in (primary, second) if r}
|
|
407
|
+
| {family_of(m) for m in members or []})
|
|
408
|
+
|
|
409
|
+
def stalest():
|
|
410
|
+
pool = [r for r in rows if r["model"] not in chosen
|
|
411
|
+
and r["family"] not in seated_families and r["model"] not in (avoid or ())]
|
|
412
|
+
return max(pool, key=lambda r: (r["stale_days"], paid(r),
|
|
413
|
+
-(r["recall"] or 0)), default=None)
|
|
414
|
+
|
|
415
|
+
# Nominations are seated FIRST, so staleness fills whatever explorer seats
|
|
416
|
+
# are left rather than being overwritten after the fact.
|
|
417
|
+
by_alias = {r["model"]: r for r in rows}
|
|
418
|
+
seats: list[dict | None] = []
|
|
419
|
+
notes: list[str] = []
|
|
420
|
+
explorers = max(0, min(2, explorers))
|
|
421
|
+
for alias in (nominated or [])[:explorers]:
|
|
422
|
+
row = by_alias.get(alias)
|
|
423
|
+
if row is None and alias in held_ad_hoc:
|
|
424
|
+
# Exactly what ad hoc is for: named, so seated. Said anyway,
|
|
425
|
+
# because the call spends a cap the rest of the panel does not.
|
|
426
|
+
notes.append(f"{alias} is ad hoc (expensive or capped) — seated "
|
|
427
|
+
"because you asked; one call, not a retry loop")
|
|
428
|
+
row = record[alias]
|
|
429
|
+
elif row is None:
|
|
430
|
+
if alias in excluded_for_size or alias in excluded_for_role:
|
|
431
|
+
# Honoured anyway: an explicit request outranks a heuristic,
|
|
432
|
+
# and the operator is the one who knows how big today's prompt
|
|
433
|
+
# is and what it is really about. Said out loud, because a
|
|
434
|
+
# seat that cannot read the prompt, or is the wrong kind of
|
|
435
|
+
# model for it, is a wasted seat.
|
|
436
|
+
if alias in excluded_for_size:
|
|
437
|
+
notes.append(
|
|
438
|
+
f"{alias} is recorded as failing on LARGE prompts — seated"
|
|
439
|
+
" because you asked; pass --small if this one is short")
|
|
440
|
+
else:
|
|
441
|
+
notes.append(
|
|
442
|
+
f"{alias} is a {role_of(alias)} model and this is a"
|
|
443
|
+
f" {shape or 'general'} task — seated because you asked;"
|
|
444
|
+
" judge its answer with that in mind")
|
|
445
|
+
row = record[alias]
|
|
446
|
+
else:
|
|
447
|
+
notes.append(f"{alias} is not available — check the alias "
|
|
448
|
+
f"against `llm models list`; seat left to staleness")
|
|
449
|
+
continue
|
|
450
|
+
if row["model"] in chosen:
|
|
451
|
+
notes.append(f"{alias} is already seated as primary or second "
|
|
452
|
+
f"opinion; no explorer seat used")
|
|
453
|
+
continue
|
|
454
|
+
row = dict(row, nominated=True)
|
|
455
|
+
seats.append(row)
|
|
456
|
+
chosen.add(row["model"])
|
|
457
|
+
seated_families.add(row["family"])
|
|
458
|
+
|
|
459
|
+
while len(seats) < explorers:
|
|
460
|
+
pick = stalest()
|
|
461
|
+
if pick is None:
|
|
462
|
+
seats.append(None)
|
|
463
|
+
continue
|
|
464
|
+
chosen.add(pick["model"])
|
|
465
|
+
seated_families.add(pick["family"])
|
|
466
|
+
seats.append(pick)
|
|
467
|
+
seats += [None] * (2 - len(seats))
|
|
468
|
+
|
|
469
|
+
explorer, explorer2 = seats[0], seats[1]
|
|
470
|
+
|
|
471
|
+
# RESERVE: the fallback when the panel meets a rate limit.
|
|
472
|
+
#
|
|
473
|
+
# This replaces the hard rule that seats must sit on different
|
|
474
|
+
# connections. That rule was preventive and blunt — it distorted every
|
|
475
|
+
# pick to guard against something that might not happen. A reserve is
|
|
476
|
+
# reactive and cheap: one named substitute on a connection NOTHING on the
|
|
477
|
+
# panel is using, so whatever limit takes out a seat cannot take out its
|
|
478
|
+
# replacement too.
|
|
479
|
+
used_connections = {r["connection"] for r in
|
|
480
|
+
(primary, second, explorer, explorer2) if r}
|
|
481
|
+
reserve = max(
|
|
482
|
+
(r for r in scored
|
|
483
|
+
if r["model"] not in chosen and r["family"] not in seated_families
|
|
484
|
+
and r["connection"] not in used_connections),
|
|
485
|
+
key=lambda r: (r["found"], paid(r)), default=None)
|
|
486
|
+
if reserve is None: # every connection in use
|
|
487
|
+
reserve = max((r for r in scored if r["model"] not in chosen),
|
|
488
|
+
key=lambda r: (r["found"], paid(r)), default=None)
|
|
489
|
+
|
|
490
|
+
return {"primary": primary, "second": second, "explorer": explorer,
|
|
491
|
+
"explorer2": explorer2, "reserve": reserve,
|
|
492
|
+
"considered": len(rows),
|
|
493
|
+
"untried": sum(1 for r in rows if not r["offloads"]),
|
|
494
|
+
"contenders": len(contenders),
|
|
495
|
+
"excluded_for_size": excluded_for_size,
|
|
496
|
+
"excluded_for_role": excluded_for_role,
|
|
497
|
+
"held_ad_hoc": held_ad_hoc,
|
|
498
|
+
"shape": shape or "general",
|
|
499
|
+
"notes": notes}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def describe(r: dict | None, role: str) -> str:
|
|
503
|
+
if r is None:
|
|
504
|
+
return f" {role:<14} —"
|
|
505
|
+
ev = (f"{r['found']}/{r['of']} over {r['tasks']} task"
|
|
506
|
+
f"{'s' if r['tasks'] != 1 else ''}" if r["of"] else "no suite score")
|
|
507
|
+
work = (f"{r['offloads']} offload{'s' if r['offloads'] != 1 else ''}"
|
|
508
|
+
if r["offloads"] else "never used for real work")
|
|
509
|
+
# A nominated seat is labelled, because the record has to distinguish
|
|
510
|
+
# evidence the router went looking for from evidence the operator asked
|
|
511
|
+
# for. They are not the same claim about a model.
|
|
512
|
+
if r.get("nominated"):
|
|
513
|
+
role = f"{role} (asked)"
|
|
514
|
+
return (f" {role:<14} {r['model']:<28} {r['connection']:<14} {ev}; {work}")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def main() -> None:
|
|
518
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
519
|
+
ap.add_argument("--shape", default=None,
|
|
520
|
+
help="what today's task is: general (default), design, prose,"
|
|
521
|
+
" review, code, enumeration, image. Decides which model"
|
|
522
|
+
" ROLES are eligible — see config/model-roles.json")
|
|
523
|
+
ap.add_argument("--small", action="store_true",
|
|
524
|
+
help="the task is a SHORT prompt, so models that fail on "
|
|
525
|
+
"large ones are eligible again")
|
|
526
|
+
ap.add_argument("--with", dest="nominated", action="append", metavar="ALIAS",
|
|
527
|
+
help="seat this model today; takes an EXPLORER seat, never "
|
|
528
|
+
"the primary or second. Repeatable up to twice, but "
|
|
529
|
+
"leaving one for staleness is the point.")
|
|
530
|
+
args = ap.parse_args()
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
p = panel(args.shape, large=not args.small, nominated=args.nominated)
|
|
534
|
+
except ValueError as exc:
|
|
535
|
+
raise SystemExit(str(exc)) from None
|
|
536
|
+
print(f"Panel of four, from {p['considered']} available models"
|
|
537
|
+
f" (task shape: {p['shape']}):\n")
|
|
538
|
+
print(describe(p["primary"], "primary"))
|
|
539
|
+
print(describe(p["second"], "second opinion"))
|
|
540
|
+
if p.get("contenders"):
|
|
541
|
+
print(f" (rotating among {p['contenders']} contender"
|
|
542
|
+
f"{'s' if p['contenders'] != 1 else ''} — only the primary is fixed)")
|
|
543
|
+
print(describe(p["explorer"], "explorer"))
|
|
544
|
+
print(describe(p["explorer2"], "explorer 2"))
|
|
545
|
+
print(describe(p["reserve"], "reserve"))
|
|
546
|
+
for note in p.get("notes", []):
|
|
547
|
+
print(f"\n note: {note}")
|
|
548
|
+
if p["excluded_for_size"]:
|
|
549
|
+
print(f"\n excluded (fail on large prompts): "
|
|
550
|
+
f"{', '.join(sorted(p['excluded_for_size']))}")
|
|
551
|
+
print(" — pass --small if this task is a short one.")
|
|
552
|
+
if p["excluded_for_role"]:
|
|
553
|
+
by_role: dict[str, list[str]] = {}
|
|
554
|
+
for m in p["excluded_for_role"]:
|
|
555
|
+
by_role.setdefault(role_of(m), []).append(m)
|
|
556
|
+
for role, models in sorted(by_role.items()):
|
|
557
|
+
print(f"\n not seated ({role} models, task is {p['shape']}): "
|
|
558
|
+
f"{', '.join(sorted(models))}")
|
|
559
|
+
if "code" in by_role:
|
|
560
|
+
print(" — pass --shape review, code or enumeration to make them"
|
|
561
|
+
" eligible; --with seats one anyway.")
|
|
562
|
+
if p["held_ad_hoc"]:
|
|
563
|
+
print(f"\n held back (ad hoc — never seated automatically): "
|
|
564
|
+
f"{', '.join(p['held_ad_hoc'])}")
|
|
565
|
+
print(" — --with <alias> seats one in an explorer chair.")
|
|
566
|
+
print(f"\n{p['untried']} of {p['considered']} available models have never "
|
|
567
|
+
f"been given real work.")
|
|
568
|
+
print("\nThe second seat is on a different connection on purpose — models "
|
|
569
|
+
"behind one proxy\nshare a rate limit and an outage, so a backup "
|
|
570
|
+
"there is not a backup.")
|
|
571
|
+
print("The third is chosen by STALENESS, not score. A router that only "
|
|
572
|
+
"asks proven\nmodels stops learning about the rest, and they vanish "
|
|
573
|
+
"by silence rather than merit.")
|
|
574
|
+
print("\nROTATION DEPENDS ON RECORDING. The explorers move only when an "
|
|
575
|
+
"offload is written\ndown (metrics/offload.py add). Skip that and "
|
|
576
|
+
"this recommends the same two for ever,\nand the exploring quietly "
|
|
577
|
+
"stops — which is the failure it exists to prevent.")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
main()
|