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