@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/scripts/auto_retire.py
CHANGED
|
@@ -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,
|
|
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()
|