@wendellresearch-ai/benchwarmers 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/AGENTS.md +147 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +272 -271
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +368 -275
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +170 -155
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -72
  12. package/cli.py +666 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +755 -712
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -0
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +295 -295
  22. package/metrics/client.py +394 -373
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +152 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/guard.py +617 -592
  30. package/metrics/learn.py +221 -221
  31. package/metrics/litellm_hub.py +206 -206
  32. package/metrics/model_roles.py +84 -84
  33. package/metrics/offload.py +166 -166
  34. package/metrics/owui.py +205 -205
  35. package/metrics/paths.py +60 -60
  36. package/metrics/route.py +579 -574
  37. package/metrics/scheduler.py +275 -275
  38. package/metrics/sources.py +167 -167
  39. package/metrics/wrong.py +144 -144
  40. package/package.json +68 -64
  41. package/perf/README.md +89 -89
  42. package/perf/run.py +261 -261
  43. package/scripts/auto_retire.py +367 -367
  44. package/scripts/check.py +108 -104
  45. package/scripts/check_deprecations.py +378 -378
  46. package/scripts/check_themes.py +103 -103
  47. package/scripts/init.py +124 -124
  48. package/scripts/list_served.py +78 -78
  49. package/scripts/notice.py +78 -78
  50. package/scripts/onboard.py +401 -331
  51. package/scripts/provider_tags.py +129 -129
  52. package/scripts/reprefix.py +136 -136
  53. package/scripts/sync_config.py +105 -105
  54. package/scripts/test_auth.py +255 -255
  55. package/scripts/test_cli.py +189 -0
  56. package/scripts/test_client.py +233 -233
  57. package/scripts/test_connect.py +222 -222
  58. package/scripts/test_first_run.py +180 -180
  59. package/scripts/test_hubs.py +328 -328
  60. package/scripts/test_launcher.py +67 -0
  61. package/scripts/test_llm_log.py +85 -0
  62. package/scripts/test_mcp.py +178 -0
  63. package/scripts/test_merged.py +214 -193
  64. package/scripts/test_no_owui.py +291 -291
  65. package/scripts/test_owui.py +90 -90
  66. package/scripts/test_serve_edit.py +448 -386
  67. package/scripts/test_verdict.py +98 -98
  68. package/scripts/test_vision.py +228 -228
  69. package/serve.py +3904 -3662
  70. package/suite/mine.py +154 -154
  71. package/suite/pace.py +145 -145
  72. package/suite/run.py +1046 -1040
  73. package/suite/seed.py +421 -421
  74. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  75. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  76. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  77. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  78. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  79. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  80. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  81. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  82. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  83. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  84. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  85. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  86. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  87. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  88. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  89. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  90. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  91. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  92. package/ui/bench.css +430 -424
  93. package/ui/bench.js +1756 -1555
  94. package/ui/themes/dark.css +35 -35
  95. package/ui/themes/hardwood.css +52 -52
  96. package/ui/themes/rainforest.css +32 -32
