@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
|
@@ -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())
|