@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -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 +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -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/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -1,367 +1,367 @@
1
- """Retire a model when the evidence says it is gone, without waiting for a person.
2
-
3
- Called by `check_deprecations.py` every morning, after the 5am availability
4
- probe has written its rows. Until 2026-10-15 the check only raised alerts, and
5
- six "no longer served" alerts sat for eight days with nobody acting on them --
6
- detection worked, removal was the bottleneck.
7
-
8
- THE RULES (decided 2026-10-15)
9
- ----------------------------
10
- - IMMEDIATE, the same day: the latest probe failed `structural` (no such
11
- model), `paywalled` (free tier gone) or `entitlement` (payment required, no
12
- credit, not authorised). Confirmed first by ONE fresh call that must fail
13
- the same way, so a single blip cannot retire a working model.
14
- - AFTER 3 DAYS: missing from its endpoint's /models (the daily served
15
- check's snapshot: Open WebUI's whole list, or the registered models on any
16
- other endpoint), or failing for a reason nothing recognises (`other`) on 3
17
- separate days with no answer since.
18
- - BACKSTOP, AFTER 7 DAYS: no answer of any kind for 7 days, whatever the
19
- reason -- a rate limit that never lifts is a quota or a dead model.
20
- `mistral-medium-3-5` answered 429 to 11 calls out of 11.
21
- - NEVER on a rate limit or timeout alone inside those 7 days.
22
-
23
- "No answer" means from ANY source: the perf probe, a useful offload, or a
24
- suite output that was a real answer. A model that answers in the suite resets
25
- the clock even if the 5am probe was throttled.
26
-
27
- THE CONNECTION GUARD
28
- --------------------
29
- An expired key, an empty balance or a renamed Open WebUI prefix makes EVERY
30
- model on that connection fail at once, and each failure looks like a
31
- retirement. So when every model on a connection (two or more, with evidence)
32
- is down, nothing on it is retired: one alert names the connection and offers
33
- "retire all" for when it really is gone. A one-model connection cannot be told
34
- apart from its model, which is what Restore is for.
35
-
36
- python scripts/auto_retire.py # self-check of the rules
37
- """
38
-
39
- from __future__ import annotations
40
-
41
- import csv
42
- import datetime as dt
43
- import json
44
- import pathlib
45
- import sys
46
-
47
- ROOT = pathlib.Path(__file__).resolve().parent.parent
48
- sys.path.insert(0, str(ROOT / "metrics"))
49
- from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
50
- # Once, at import: evidence() runs on every dashboard render, and used to
51
- # insert on each call.
52
- for _d in (ROOT, ROOT / "metrics", ROOT / "suite"):
53
- if str(_d) not in sys.path:
54
- sys.path.insert(0, str(_d))
55
- import locked # noqa: E402
56
- IMMEDIATE = {"structural", "paywalled", "entitlement"}
57
- MISSING_DAYS, OTHER_DAYS, BACKSTOP_DAYS = 3, 3, 7
58
- PROBE = "Reply with exactly one word: ready"
59
- # see account_quota(); the second is the weekly cap a ChatGPT plan sets per app
60
- ACCOUNT_QUOTAS = ("free-models-per-day", "usage_limit_exceeded")
61
-
62
-
63
- def account_quota(detail: str) -> bool:
64
- """A failure that says nothing about the model: OpenRouter's 50 free calls
65
- a day are shared by every free model on the account, so the model that
66
- meets the 429 is whichever was called after the others spent them (the
67
- suite's own runs, 2026-10-19). Not counted toward retirement."""
68
- return any(marker in detail for marker in ACCOUNT_QUOTAS)
69
-
70
-
71
- def plan(today: dt.date, models: dict[str, str], live: set[str],
72
- first_missing: dict[str, str], rows: list[dict],
73
- answers: dict[str, dt.date], restored: dict[str, dt.date],
74
- tags: dict[str, str], owui: set[str] = frozenset()) -> dict:
75
- """Decide, from evidence alone. No calls, no writes.
76
-
77
- models alias -> served name, every registered model
78
- rows perf rows: {alias, date, answered, failure, detail}
79
- answers alias -> last date it answered outside the perf tier
80
- tags served name -> its connection (route.connection_of)
81
- owui aliases served through an Open WebUI hub, the only place a
82
- connection prefix can rename a model
83
- Returns {"retire": [...], "confirm": [...], "pending": [...],
84
- "held": {connection: [...]}}; each item is (alias, why).
85
- """
86
- out = {"retire": [], "confirm": [], "pending": [], "held": {}}
87
- down: dict[str, bool] = {}
88
- candidates: list[tuple[str, str, str]] = [] # (alias, bucket, why)
89
- for alias, served in models.items():
90
- mine = [r for r in rows if r["alias"] == alias]
91
- last_ok = max([r["date"] for r in mine if r["answered"]]
92
- + ([answers[alias]] if alias in answers else []),
93
- default=None)
94
- floor = max(d for d in (last_ok, restored.get(alias), dt.date.min) if d)
95
- # A refused key (`signin`) is the endpoint's, fixed by signing in again.
96
- fails = sorted((r for r in mine if not r["answered"] and r["failure"] and r["failure"] != "signin"
97
- and r["date"] > floor and not account_quota(r["detail"])),
98
- key=lambda r: r["date"])
99
- missing_since = None
100
- if served not in live and served in first_missing:
101
- missing_since = max(dt.date.fromisoformat(first_missing[served]),
102
- restored.get(alias, dt.date.min))
103
- if mine or missing_since:
104
- down[alias] = bool(missing_since) or not mine[-1]["answered"]
105
-
106
- latest = fails[-1] if fails else None
107
- why = (f"{latest['failure']}: {latest['detail']}".rstrip(": ")
108
- if latest else "")
109
- other_days = {r["date"] for r in fails if r["failure"] == "other"}
110
- span = (today - fails[0]["date"]).days if fails else 0
111
- fail_days = {r["date"] for r in fails}
112
-
113
- # A CONNECTION PREFIX ADDED IN OPEN WEBUI renames every model on it
114
- # (`codestral-latest` -> `llm7.codestral-latest`), and the old names
115
- # vanish. That is an address change, not a retirement, and
116
- # `reprefix.py` repoints the registry with all history intact.
117
- renamed = next((s for s in live if s.endswith("." + served)), None) \
118
- if missing_since and alias in owui else None
119
- if renamed:
120
- out["pending"].append((alias, f"1|served as {renamed} now — run "
121
- f"scripts/reprefix.py, not a retirement"))
122
- continue
123
- where = "Open WebUI" if alias in owui else "its endpoint"
124
- if missing_since and (today - missing_since).days >= MISSING_DAYS:
125
- # Not guarded: three days of absence already outlasts a blip, and
126
- # providers DO retire in batches (three gpt-* on 10-08, three
127
- # mistral-*-latest on 10-07), which a guard would hold for ever.
128
- out["retire"].append((alias,
129
- f"not served by {where} since {missing_since}"))
130
- elif latest and latest["failure"] in IMMEDIATE \
131
- and (today - latest["date"]).days <= 1:
132
- candidates.append((alias, "confirm", why))
133
- elif latest and latest["failure"] == "other" \
134
- and len(other_days) >= OTHER_DAYS:
135
- candidates.append((alias, "retire",
136
- f"unrecognised failure on {len(other_days)} days — {why}"))
137
- elif fails and span >= BACKSTOP_DAYS and len(fail_days) >= 3:
138
- candidates.append((alias, "retire",
139
- f"no answer for {span} days — {why}"))
140
- else:
141
- left = []
142
- if missing_since:
143
- left.append((MISSING_DAYS - (today - missing_since).days,
144
- f"not served by {where}"))
145
- if latest and latest["failure"] == "other":
146
- left.append((OTHER_DAYS - len(other_days), why))
147
- if fails:
148
- left.append((BACKSTOP_DAYS - span, why))
149
- if left:
150
- days, reason = min(left)
151
- out["pending"].append((alias, f"{max(days, 1)}|{reason}"))
152
-
153
- conn_of = {a: (tags.get(s) or "?").split("/")[0] for a, s in models.items()}
154
- for conn in set(conn_of.values()):
155
- evidenced = [a for a in models if conn_of[a] == conn and a in down]
156
- mine = [c for c in candidates if conn_of[c[0]] == conn]
157
- # "?" is not a connection, it is "no tag": a model drops out of
158
- # provider-tags.json when Open WebUI stops listing it, so grouping the
159
- # untagged together would guard strangers against each other.
160
- if mine and conn != "?" and len(evidenced) >= 2 \
161
- and all(down[a] for a in evidenced):
162
- out["held"][conn] = [(a, why) for a, _b, why in mine]
163
- else:
164
- for alias, bucket, why in mine:
165
- out[bucket].append((alias, why))
166
- return out
167
-
168
-
169
- # ---- evidence from the repository ------------------------------------------
170
-
171
- def _current(alias: str, history: dict[str, str]) -> str:
172
- seen = set()
173
- while alias in history and alias not in seen:
174
- seen.add(alias)
175
- alias = history[alias]
176
- return alias
177
-
178
-
179
- def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
180
- """(perf rows, last non-perf answer per alias, restore date per alias)."""
181
- history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
182
- rows = []
183
- path = DATA / "perf" / "history.csv"
184
- if path.exists():
185
- with path.open(encoding="utf-8", newline="") as fh:
186
- for r in csv.DictReader(fh):
187
- rows.append({"alias": _current(r["model"], history),
188
- "date": dt.date.fromisoformat(r["timestamp"][:10]),
189
- "answered": int(r.get("answered") or 0) > 0,
190
- "failure": r.get("failure") or "",
191
- "detail": r.get("detail") or ""})
192
- rows.sort(key=lambda r: r["date"])
193
-
194
- answers: dict[str, dt.date] = {}
195
-
196
- def saw(alias: str, day: dt.date) -> None:
197
- alias = _current(alias, history)
198
- answers[alias] = max(answers.get(alias, day), day)
199
-
200
- offloads = DATA / "metrics" / "offloads.jsonl"
201
- for line in (offloads.read_text(encoding="utf-8").splitlines()
202
- if offloads.exists() else []):
203
- try:
204
- o = json.loads(line)
205
- except ValueError:
206
- continue
207
- if o.get("useful"):
208
- saw(o["model"], dt.date.fromisoformat(o["date"]))
209
-
210
- from run import outcome
211
- for out in DATA.glob("suite/tasks/*/output/*.md"): # the saved answers are the install's data
212
- if outcome(out.read_text(encoding="utf-8", errors="replace")) is None:
213
- saw(out.stem, dt.date.fromtimestamp(out.stat().st_mtime))
214
-
215
- restored: dict[str, dt.date] = {}
216
- changes = DATA / "config" / "changes.jsonl"
217
- for line in (changes.read_text(encoding="utf-8").splitlines()
218
- if changes.exists() else []):
219
- try:
220
- c = json.loads(line)
221
- except ValueError:
222
- continue
223
- if c.get("field") == "restored":
224
- restored[c["alias"]] = dt.date.fromisoformat(c["at"][:10])
225
- return rows, answers, restored
226
-
227
-
228
- def connections(models: dict[str, str]) -> tuple[dict[str, str], set[str]]:
229
- """(snapshot key -> connection, aliases on an Open WebUI hub), for plan.
230
-
231
- The connection is route.connection_of's, the one answer every grouping
232
- uses: the Open WebUI tag, else the endpoint the model is registered on.
233
- Until 2026-10-21 this read provider-tags.json directly and named only
234
- `served@endpoint` models by their endpoint, so on an install without Open
235
- WebUI every model on the default was "?" and the guard never held one."""
236
- import endpoints
237
- import route
238
- data = endpoints.load()
239
- owui = {r["alias"] for r in endpoints.registry()
240
- if r["endpoint"] in data and endpoints.hub(r["endpoint"], data) == "openwebui"}
241
- return {key: route.connection_of(alias) for alias, key in models.items()}, owui
242
-
243
-
244
- def confirm(alias: str) -> tuple[bool, str]:
245
- """One fresh call. Retire only if it fails the same way again."""
246
- from run import ask_once, failure_class, llm_binary
247
- body, _ = ask_once(llm_binary(), alias, PROBE, 90)
248
- cls = failure_class(body, min_length=1)
249
- return cls in IMMEDIATE, f"{cls}: {' '.join(body.split())[:200]}"
250
-
251
-
252
- def run(models: dict[str, str], live: set[str], first_missing: dict[str, str],
253
- dry_run: bool = False) -> tuple[list[str], set[str]]:
254
- """Apply the plan. Returns (report lines, alert keys raised)."""
255
- rows, answers, restored = evidence()
256
- tags, owui = connections(models)
257
- p = plan(dt.date.today(), models, live, first_missing, rows, answers,
258
- restored, tags, owui)
259
-
260
- retire = list(p["retire"])
261
- for alias, why in p["confirm"]:
262
- if dry_run:
263
- retire.append((alias, f"{why} (would confirm with one call)"))
264
- continue
265
- gone, fresh = confirm(alias)
266
- if gone:
267
- retire.append((alias, f"{why} — confirmed {dt.date.today()}: {fresh}"))
268
- else:
269
- p["pending"].append((alias, f"1|failed the 5am probe, answered the "
270
- f"confirmation call ({fresh})"))
271
-
272
- lines, keys = [], set()
273
- import alerts
274
- for alias, why in retire:
275
- if dry_run:
276
- lines.append(f" would retire {alias}: {why}")
277
- continue
278
- import serve
279
- result = serve.remove_model(alias, alias, f"auto: {why}"[:400], who="auto")
280
- lines.append(f" retired {alias}: {why}" if result["ok"]
281
- else f" could not retire {alias}: {'; '.join(result['errors'])}")
282
- for alias, item in p["pending"]:
283
- days, why = item.split("|", 1)
284
- lines.append(f" {alias} retires in {days}d unless it answers — {why}")
285
- if not dry_run:
286
- key = f"model:pending:{models[alias]}"
287
- alerts.raise_alert(key, "retiring",
288
- f"{alias} retires in {days} day{'s' * (days != '1')}"
289
- f" unless it answers", why[:300], alias=alias)
290
- keys.add(key)
291
- for conn, items in p["held"].items():
292
- names = ", ".join(a for a, _ in items)
293
- lines.append(f" HELD on {conn}: every model there is down, so this"
294
- f" looks like the connection — not retiring {names}")
295
- if not dry_run:
296
- key = f"connection:held:{conn}"
297
- alerts.raise_alert(key, "connection",
298
- f"every model on {conn} is failing — likely the"
299
- f" connection (key, credit, prefix), so nothing"
300
- f" was retired", items[0][1][:300],
301
- aliases=[a for a, _ in items])
302
- keys.add(key)
303
- if not dry_run:
304
- alerts.sweep("connection:", keys)
305
- return lines, keys
306
-
307
-
308
- def _selfcheck() -> None:
309
- d = dt.date(2026, 10, 15)
310
- day = lambda n: d - dt.timedelta(days=n) # noqa: E731
311
- row = lambda a, n, f="", ok=False: {"alias": a, "date": day(n), # noqa: E731
312
- "answered": ok, "failure": f, "detail": "x"}
313
- models = {"dead": "v/dead", "paid": "v/paid", "slow": "v/slow",
314
- "quota": "v/quota", "gone": "v/gone", "fine": "v/fine",
315
- "k1": "k/1", "k2": "k/2", "back": "v/back"}
316
- tags = {"v/dead": "v", "v/paid": "v", "v/slow": "v", "v/quota": "v",
317
- "v/gone": "v", "v/fine": "v", "k/1": "k", "k/2": "k", "v/back": "v"}
318
- live = set(models.values()) - {"v/gone"}
319
- rows = ([row("dead", n, "other") for n in (2, 1, 0)]
320
- + [row("paid", 0, "paywalled"), row("slow", 2, "timeout"),
321
- row("slow", 0, "capacity"), row("fine", 0, ok=True)]
322
- + [row("quota", n, "capacity") for n in (8, 5, 0)]
323
- + [row("k1", 0, "entitlement"), row("k2", 0, "entitlement")]
324
- + [row("back", n, "other") for n in (3, 2, 1)])
325
- p = plan(d, models, live, {"v/gone": str(day(3))}, rows, {},
326
- {"back": day(2)}, tags)
327
- # untagged models missing together are retired, not held as one connection
328
- p2 = plan(d, {"a": "x/a", "b": "x/b"}, set(),
329
- {"x/a": str(day(4)), "x/b": str(day(4))}, [], {}, {}, {})
330
- assert {a for a, _ in p2["retire"]} == {"a", "b"} and not p2["held"], p2
331
- # a prefix added in Open WebUI is a rename, never a retirement
332
- p3 = plan(d, {"c": "codestral"}, {"llm7.codestral"},
333
- {"codestral": str(day(9))}, [], {}, {}, {}, {"c"})
334
- assert not p3["retire"] and "reprefix" in dict(p3["pending"])["c"], p3
335
- # ...and only Open WebUI prefixes: elsewhere a model gone for 9 days is gone
336
- p3 = plan(d, {"c": "codestral"}, {"llm7.codestral"},
337
- {"codestral": str(day(9))}, [], {}, {}, {})
338
- assert dict(p3["retire"])["c"].startswith("not served by its endpoint"), p3
339
- retire = {a for a, _ in p["retire"]}
340
- assert retire == {"dead", "quota", "gone"}, retire
341
- assert {a for a, _ in p["confirm"]} == {"paid"}
342
- assert set(p["held"]) == {"k"}, p["held"] # whole connection down
343
- pending = dict(p["pending"])
344
- assert pending["slow"].startswith("5|"), pending # 7-day backstop counting
345
- assert "back" in pending # restored: old rows ignored
346
- # one answer in the suite resets the clock
347
- p = plan(d, models, live, {}, rows, {"quota": day(1)}, {}, tags)
348
- assert "quota" not in {a for a, _ in p["retire"]}
349
- # OpenRouter's account-wide free quota is not the model failing
350
- shared = [dict(row("quota", n, "capacity"),
351
- detail="429 Rate limit exceeded: free-models-per-day") for n in (8, 5, 0)]
352
- p = plan(d, {"quota": "v/quota"}, {"v/quota"}, {}, shared, {}, {}, {"v/quota": "v"})
353
- assert not p["retire"] and not p["pending"], p
354
- # A refused key is the endpoint's, never the model's (suite/run.py SIGNIN)
355
- from run import failure_class
356
- assert failure_class("Error: Error code: 401 - {'error': 'invalid key'}") == "signin"
357
- assert failure_class("Error: Error code: 403 - payment required") == "entitlement"
358
- assert failure_class("Error: Error code: subscription_sharing_usage_unavailable - {}") == "signin"
359
- assert account_quota("Error: Error code: subscription_sharing_usage_limit_exceeded - {}")
360
- refused = [row("pk", n, "signin") for n in (9, 5, 0)]
361
- p = plan(d, {"pk": "v/pk"}, {"v/pk"}, {}, refused, {}, {}, {"v/pk": "v"})
362
- assert not p["retire"] and not p["confirm"] and not p["pending"], p
363
- print("auto_retire: rules hold")
364
-
365
-
366
- if __name__ == "__main__":
367
- _selfcheck()
1
+ """Retire a model when the evidence says it is gone, without waiting for a person.
2
+
3
+ Called by `check_deprecations.py` every morning, after the 5am availability
4
+ probe has written its rows. Until 2026-10-15 the check only raised alerts, and
5
+ six "no longer served" alerts sat for eight days with nobody acting on them --
6
+ detection worked, removal was the bottleneck.
7
+
8
+ THE RULES (decided 2026-10-15)
9
+ ----------------------------
10
+ - IMMEDIATE, the same day: the latest probe failed `structural` (no such
11
+ model), `paywalled` (free tier gone) or `entitlement` (payment required, no
12
+ credit, not authorised). Confirmed first by ONE fresh call that must fail
13
+ the same way, so a single blip cannot retire a working model.
14
+ - AFTER 3 DAYS: missing from its endpoint's /models (the daily served
15
+ check's snapshot: Open WebUI's whole list, or the registered models on any
16
+ other endpoint), or failing for a reason nothing recognises (`other`) on 3
17
+ separate days with no answer since.
18
+ - BACKSTOP, AFTER 7 DAYS: no answer of any kind for 7 days, whatever the
19
+ reason -- a rate limit that never lifts is a quota or a dead model.
20
+ `mistral-medium-3-5` answered 429 to 11 calls out of 11.
21
+ - NEVER on a rate limit or timeout alone inside those 7 days.
22
+
23
+ "No answer" means from ANY source: the perf probe, a useful offload, or a
24
+ suite output that was a real answer. A model that answers in the suite resets
25
+ the clock even if the 5am probe was throttled.
26
+
27
+ THE CONNECTION GUARD
28
+ --------------------
29
+ An expired key, an empty balance or a renamed Open WebUI prefix makes EVERY
30
+ model on that connection fail at once, and each failure looks like a
31
+ retirement. So when every model on a connection (two or more, with evidence)
32
+ is down, nothing on it is retired: one alert names the connection and offers
33
+ "retire all" for when it really is gone. A one-model connection cannot be told
34
+ apart from its model, which is what Restore is for.
35
+
36
+ python scripts/auto_retire.py # self-check of the rules
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import csv
42
+ import datetime as dt
43
+ import json
44
+ import pathlib
45
+ import sys
46
+
47
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
48
+ sys.path.insert(0, str(ROOT / "metrics"))
49
+ from paths import DATA # noqa: E402 -- the install's data folder (metrics/paths.py)
50
+ # Once, at import: evidence() runs on every dashboard render, and used to
51
+ # insert on each call.
52
+ for _d in (ROOT, ROOT / "metrics", ROOT / "suite"):
53
+ if str(_d) not in sys.path:
54
+ sys.path.insert(0, str(_d))
55
+ import locked # noqa: E402
56
+ IMMEDIATE = {"structural", "paywalled", "entitlement"}
57
+ MISSING_DAYS, OTHER_DAYS, BACKSTOP_DAYS = 3, 3, 7
58
+ PROBE = "Reply with exactly one word: ready"
59
+ # see account_quota(); the second is the weekly cap a ChatGPT plan sets per app
60
+ ACCOUNT_QUOTAS = ("free-models-per-day", "usage_limit_exceeded")
61
+
62
+
63
+ def account_quota(detail: str) -> bool:
64
+ """A failure that says nothing about the model: OpenRouter's 50 free calls
65
+ a day are shared by every free model on the account, so the model that
66
+ meets the 429 is whichever was called after the others spent them (the
67
+ suite's own runs, 2026-10-19). Not counted toward retirement."""
68
+ return any(marker in detail for marker in ACCOUNT_QUOTAS)
69
+
70
+
71
+ def plan(today: dt.date, models: dict[str, str], live: set[str],
72
+ first_missing: dict[str, str], rows: list[dict],
73
+ answers: dict[str, dt.date], restored: dict[str, dt.date],
74
+ tags: dict[str, str], owui: set[str] = frozenset()) -> dict:
75
+ """Decide, from evidence alone. No calls, no writes.
76
+
77
+ models alias -> served name, every registered model
78
+ rows perf rows: {alias, date, answered, failure, detail}
79
+ answers alias -> last date it answered outside the perf tier
80
+ tags served name -> its connection (route.connection_of)
81
+ owui aliases served through an Open WebUI hub, the only place a
82
+ connection prefix can rename a model
83
+ Returns {"retire": [...], "confirm": [...], "pending": [...],
84
+ "held": {connection: [...]}}; each item is (alias, why).
85
+ """
86
+ out = {"retire": [], "confirm": [], "pending": [], "held": {}}
87
+ down: dict[str, bool] = {}
88
+ candidates: list[tuple[str, str, str]] = [] # (alias, bucket, why)
89
+ for alias, served in models.items():
90
+ mine = [r for r in rows if r["alias"] == alias]
91
+ last_ok = max([r["date"] for r in mine if r["answered"]]
92
+ + ([answers[alias]] if alias in answers else []),
93
+ default=None)
94
+ floor = max(d for d in (last_ok, restored.get(alias), dt.date.min) if d)
95
+ # A refused key (`signin`) is the endpoint's, fixed by signing in again.
96
+ fails = sorted((r for r in mine if not r["answered"] and r["failure"] and r["failure"] != "signin"
97
+ and r["date"] > floor and not account_quota(r["detail"])),
98
+ key=lambda r: r["date"])
99
+ missing_since = None
100
+ if served not in live and served in first_missing:
101
+ missing_since = max(dt.date.fromisoformat(first_missing[served]),
102
+ restored.get(alias, dt.date.min))
103
+ if mine or missing_since:
104
+ down[alias] = bool(missing_since) or not mine[-1]["answered"]
105
+
106
+ latest = fails[-1] if fails else None
107
+ why = (f"{latest['failure']}: {latest['detail']}".rstrip(": ")
108
+ if latest else "")
109
+ other_days = {r["date"] for r in fails if r["failure"] == "other"}
110
+ span = (today - fails[0]["date"]).days if fails else 0
111
+ fail_days = {r["date"] for r in fails}
112
+
113
+ # A CONNECTION PREFIX ADDED IN OPEN WEBUI renames every model on it
114
+ # (`codestral-latest` -> `llm7.codestral-latest`), and the old names
115
+ # vanish. That is an address change, not a retirement, and
116
+ # `reprefix.py` repoints the registry with all history intact.
117
+ renamed = next((s for s in live if s.endswith("." + served)), None) \
118
+ if missing_since and alias in owui else None
119
+ if renamed:
120
+ out["pending"].append((alias, f"1|served as {renamed} now — run "
121
+ f"scripts/reprefix.py, not a retirement"))
122
+ continue
123
+ where = "Open WebUI" if alias in owui else "its endpoint"
124
+ if missing_since and (today - missing_since).days >= MISSING_DAYS:
125
+ # Not guarded: three days of absence already outlasts a blip, and
126
+ # providers DO retire in batches (three gpt-* on 10-08, three
127
+ # mistral-*-latest on 10-07), which a guard would hold for ever.
128
+ out["retire"].append((alias,
129
+ f"not served by {where} since {missing_since}"))
130
+ elif latest and latest["failure"] in IMMEDIATE \
131
+ and (today - latest["date"]).days <= 1:
132
+ candidates.append((alias, "confirm", why))
133
+ elif latest and latest["failure"] == "other" \
134
+ and len(other_days) >= OTHER_DAYS:
135
+ candidates.append((alias, "retire",
136
+ f"unrecognised failure on {len(other_days)} days — {why}"))
137
+ elif fails and span >= BACKSTOP_DAYS and len(fail_days) >= 3:
138
+ candidates.append((alias, "retire",
139
+ f"no answer for {span} days — {why}"))
140
+ else:
141
+ left = []
142
+ if missing_since:
143
+ left.append((MISSING_DAYS - (today - missing_since).days,
144
+ f"not served by {where}"))
145
+ if latest and latest["failure"] == "other":
146
+ left.append((OTHER_DAYS - len(other_days), why))
147
+ if fails:
148
+ left.append((BACKSTOP_DAYS - span, why))
149
+ if left:
150
+ days, reason = min(left)
151
+ out["pending"].append((alias, f"{max(days, 1)}|{reason}"))
152
+
153
+ conn_of = {a: (tags.get(s) or "?").split("/")[0] for a, s in models.items()}
154
+ for conn in set(conn_of.values()):
155
+ evidenced = [a for a in models if conn_of[a] == conn and a in down]
156
+ mine = [c for c in candidates if conn_of[c[0]] == conn]
157
+ # "?" is not a connection, it is "no tag": a model drops out of
158
+ # provider-tags.json when Open WebUI stops listing it, so grouping the
159
+ # untagged together would guard strangers against each other.
160
+ if mine and conn != "?" and len(evidenced) >= 2 \
161
+ and all(down[a] for a in evidenced):
162
+ out["held"][conn] = [(a, why) for a, _b, why in mine]
163
+ else:
164
+ for alias, bucket, why in mine:
165
+ out[bucket].append((alias, why))
166
+ return out
167
+
168
+
169
+ # ---- evidence from the repository ------------------------------------------
170
+
171
+ def _current(alias: str, history: dict[str, str]) -> str:
172
+ seen = set()
173
+ while alias in history and alias not in seen:
174
+ seen.add(alias)
175
+ alias = history[alias]
176
+ return alias
177
+
178
+
179
+ def evidence() -> tuple[list[dict], dict[str, dt.date], dict[str, dt.date]]:
180
+ """(perf rows, last non-perf answer per alias, restore date per alias)."""
181
+ history = locked.read_json(DATA / "config" / "alias-history.json") # {} until a rename
182
+ rows = []
183
+ path = DATA / "perf" / "history.csv"
184
+ if path.exists():
185
+ with path.open(encoding="utf-8", newline="") as fh:
186
+ for r in csv.DictReader(fh):
187
+ rows.append({"alias": _current(r["model"], history),
188
+ "date": dt.date.fromisoformat(r["timestamp"][:10]),
189
+ "answered": int(r.get("answered") or 0) > 0,
190
+ "failure": r.get("failure") or "",
191
+ "detail": r.get("detail") or ""})
192
+ rows.sort(key=lambda r: r["date"])
193
+
194
+ answers: dict[str, dt.date] = {}
195
+
196
+ def saw(alias: str, day: dt.date) -> None:
197
+ alias = _current(alias, history)
198
+ answers[alias] = max(answers.get(alias, day), day)
199
+
200
+ offloads = DATA / "metrics" / "offloads.jsonl"
201
+ for line in (offloads.read_text(encoding="utf-8").splitlines()
202
+ if offloads.exists() else []):
203
+ try:
204
+ o = json.loads(line)
205
+ except ValueError:
206
+ continue
207
+ if o.get("useful"):
208
+ saw(o["model"], dt.date.fromisoformat(o["date"]))
209
+
210
+ from run import outcome
211
+ for out in DATA.glob("suite/tasks/*/output/*.md"): # the saved answers are the install's data
212
+ if outcome(out.read_text(encoding="utf-8", errors="replace")) is None:
213
+ saw(out.stem, dt.date.fromtimestamp(out.stat().st_mtime))
214
+
215
+ restored: dict[str, dt.date] = {}
216
+ changes = DATA / "config" / "changes.jsonl"
217
+ for line in (changes.read_text(encoding="utf-8").splitlines()
218
+ if changes.exists() else []):
219
+ try:
220
+ c = json.loads(line)
221
+ except ValueError:
222
+ continue
223
+ if c.get("field") == "restored":
224
+ restored[c["alias"]] = dt.date.fromisoformat(c["at"][:10])
225
+ return rows, answers, restored
226
+
227
+
228
+ def connections(models: dict[str, str]) -> tuple[dict[str, str], set[str]]:
229
+ """(snapshot key -> connection, aliases on an Open WebUI hub), for plan.
230
+
231
+ The connection is route.connection_of's, the one answer every grouping
232
+ uses: the Open WebUI tag, else the endpoint the model is registered on.
233
+ Until 2026-10-21 this read provider-tags.json directly and named only
234
+ `served@endpoint` models by their endpoint, so on an install without Open
235
+ WebUI every model on the default was "?" and the guard never held one."""
236
+ import endpoints
237
+ import route
238
+ data = endpoints.load()
239
+ owui = {r["alias"] for r in endpoints.registry()
240
+ if r["endpoint"] in data and endpoints.hub(r["endpoint"], data) == "openwebui"}
241
+ return {key: route.connection_of(alias) for alias, key in models.items()}, owui
242
+
243
+
244
+ def confirm(alias: str) -> tuple[bool, str]:
245
+ """One fresh call. Retire only if it fails the same way again."""
246
+ from run import ask_once, failure_class, llm_binary
247
+ body, _ = ask_once(llm_binary(), alias, PROBE, 90)
248
+ cls = failure_class(body, min_length=1)
249
+ return cls in IMMEDIATE, f"{cls}: {' '.join(body.split())[:200]}"
250
+
251
+
252
+ def run(models: dict[str, str], live: set[str], first_missing: dict[str, str],
253
+ dry_run: bool = False) -> tuple[list[str], set[str]]:
254
+ """Apply the plan. Returns (report lines, alert keys raised)."""
255
+ rows, answers, restored = evidence()
256
+ tags, owui = connections(models)
257
+ p = plan(dt.date.today(), models, live, first_missing, rows, answers,
258
+ restored, tags, owui)
259
+
260
+ retire = list(p["retire"])
261
+ for alias, why in p["confirm"]:
262
+ if dry_run:
263
+ retire.append((alias, f"{why} (would confirm with one call)"))
264
+ continue
265
+ gone, fresh = confirm(alias)
266
+ if gone:
267
+ retire.append((alias, f"{why} — confirmed {dt.date.today()}: {fresh}"))
268
+ else:
269
+ p["pending"].append((alias, f"1|failed the 5am probe, answered the "
270
+ f"confirmation call ({fresh})"))
271
+
272
+ lines, keys = [], set()
273
+ import alerts
274
+ for alias, why in retire:
275
+ if dry_run:
276
+ lines.append(f" would retire {alias}: {why}")
277
+ continue
278
+ import serve
279
+ result = serve.remove_model(alias, f"auto: {why}"[:400], who="auto")
280
+ lines.append(f" retired {alias}: {why}" if result["ok"]
281
+ else f" could not retire {alias}: {'; '.join(result['errors'])}")
282
+ for alias, item in p["pending"]:
283
+ days, why = item.split("|", 1)
284
+ lines.append(f" {alias} retires in {days}d unless it answers — {why}")
285
+ if not dry_run:
286
+ key = f"model:pending:{models[alias]}"
287
+ alerts.raise_alert(key, "retiring",
288
+ f"{alias} retires in {days} day{'s' * (days != '1')}"
289
+ f" unless it answers", why[:300], alias=alias)
290
+ keys.add(key)
291
+ for conn, items in p["held"].items():
292
+ names = ", ".join(a for a, _ in items)
293
+ lines.append(f" HELD on {conn}: every model there is down, so this"
294
+ f" looks like the connection — not retiring {names}")
295
+ if not dry_run:
296
+ key = f"connection:held:{conn}"
297
+ alerts.raise_alert(key, "connection",
298
+ f"every model on {conn} is failing — likely the"
299
+ f" connection (key, credit, prefix), so nothing"
300
+ f" was retired", items[0][1][:300],
301
+ aliases=[a for a, _ in items])
302
+ keys.add(key)
303
+ if not dry_run:
304
+ alerts.sweep("connection:", keys)
305
+ return lines, keys
306
+
307
+
308
+ def _selfcheck() -> None:
309
+ d = dt.date(2026, 10, 15)
310
+ day = lambda n: d - dt.timedelta(days=n) # noqa: E731
311
+ row = lambda a, n, f="", ok=False: {"alias": a, "date": day(n), # noqa: E731
312
+ "answered": ok, "failure": f, "detail": "x"}
313
+ models = {"dead": "v/dead", "paid": "v/paid", "slow": "v/slow",
314
+ "quota": "v/quota", "gone": "v/gone", "fine": "v/fine",
315
+ "k1": "k/1", "k2": "k/2", "back": "v/back"}
316
+ tags = {"v/dead": "v", "v/paid": "v", "v/slow": "v", "v/quota": "v",
317
+ "v/gone": "v", "v/fine": "v", "k/1": "k", "k/2": "k", "v/back": "v"}
318
+ live = set(models.values()) - {"v/gone"}
319
+ rows = ([row("dead", n, "other") for n in (2, 1, 0)]
320
+ + [row("paid", 0, "paywalled"), row("slow", 2, "timeout"),
321
+ row("slow", 0, "capacity"), row("fine", 0, ok=True)]
322
+ + [row("quota", n, "capacity") for n in (8, 5, 0)]
323
+ + [row("k1", 0, "entitlement"), row("k2", 0, "entitlement")]
324
+ + [row("back", n, "other") for n in (3, 2, 1)])
325
+ p = plan(d, models, live, {"v/gone": str(day(3))}, rows, {},
326
+ {"back": day(2)}, tags)
327
+ # untagged models missing together are retired, not held as one connection
328
+ p2 = plan(d, {"a": "x/a", "b": "x/b"}, set(),
329
+ {"x/a": str(day(4)), "x/b": str(day(4))}, [], {}, {}, {})
330
+ assert {a for a, _ in p2["retire"]} == {"a", "b"} and not p2["held"], p2
331
+ # a prefix added in Open WebUI is a rename, never a retirement
332
+ p3 = plan(d, {"c": "codestral"}, {"llm7.codestral"},
333
+ {"codestral": str(day(9))}, [], {}, {}, {}, {"c"})
334
+ assert not p3["retire"] and "reprefix" in dict(p3["pending"])["c"], p3
335
+ # ...and only Open WebUI prefixes: elsewhere a model gone for 9 days is gone
336
+ p3 = plan(d, {"c": "codestral"}, {"llm7.codestral"},
337
+ {"codestral": str(day(9))}, [], {}, {}, {})
338
+ assert dict(p3["retire"])["c"].startswith("not served by its endpoint"), p3
339
+ retire = {a for a, _ in p["retire"]}
340
+ assert retire == {"dead", "quota", "gone"}, retire
341
+ assert {a for a, _ in p["confirm"]} == {"paid"}
342
+ assert set(p["held"]) == {"k"}, p["held"] # whole connection down
343
+ pending = dict(p["pending"])
344
+ assert pending["slow"].startswith("5|"), pending # 7-day backstop counting
345
+ assert "back" in pending # restored: old rows ignored
346
+ # one answer in the suite resets the clock
347
+ p = plan(d, models, live, {}, rows, {"quota": day(1)}, {}, tags)
348
+ assert "quota" not in {a for a, _ in p["retire"]}
349
+ # OpenRouter's account-wide free quota is not the model failing
350
+ shared = [dict(row("quota", n, "capacity"),
351
+ detail="429 Rate limit exceeded: free-models-per-day") for n in (8, 5, 0)]
352
+ p = plan(d, {"quota": "v/quota"}, {"v/quota"}, {}, shared, {}, {}, {"v/quota": "v"})
353
+ assert not p["retire"] and not p["pending"], p
354
+ # A refused key is the endpoint's, never the model's (suite/run.py SIGNIN)
355
+ from run import failure_class
356
+ assert failure_class("Error: Error code: 401 - {'error': 'invalid key'}") == "signin"
357
+ assert failure_class("Error: Error code: 403 - payment required") == "entitlement"
358
+ assert failure_class("Error: Error code: subscription_sharing_usage_unavailable - {}") == "signin"
359
+ assert account_quota("Error: Error code: subscription_sharing_usage_limit_exceeded - {}")
360
+ refused = [row("pk", n, "signin") for n in (9, 5, 0)]
361
+ p = plan(d, {"pk": "v/pk"}, {"v/pk"}, {}, refused, {}, {}, {"v/pk": "v"})
362
+ assert not p["retire"] and not p["confirm"] and not p["pending"], p
363
+ print("auto_retire: rules hold")
364
+
365
+
366
+ if __name__ == "__main__":
367
+ _selfcheck()