@@ -1,378 +1,378 @@
1
- """Notice when a model goes away, instead of finding out when it stops working.
2
-
3
- Providers retire models quietly. The first symptom is usually a task failing
4
- for what looks like a transient reason, which is the worst possible way to
5
- learn it — the failure is indistinguishable from a rate limit, and the model
6
- stays in the routing table being recommended for work it can no longer do.
7
-
8
- This compares three lists and reports the differences:
9
-
10
- what the endpoints SERVE (each live /models list; see served())
11
- what the CLI has REGISTERED (config/extra-openai-models.yaml)
12
- what was served LAST TIME (config/served-snapshot.json)
13
-
14
- python scripts/check_deprecations.py
15
- python scripts/check_deprecations.py --quiet # only problems
16
-
17
- **A model that vanishes is reported with the date it was last seen**, so
18
- "gone" can be told apart from "the endpoint hiccuped this morning". The
19
- snapshot is what makes that possible, and it is committed on purpose: the
20
- history of what was available when is part of the record this repo keeps.
21
-
22
- Exit status is 1 if anything REGISTERED has gone missing, so this can be run
23
- on a schedule and only speak up when it matters.
24
-
25
- SINCE 2026-10-15 IT ALSO ACTS: `scripts/auto_retire.py` retires models that
26
- are clearly gone before anything is reported (rules in its docstring), and
27
- `--dry-run` says what it would retire without retiring or calling anything.
28
- """
29
-
30
- from __future__ import annotations
31
-
32
- import argparse
33
- import datetime as dt
34
- import json
35
- import pathlib
36
- import re
37
- import sys
38
- import urllib.error
39
- import urllib.request
40
-
41
- REPO = pathlib.Path(__file__).resolve().parent.parent
42
- sys.path.insert(0, str(REPO / "metrics"))
43
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
44
- YAML = DATA / "config" / "extra-openai-models.yaml"
45
- SNAPSHOT = DATA / "config" / "served-snapshot.json"
46
- sys.path.insert(0, str(REPO / "metrics"))
47
- import endpoints # noqa: E402
48
- API_BASE = endpoints.base()
49
- TIMEOUT = 30
50
-
51
- # How long a model may be absent before it is called gone rather than a blip.
52
- # One day is enough to survive a restart and short enough to be useful.
53
- GRACE_DAYS = 1
54
-
55
-
56
- def served() -> set[str]:
57
- """What every endpoint serves, as snapshot keys (`endpoints.snapshot_key`).
58
-
59
- The default endpoint is recorded whole when it is a hub or local (the
60
- owner's Open WebUI, as it always was): a list the user chose.
61
- Anything else is recorded only for the models REGISTERED on it --
62
- OpenRouter alone lists 465, and the snapshot is not a mirror of its
63
- catalogue (`endpoints.recorded_whole`), even as a stranger's only and
64
- default endpoint. An endpoint that cannot be asked contributes its
65
- registered models as if present: its outage is not evidence that any of
66
- them went. Only the default being unreachable raises, and the caller
67
- writes nothing.
68
- """
69
- live: set[str] = set()
70
- on = {}
71
- for r in endpoints.registry():
72
- on.setdefault(r["endpoint"], set()).add(r["served"])
73
- for name in endpoints.load():
74
- try:
75
- listed = set(endpoints.models(name, TIMEOUT))
76
- except Exception as exc: # noqa: BLE001
77
- if name == endpoints.default():
78
- raise
79
- print(f"cannot reach {name} ({type(exc).__name__}); its models are "
80
- f"left as they were")
81
- listed = on.get(name, set())
82
- if endpoints.recorded_whole(name):
83
- live |= listed
84
- else:
85
- live |= {endpoints.snapshot_key(s, name) for s in on.get(name, set()) & listed}
86
- return live
87
-
88
-
89
- def registered() -> dict[str, str]:
90
- """snapshot key -> alias, for every registration on every endpoint."""
91
- return {endpoints.snapshot_key(r["served"], r["endpoint"]): r["alias"]
92
- for r in endpoints.registry()}
93
-
94
-
95
- def load_snapshot() -> dict:
96
- if not SNAPSHOT.exists():
97
- return {"last_seen": {}, "first_missing": {}}
98
- return json.loads(SNAPSHOT.read_text(encoding="utf-8"))
99
-
100
-
101
- def main() -> int:
102
- ap = argparse.ArgumentParser(description=__doc__)
103
- ap.add_argument("--quiet", action="store_true",
104
- help="print only problems")
105
- ap.add_argument("--dry-run", action="store_true",
106
- help="say what auto-retire would do; retire nothing, call nothing")
107
- args = ap.parse_args()
108
-
109
- today = dt.date.today().isoformat()
110
- try:
111
- live = served()
112
- except (urllib.error.URLError, OSError, KeyError) as exc:
113
- # An endpoint that cannot be reached says NOTHING about any model, and
114
- # writing a snapshot now would record every model as missing and
115
- # generate a page of false deprecations tomorrow.
116
- print(f"cannot reach {API_BASE}: {exc}")
117
- print("No snapshot written — an unreachable endpoint is not evidence "
118
- "that anything was retired.")
119
- return 2
120
-
121
- known = registered()
122
- snap = load_snapshot()
123
- last_seen, first_missing = snap["last_seen"], snap["first_missing"]
124
-
125
- for name in live:
126
- last_seen[name] = today
127
- first_missing.pop(name, None)
128
-
129
- gone_registered, gone_other, returned = [], [], []
130
- for name in list(last_seen):
131
- if name in live:
132
- continue
133
- first_missing.setdefault(name, today)
134
- days = (dt.date.fromisoformat(today)
135
- - dt.date.fromisoformat(first_missing[name])).days
136
- row = (name, known.get(name), last_seen[name], days)
137
- (gone_registered if name in known else gone_other).append(row)
138
-
139
- # REGISTERED AND NEVER SEEN AT ALL. The loop above can only report a model
140
- # that was in the snapshot, so a registration that was wrong from the
141
- # start — or predates the first snapshot — was invisible here.
142
- #
143
- # `gpt-oss-120b` was exactly that: registered, never served under that
144
- # name, and discovered only when a 16-model suite run recorded it as
145
- # scoring 0/11 on every task. It was returning "Model not found" in four
146
- # seconds. A checker whose whole job is to notice absent models should not
147
- # need a benchmark to find one.
148
- never_seen = sorted(n for n in known if n not in live
149
- and n not in last_seen)
150
- new = sorted(n for n in live if n not in snap["last_seen"])
151
- unregistered = sorted(n for n in live if n not in known)
152
-
153
- # A dry run writes nothing: not the snapshot (it starts the countdowns)
154
- # and not alerts (they put red rows on the dashboard).
155
- if not args.dry_run:
156
- SNAPSHOT.write_text(json.dumps(
157
- {"updated": today, "last_seen": last_seen,
158
- "first_missing": first_missing}, indent=2, sort_keys=True) + "\n",
159
- encoding="utf-8")
160
-
161
- # ACT, THEN REPORT WHAT IS LEFT. Everything below this describes the
162
- # registry as auto-retire leaves it, so a model retired this morning does
163
- # not also appear as a problem for someone to go and fix.
164
- sys.path.insert(0, str(REPO / "scripts"))
165
- import auto_retire
166
- retire_lines, pending_keys = auto_retire.run(
167
- {alias: name for name, alias in known.items()}, live, first_missing,
168
- dry_run=args.dry_run)
169
- if retire_lines:
170
- print("\nAUTO-RETIRE" + (" (dry run)" if args.dry_run else ""))
171
- print("\n".join(retire_lines))
172
- known = registered()
173
- gone_registered = [g for g in gone_registered if g[0] in known]
174
- never_seen = [n for n in never_seen if n in known]
175
- # One row per model on the dashboard: a model counting down to retirement
176
- # already says why, so its older per-kind alert is not raised as well.
177
- counting_down = {k.split(":", 2)[2] for k in pending_keys
178
- if k.startswith("model:pending:")}
179
-
180
- # LISTED, BUT FAILING STRUCTURALLY. The catalogue is the primary signal
181
- # and it is a good one — a throttled or slow model still appears in it, so
182
- # rate limits and timeouts can never be mistaken for a retirement.
183
- #
184
- # The gap is the other direction: a model the provider has retired can go
185
- # on being LISTED until the endpoint's own list catches up. `laguna-m1`
186
- # did exactly that, answering "No endpoints found" while still appearing.
187
- #
188
- # So the second signal is the KIND of failure, recorded by the perf tier.
189
- # `structural` means the model is not there whatever the list says;
190
- # `capacity` and `timeout` are explicitly NOT evidence of anything and are
191
- # never reported here.
192
- structural, entitlement, paywalled, unclassified = [], [], [], []
193
- history = DATA / "perf" / "history.csv"
194
- if history.exists():
195
- import csv
196
- latest: dict[str, str] = {}
197
- with history.open(encoding="utf-8", newline="") as fh:
198
- for row in csv.DictReader(fh):
199
- if row.get("failure"):
200
- latest[row["model"]] = row["failure"]
201
- elif int(row.get("answered") or 0) > 0:
202
- latest.pop(row["model"], None) # it answered: cleared
203
- alias_of = {name: alias for name, alias in known.items()}
204
- for name, alias in alias_of.items():
205
- if name in live and latest.get(alias) == "structural":
206
- structural.append((name, alias))
207
- if name in live and latest.get(alias) == "entitlement":
208
- entitlement.append((name, alias))
209
- # A `:free` slug that retired behind a paid one. Counted as a
210
- # RETIREMENT here, not a plan question: this bench does not buy
211
- # paid tiers, so a model that costs money is as gone as one that
212
- # was deleted, and it should stop occupying a registration.
213
- if name in live and latest.get(alias) == "paywalled":
214
- paywalled.append((name, alias))
215
- # FAILING FOR A REASON NOTHING HERE RECOGNISES. `other` means
216
- # `failure_class` found no marker, and until now that was silently
217
- # dropped -- which is how `minimax-m3-free` went unreported for a
218
- # day and `glm-5.2-free` for longer. Both were a wording nobody had
219
- # seen before, and the only way to learn that is to be told.
220
- # Surfaced, not counted: an unrecognised message is a prompt to go
221
- # and look, not proof of a retirement, and the alert sweeps itself
222
- # away as soon as the model answers again.
223
- if name in live and latest.get(alias) == "other":
224
- unclassified.append((name, alias))
225
-
226
- # RAISE WHAT IS STILL WRONG, AND RETIRE WHAT IS NOT. Every problem found
227
- # here is a state somebody has to decide about, and until now it was
228
- # announced only by a non-zero exit code that Windows recorded and nobody
229
- # read. `sweep` clears anything no longer found, so a model that comes back
230
- # does not leave a red row behind it.
231
- try:
232
- sys.path.insert(0, str(REPO / "metrics"))
233
- import alerts as _alerts
234
- raise_alert = (lambda *a, **k: None) if args.dry_run else _alerts.raise_alert
235
- raised = set(pending_keys)
236
- for name, alias in never_seen and [(n, known[n]) for n in never_seen] or []:
237
- key = f"model:never-served:{name}"
238
- raise_alert(
239
- key, "registration",
240
- f"{name} is registered but the endpoint has never served it",
241
- f"fix or drop the entry for `{alias}` in "
242
- f"config/extra-openai-models.yaml")
243
- raised.add(key)
244
- data = endpoints.load()
245
- for name, alias, seen, days in gone_registered:
246
- key = f"model:gone:{name}"
247
- served_as, _, ep = name.rpartition("@") # bare: on the default (a model may be named like an endpoint)
248
- hub = endpoints.hub(ep if served_as and ep in data else endpoints.default(), data)
249
- raise_alert(
250
- key, "retired",
251
- f"{name} is no longer served (last seen {seen}, {days}d ago)",
252
- f"remove it from {'Open WebUI and from ' if hub == 'openwebui' else ''}the yaml")
253
- raised.add(key)
254
- for name, alias in structural:
255
- key = f"model:structural:{name}"
256
- raise_alert(
257
- key, "retired",
258
- f"{name} is listed but every call fails structurally",
259
- "verify, then remove — the catalogue has not caught up")
260
- raised.add(key)
261
- for name, alias in entitlement:
262
- key = f"model:entitlement:{name}"
263
- raise_alert(
264
- key, "plan",
265
- f"{name} is served but this account may not call it",
266
- "keep registered and watch — a pricing change would clear this")
267
- raised.add(key)
268
- for name, alias in unclassified:
269
- key = f"model:unclassified:{name}"
270
- raise_alert(
271
- key, "registration",
272
- f"{name} is failing for a reason this check does not recognise",
273
- f"call it once and read the message — `llm -m {alias} hi` — "
274
- f"then add a marker to the right class in suite/run.py")
275
- raised.add(key)
276
- for name, alias in paywalled:
277
- key = f"model:paywalled:{name}"
278
- raise_alert(
279
- key, "retired",
280
- f"{name} has retired its free tier behind a paid slug",
281
- "re-point the yaml at the paid slug and pay for it, or drop "
282
- "the entry — waiting will not bring the free one back")
283
- raised.add(key)
284
- raised = {k for k in raised if k.startswith("model:pending:")
285
- or k.split(":", 2)[2] not in counting_down}
286
- if not args.dry_run:
287
- _alerts.sweep("model:", raised)
288
- except Exception: # noqa: BLE001
289
- pass # alerting is not the job
290
-
291
- problems = 0
292
- if structural:
293
- print("\nLISTED BUT NOT ACTUALLY THERE")
294
- print(" Still in the endpoint's catalogue, but every recent call"
295
- " failed structurally\n (no such model, no provider, or not"
296
- " permitted) rather than being throttled.\n A retirement the"
297
- " list has not caught up with — verify before removing.")
298
- for name, alias in sorted(structural):
299
- print(f" - {name} [llm id: {alias}]")
300
- # COUNTED, since 2026-09-09. The exit code is how the scheduled task
301
- # surfaces a problem at all -- nobody reads the task's stdout -- and a
302
- # model that is listed while every call fails structurally is a
303
- # retirement the catalogue has not caught up with. Leaving it out of
304
- # the count meant the one signal that reaches anyone stayed green.
305
- problems += len(structural)
306
-
307
- if paywalled:
308
- print("\nFREE TIER RETIRED BEHIND A PAID SLUG")
309
- print(" Still in the catalogue and still reachable — but only for"
310
- " money, and this bench\n does not buy paid tiers for this"
311
- " work. That makes it a RETIREMENT for our purposes:\n"
312
- " waiting will not bring the free endpoint back, so re-point"
313
- " the registration at\n the paid slug and pay for it, or drop"
314
- " the entry.")
315
- for name, alias in sorted(paywalled):
316
- print(f" - {name} [llm id: {alias}]")
317
- problems += len(paywalled)
318
-
319
- if unclassified and not args.quiet:
320
- print("\nFAILING FOR A REASON THIS CHECK DOES NOT RECOGNISE")
321
- print(" Every recent call failed, but the message matched no marker"
322
- " in suite/run.py, so\n the failure could not be filed as"
323
- " retired, paywalled, throttled or a plan\n problem. NOT"
324
- " counted as a retirement — it is a prompt to go and read the"
325
- " message.\n Call one by hand, then add a marker so the next"
326
- " model with that wording is caught.")
327
- for name, alias in sorted(unclassified):
328
- print(f" - {name} [llm id: {alias}]")
329
-
330
- if entitlement and not args.quiet:
331
- print("\nSERVED, BUT THIS ACCOUNT MAY NOT CALL THEM")
332
- print(" Present and working — the plan does not include them. Not a"
333
- " retirement,\n and not something to remove: keep them"
334
- " registered to notice if that changes.")
335
- for name, alias in sorted(entitlement):
336
- print(f" - {name} [llm id: {alias}]")
337
-
338
- if never_seen:
339
- print("\nREGISTERED BUT NEVER SERVED")
340
- print(" The endpoint has no model by this name — the registration is"
341
- " wrong, not the model.\n Fix the name in"
342
- " config/extra-openai-models.yaml or drop the entry.")
343
- for name in never_seen:
344
- print(f" - {name} [llm id: {known[name]}]")
345
- problems += len(never_seen)
346
-
347
- if gone_registered:
348
- print("\nREGISTERED MODELS NO LONGER SERVED")
349
- print(" These are in the routing table and the endpoint no longer "
350
- "offers them.\n Auto-retire removes them after 3 days missing;"
351
- " the dashboard can retire them sooner.")
352
- for name, alias, seen, days in sorted(gone_registered):
353
- hedge = "" if days >= GRACE_DAYS else " (first miss today — "\
354
- "could still be a blip)"
355
- print(f" - {name} [llm id: {alias}]"
356
- f"\n last seen {seen}, missing {days}d{hedge}")
357
- problems += len(gone_registered)
358
-
359
- if gone_other and not args.quiet:
360
- print("\nUnregistered models that disappeared (informational)")
361
- for name, _alias, seen, days in sorted(gone_other):
362
- print(f" - {name} last seen {seen}, missing {days}d")
363
-
364
- if new and not args.quiet:
365
- print("\nNEW since the last check")
366
- for name in new:
367
- print(f" + {name}")
368
-
369
- if not args.quiet:
370
- print(f"\n{len(live)} served, {len(known)} registered, "
371
- f"{len(unregistered)} served-but-unregistered")
372
- if not problems and not args.quiet:
373
- print("No registered model has gone missing.")
374
- return 1 if problems else 0
375
-
376
-
377
- if __name__ == "__main__":
378
- sys.exit(main())
1
+ """Notice when a model goes away, instead of finding out when it stops working.
2
+
3
+ Providers retire models quietly. The first symptom is usually a task failing
4
+ for what looks like a transient reason, which is the worst possible way to
5
+ learn it — the failure is indistinguishable from a rate limit, and the model
6
+ stays in the routing table being recommended for work it can no longer do.
7
+
8
+ This compares three lists and reports the differences:
9
+
10
+ what the endpoints SERVE (each live /models list; see served())
11
+ what the CLI has REGISTERED (config/extra-openai-models.yaml)
12
+ what was served LAST TIME (config/served-snapshot.json)
13
+
14
+ python scripts/check_deprecations.py
15
+ python scripts/check_deprecations.py --quiet # only problems
16
+
17
+ **A model that vanishes is reported with the date it was last seen**, so
18
+ "gone" can be told apart from "the endpoint hiccuped this morning". The
19
+ snapshot is what makes that possible, and it is committed on purpose: the
20
+ history of what was available when is part of the record this repo keeps.
21
+
22
+ Exit status is 1 if anything REGISTERED has gone missing, so this can be run
23
+ on a schedule and only speak up when it matters.
24
+
25
+ SINCE 2026-10-15 IT ALSO ACTS: `scripts/auto_retire.py` retires models that
26
+ are clearly gone before anything is reported (rules in its docstring), and
27
+ `--dry-run` says what it would retire without retiring or calling anything.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import datetime as dt
34
+ import json
35
+ import pathlib
36
+ import re
37
+ import sys
38
+ import urllib.error
39
+ import urllib.request
40
+
41
+ REPO = pathlib.Path(__file__).resolve().parent.parent
42
+ sys.path.insert(0, str(REPO / "metrics"))
43
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
44
+ YAML = DATA / "config" / "extra-openai-models.yaml"
45
+ SNAPSHOT = DATA / "config" / "served-snapshot.json"
46
+ sys.path.insert(0, str(REPO / "metrics"))
47
+ import endpoints # noqa: E402
48
+ API_BASE = endpoints.base()
49
+ TIMEOUT = 30
50
+
51
+ # How long a model may be absent before it is called gone rather than a blip.
52
+ # One day is enough to survive a restart and short enough to be useful.
53
+ GRACE_DAYS = 1
54
+
55
+
56
+ def served() -> set[str]:
57
+ """What every endpoint serves, as snapshot keys (`endpoints.snapshot_key`).
58
+
59
+ The default endpoint is recorded whole when it is a hub or local (the
60
+ owner's Open WebUI, as it always was): a list the user chose.
61
+ Anything else is recorded only for the models REGISTERED on it --
62
+ OpenRouter alone lists 465, and the snapshot is not a mirror of its
63
+ catalogue (`endpoints.recorded_whole`), even as a stranger's only and
64
+ default endpoint. An endpoint that cannot be asked contributes its
65
+ registered models as if present: its outage is not evidence that any of
66
+ them went. Only the default being unreachable raises, and the caller
67
+ writes nothing.
68
+ """
69
+ live: set[str] = set()
70
+ on = {}
71
+ for r in endpoints.registry():
72
+ on.setdefault(r["endpoint"], set()).add(r["served"])
73
+ for name in endpoints.load():
74
+ try:
75
+ listed = set(endpoints.models(name, TIMEOUT))
76
+ except Exception as exc: # noqa: BLE001
77
+ if name == endpoints.default():
78
+ raise
79
+ print(f"cannot reach {name} ({type(exc).__name__}); its models are "
80
+ f"left as they were")
81
+ listed = on.get(name, set())
82
+ if endpoints.recorded_whole(name):
83
+ live |= listed
84
+ else:
85
+ live |= {endpoints.snapshot_key(s, name) for s in on.get(name, set()) & listed}
86
+ return live
87
+
88
+
89
+ def registered() -> dict[str, str]:
90
+ """snapshot key -> alias, for every registration on every endpoint."""
91
+ return {endpoints.snapshot_key(r["served"], r["endpoint"]): r["alias"]
92
+ for r in endpoints.registry()}
93
+
94
+
95
+ def load_snapshot() -> dict:
96
+ if not SNAPSHOT.exists():
97
+ return {"last_seen": {}, "first_missing": {}}
98
+ return json.loads(SNAPSHOT.read_text(encoding="utf-8"))
99
+
100
+
101
+ def main() -> int:
102
+ ap = argparse.ArgumentParser(description=__doc__)
103
+ ap.add_argument("--quiet", action="store_true",
104
+ help="print only problems")
105
+ ap.add_argument("--dry-run", action="store_true",
106
+ help="say what auto-retire would do; retire nothing, call nothing")
107
+ args = ap.parse_args()
108
+
109
+ today = dt.date.today().isoformat()
110
+ try:
111
+ live = served()
112
+ except (urllib.error.URLError, OSError, KeyError) as exc:
113
+ # An endpoint that cannot be reached says NOTHING about any model, and
114
+ # writing a snapshot now would record every model as missing and
115
+ # generate a page of false deprecations tomorrow.
116
+ print(f"cannot reach {API_BASE}: {exc}")
117
+ print("No snapshot written — an unreachable endpoint is not evidence "
118
+ "that anything was retired.")
119
+ return 2
120
+
121
+ known = registered()
122
+ snap = load_snapshot()
123
+ last_seen, first_missing = snap["last_seen"], snap["first_missing"]
124
+
125
+ for name in live:
126
+ last_seen[name] = today
127
+ first_missing.pop(name, None)
128
+
129
+ gone_registered, gone_other, returned = [], [], []
130
+ for name in list(last_seen):
131
+ if name in live:
132
+ continue
133
+ first_missing.setdefault(name, today)
134
+ days = (dt.date.fromisoformat(today)
135
+ - dt.date.fromisoformat(first_missing[name])).days
136
+ row = (name, known.get(name), last_seen[name], days)
137
+ (gone_registered if name in known else gone_other).append(row)
138
+
139
+ # REGISTERED AND NEVER SEEN AT ALL. The loop above can only report a model
140
+ # that was in the snapshot, so a registration that was wrong from the
141
+ # start — or predates the first snapshot — was invisible here.
142
+ #
143
+ # `gpt-oss-120b` was exactly that: registered, never served under that
144
+ # name, and discovered only when a 16-model suite run recorded it as
145
+ # scoring 0/11 on every task. It was returning "Model not found" in four
146
+ # seconds. A checker whose whole job is to notice absent models should not
147
+ # need a benchmark to find one.
148
+ never_seen = sorted(n for n in known if n not in live
149
+ and n not in last_seen)
150
+ new = sorted(n for n in live if n not in snap["last_seen"])
151
+ unregistered = sorted(n for n in live if n not in known)
152
+
153
+ # A dry run writes nothing: not the snapshot (it starts the countdowns)
154
+ # and not alerts (they put red rows on the dashboard).
155
+ if not args.dry_run:
156
+ SNAPSHOT.write_text(json.dumps(
157
+ {"updated": today, "last_seen": last_seen,
158
+ "first_missing": first_missing}, indent=2, sort_keys=True) + "\n",
159
+ encoding="utf-8")
160
+
161
+ # ACT, THEN REPORT WHAT IS LEFT. Everything below this describes the
162
+ # registry as auto-retire leaves it, so a model retired this morning does
163
+ # not also appear as a problem for someone to go and fix.
164
+ sys.path.insert(0, str(REPO / "scripts"))
165
+ import auto_retire
166
+ retire_lines, pending_keys = auto_retire.run(
167
+ {alias: name for name, alias in known.items()}, live, first_missing,
168
+ dry_run=args.dry_run)
169
+ if retire_lines:
170
+ print("\nAUTO-RETIRE" + (" (dry run)" if args.dry_run else ""))
171
+ print("\n".join(retire_lines))
172
+ known = registered()
173
+ gone_registered = [g for g in gone_registered if g[0] in known]
174
+ never_seen = [n for n in never_seen if n in known]
175
+ # One row per model on the dashboard: a model counting down to retirement
176
+ # already says why, so its older per-kind alert is not raised as well.
177
+ counting_down = {k.split(":", 2)[2] for k in pending_keys
178
+ if k.startswith("model:pending:")}
179
+
180
+ # LISTED, BUT FAILING STRUCTURALLY. The catalogue is the primary signal
181
+ # and it is a good one — a throttled or slow model still appears in it, so
182
+ # rate limits and timeouts can never be mistaken for a retirement.
183
+ #
184
+ # The gap is the other direction: a model the provider has retired can go
185
+ # on being LISTED until the endpoint's own list catches up. `laguna-m1`
186
+ # did exactly that, answering "No endpoints found" while still appearing.
187
+ #
188
+ # So the second signal is the KIND of failure, recorded by the perf tier.
189
+ # `structural` means the model is not there whatever the list says;
190
+ # `capacity` and `timeout` are explicitly NOT evidence of anything and are
191
+ # never reported here.
192
+ structural, entitlement, paywalled, unclassified = [], [], [], []
193
+ history = DATA / "perf" / "history.csv"
194
+ if history.exists():
195
+ import csv
196
+ latest: dict[str, str] = {}
197
+ with history.open(encoding="utf-8", newline="") as fh:
198
+ for row in csv.DictReader(fh):
199
+ if row.get("failure"):
200
+ latest[row["model"]] = row["failure"]
201
+ elif int(row.get("answered") or 0) > 0:
202
+ latest.pop(row["model"], None) # it answered: cleared
203
+ alias_of = {name: alias for name, alias in known.items()}
204
+ for name, alias in alias_of.items():
205
+ if name in live and latest.get(alias) == "structural":
206
+ structural.append((name, alias))
207
+ if name in live and latest.get(alias) == "entitlement":
208
+ entitlement.append((name, alias))
209
+ # A `:free` slug that retired behind a paid one. Counted as a
210
+ # RETIREMENT here, not a plan question: this bench does not buy
211
+ # paid tiers, so a model that costs money is as gone as one that
212
+ # was deleted, and it should stop occupying a registration.
213
+ if name in live and latest.get(alias) == "paywalled":
214
+ paywalled.append((name, alias))
215
+ # FAILING FOR A REASON NOTHING HERE RECOGNISES. `other` means
216
+ # `failure_class` found no marker, and until now that was silently
217
+ # dropped -- which is how `minimax-m3-free` went unreported for a
218
+ # day and `glm-5.2-free` for longer. Both were a wording nobody had
219
+ # seen before, and the only way to learn that is to be told.
220
+ # Surfaced, not counted: an unrecognised message is a prompt to go
221
+ # and look, not proof of a retirement, and the alert sweeps itself
222
+ # away as soon as the model answers again.
223
+ if name in live and latest.get(alias) == "other":
224
+ unclassified.append((name, alias))
225
+
226
+ # RAISE WHAT IS STILL WRONG, AND RETIRE WHAT IS NOT. Every problem found
227
+ # here is a state somebody has to decide about, and until now it was
228
+ # announced only by a non-zero exit code that Windows recorded and nobody
229
+ # read. `sweep` clears anything no longer found, so a model that comes back
230
+ # does not leave a red row behind it.
231
+ try:
232
+ sys.path.insert(0, str(REPO / "metrics"))
233
+ import alerts as _alerts
234
+ raise_alert = (lambda *a, **k: None) if args.dry_run else _alerts.raise_alert
235
+ raised = set(pending_keys)
236
+ for name, alias in never_seen and [(n, known[n]) for n in never_seen] or []:
237
+ key = f"model:never-served:{name}"
238
+ raise_alert(
239
+ key, "registration",
240
+ f"{name} is registered but the endpoint has never served it",
241
+ f"fix or drop the entry for `{alias}` in "
242
+ f"config/extra-openai-models.yaml")
243
+ raised.add(key)
244
+ data = endpoints.load()
245
+ for name, alias, seen, days in gone_registered:
246
+ key = f"model:gone:{name}"
247
+ served_as, _, ep = name.rpartition("@") # bare: on the default (a model may be named like an endpoint)
248
+ hub = endpoints.hub(ep if served_as and ep in data else endpoints.default(), data)
249
+ raise_alert(
250
+ key, "retired",
251
+ f"{name} is no longer served (last seen {seen}, {days}d ago)",
252
+ f"remove it from {'Open WebUI and from ' if hub == 'openwebui' else ''}the yaml")
253
+ raised.add(key)
254
+ for name, alias in structural:
255
+ key = f"model:structural:{name}"
256
+ raise_alert(
257
+ key, "retired",
258
+ f"{name} is listed but every call fails structurally",
259
+ "verify, then remove — the catalogue has not caught up")
260
+ raised.add(key)
261
+ for name, alias in entitlement:
262
+ key = f"model:entitlement:{name}"
263
+ raise_alert(
264
+ key, "plan",
265
+ f"{name} is served but this account may not call it",
266
+ "keep registered and watch — a pricing change would clear this")
267
+ raised.add(key)
268
+ for name, alias in unclassified:
269
+ key = f"model:unclassified:{name}"
270
+ raise_alert(
271
+ key, "registration",
272
+ f"{name} is failing for a reason this check does not recognise",
273
+ f"call it once and read the message — `llm -m {alias} hi` — "
274
+ f"then add a marker to the right class in suite/run.py")
275
+ raised.add(key)
276
+ for name, alias in paywalled:
277
+ key = f"model:paywalled:{name}"
278
+ raise_alert(
279
+ key, "retired",
280
+ f"{name} has retired its free tier behind a paid slug",
281
+ "re-point the yaml at the paid slug and pay for it, or drop "
282
+ "the entry — waiting will not bring the free one back")
283
+ raised.add(key)
284
+ raised = {k for k in raised if k.startswith("model:pending:")
285
+ or k.split(":", 2)[2] not in counting_down}
286
+ if not args.dry_run:
287
+ _alerts.sweep("model:", raised)
288
+ except Exception: # noqa: BLE001
289
+ pass # alerting is not the job
290
+
291
+ problems = 0
292
+ if structural:
293
+ print("\nLISTED BUT NOT ACTUALLY THERE")
294
+ print(" Still in the endpoint's catalogue, but every recent call"
295
+ " failed structurally\n (no such model, no provider, or not"
296
+ " permitted) rather than being throttled.\n A retirement the"
297
+ " list has not caught up with — verify before removing.")
298
+ for name, alias in sorted(structural):
299
+ print(f" - {name} [llm id: {alias}]")
300
+ # COUNTED, since 2026-09-09. The exit code is how the scheduled task
301
+ # surfaces a problem at all -- nobody reads the task's stdout -- and a
302
+ # model that is listed while every call fails structurally is a
303
+ # retirement the catalogue has not caught up with. Leaving it out of
304
+ # the count meant the one signal that reaches anyone stayed green.
305
+ problems += len(structural)
306
+
307
+ if paywalled:
308
+ print("\nFREE TIER RETIRED BEHIND A PAID SLUG")
309
+ print(" Still in the catalogue and still reachable — but only for"
310
+ " money, and this bench\n does not buy paid tiers for this"
311
+ " work. That makes it a RETIREMENT for our purposes:\n"
312
+ " waiting will not bring the free endpoint back, so re-point"
313
+ " the registration at\n the paid slug and pay for it, or drop"
314
+ " the entry.")
315
+ for name, alias in sorted(paywalled):
316
+ print(f" - {name} [llm id: {alias}]")
317
+ problems += len(paywalled)
318
+
319
+ if unclassified and not args.quiet:
320
+ print("\nFAILING FOR A REASON THIS CHECK DOES NOT RECOGNISE")
321
+ print(" Every recent call failed, but the message matched no marker"
322
+ " in suite/run.py, so\n the failure could not be filed as"
323
+ " retired, paywalled, throttled or a plan\n problem. NOT"
324
+ " counted as a retirement — it is a prompt to go and read the"
325
+ " message.\n Call one by hand, then add a marker so the next"
326
+ " model with that wording is caught.")
327
+ for name, alias in sorted(unclassified):
328
+ print(f" - {name} [llm id: {alias}]")
329
+
330
+ if entitlement and not args.quiet:
331
+ print("\nSERVED, BUT THIS ACCOUNT MAY NOT CALL THEM")
332
+ print(" Present and working — the plan does not include them. Not a"
333
+ " retirement,\n and not something to remove: keep them"
334
+ " registered to notice if that changes.")
335
+ for name, alias in sorted(entitlement):
336
+ print(f" - {name} [llm id: {alias}]")
337
+
338
+ if never_seen:
339
+ print("\nREGISTERED BUT NEVER SERVED")
340
+ print(" The endpoint has no model by this name — the registration is"
341
+ " wrong, not the model.\n Fix the name in"
342
+ " config/extra-openai-models.yaml or drop the entry.")
343
+ for name in never_seen:
344
+ print(f" - {name} [llm id: {known[name]}]")
345
+ problems += len(never_seen)
346
+
347
+ if gone_registered:
348
+ print("\nREGISTERED MODELS NO LONGER SERVED")
349
+ print(" These are in the routing table and the endpoint no longer "
350
+ "offers them.\n Auto-retire removes them after 3 days missing;"
351
+ " the dashboard can retire them sooner.")
352
+ for name, alias, seen, days in sorted(gone_registered):
353
+ hedge = "" if days >= GRACE_DAYS else " (first miss today — "\
354
+ "could still be a blip)"
355
+ print(f" - {name} [llm id: {alias}]"
356
+ f"\n last seen {seen}, missing {days}d{hedge}")
357
+ problems += len(gone_registered)
358
+
359
+ if gone_other and not args.quiet:
360
+ print("\nUnregistered models that disappeared (informational)")
361
+ for name, _alias, seen, days in sorted(gone_other):
362
+ print(f" - {name} last seen {seen}, missing {days}d")
363
+
364
+ if new and not args.quiet:
365
+ print("\nNEW since the last check")
366
+ for name in new:
367
+ print(f" + {name}")
368
+
369
+ if not args.quiet:
370
+ print(f"\n{len(live)} served, {len(known)} registered, "
371
+ f"{len(unregistered)} served-but-unregistered")
372
+ if not problems and not args.quiet:
373
+ print("No registered model has gone missing.")
374
+ return 1 if problems else 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ sys.exit(main())