poolprobe 1.0.0__py3-none-any.whl
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.
- poolprobe/__init__.py +137 -0
- poolprobe/__main__.py +8 -0
- poolprobe/adapter.py +525 -0
- poolprobe/bench.py +368 -0
- poolprobe/chains.py +414 -0
- poolprobe/cli.py +1008 -0
- poolprobe/probe.py +352 -0
- poolprobe/report.py +1096 -0
- poolprobe/safewrite.py +66 -0
- poolprobe/state.py +272 -0
- poolprobe/taxonomy.py +312 -0
- poolprobe-1.0.0.dist-info/METADATA +259 -0
- poolprobe-1.0.0.dist-info/RECORD +17 -0
- poolprobe-1.0.0.dist-info/WHEEL +5 -0
- poolprobe-1.0.0.dist-info/entry_points.txt +2 -0
- poolprobe-1.0.0.dist-info/licenses/LICENSE +21 -0
- poolprobe-1.0.0.dist-info/top_level.txt +1 -0
poolprobe/safewrite.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""poolprobe.safewrite — one atomic write path for every file this tool emits.
|
|
2
|
+
|
|
3
|
+
A report written halfway is worse than no report: the reader sees a truncated
|
|
4
|
+
HTML page or a JSON document that stops mid-object and concludes the run broke.
|
|
5
|
+
Every write in the package goes through here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
__all__ = ["write_atomic"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_atomic(path: str | Path, text: str, *, backup: bool = True) -> Path:
|
|
18
|
+
"""Write ``text`` to ``path`` with no window where the file is half-there.
|
|
19
|
+
|
|
20
|
+
The temp file lives in the same directory so ``os.replace`` stays atomic
|
|
21
|
+
(same filesystem). The rotation copies rather than renames: a reader that
|
|
22
|
+
opens the path mid-write must never find it missing.
|
|
23
|
+
"""
|
|
24
|
+
target = Path(path).expanduser()
|
|
25
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
temporary = target.with_name(target.name + ".tmp")
|
|
27
|
+
try:
|
|
28
|
+
temporary.write_text(text, encoding="utf-8")
|
|
29
|
+
if backup and target.exists():
|
|
30
|
+
try:
|
|
31
|
+
shutil.copy2(target, target.with_name(target.name + ".bak"))
|
|
32
|
+
except OSError:
|
|
33
|
+
pass # a missing backup is never worth failing the write
|
|
34
|
+
os.replace(temporary, target)
|
|
35
|
+
finally:
|
|
36
|
+
temporary.unlink(missing_ok=True)
|
|
37
|
+
return target
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def selfcheck() -> None:
|
|
41
|
+
"""Offline asserts. Run: ``python -m poolprobe.safewrite``."""
|
|
42
|
+
import tempfile
|
|
43
|
+
|
|
44
|
+
with tempfile.TemporaryDirectory() as workdir:
|
|
45
|
+
target = Path(workdir) / "nested" / "report.json"
|
|
46
|
+
write_atomic(target, '{"first": true}')
|
|
47
|
+
assert target.read_text(encoding="utf-8") == '{"first": true}'
|
|
48
|
+
assert not target.with_name(target.name + ".bak").exists()
|
|
49
|
+
|
|
50
|
+
write_atomic(target, '{"second": true}')
|
|
51
|
+
assert target.read_text(encoding="utf-8") == '{"second": true}'
|
|
52
|
+
backup = target.with_name(target.name + ".bak")
|
|
53
|
+
assert backup.read_text(encoding="utf-8") == '{"first": true}'
|
|
54
|
+
|
|
55
|
+
# no leftovers, and backup rotation is opt-out
|
|
56
|
+
write_atomic(target, '{"third": true}', backup=False)
|
|
57
|
+
assert backup.read_text(encoding="utf-8") == '{"first": true}'
|
|
58
|
+
assert sorted(p.name for p in target.parent.iterdir()) == [
|
|
59
|
+
"report.json",
|
|
60
|
+
"report.json.bak",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
selfcheck()
|
|
66
|
+
print("safewrite selfcheck ok")
|
poolprobe/state.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Streak memory: the difference between "failed once" and "failed for a week".
|
|
2
|
+
|
|
3
|
+
No run in isolation can tell a flaky upstream from a dead one, and nothing in
|
|
4
|
+
this package is allowed to delete a model on a single observation. So verdicts
|
|
5
|
+
are persisted between runs and the prune gate reads the *streak*, not the last
|
|
6
|
+
result.
|
|
7
|
+
|
|
8
|
+
Two properties are load-bearing:
|
|
9
|
+
|
|
10
|
+
* **Fail-open on load.** A missing, unreadable or corrupt state file starts
|
|
11
|
+
from zero streaks. That is safe in exactly one direction: a zeroed streak sits
|
|
12
|
+
below ``streak_min``, so losing the state can never cause a prune. Failing
|
|
13
|
+
closed instead would mean refusing to run — which hides a real outage behind a
|
|
14
|
+
bookkeeping error.
|
|
15
|
+
* **Ghost-drop only on a full sweep.** Models missing from the results are
|
|
16
|
+
forgotten only when the run covered the whole pool. A two-model diagnostic run
|
|
17
|
+
once wiped 52 of 54 live rows from the state file; every streak reset to zero
|
|
18
|
+
and the prune gate went blind for a week.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import shutil
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from . import taxonomy
|
|
32
|
+
from .safewrite import write_atomic
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # runtime duck-typed: any object with these attributes works
|
|
35
|
+
from .probe import ProbeResult
|
|
36
|
+
|
|
37
|
+
#: Bumped when the on-disk shape changes. An older file is still read — losing
|
|
38
|
+
#: history is worse than reading a field that is no longer written.
|
|
39
|
+
VERSION = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now() -> str:
|
|
43
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _as_str(value: object, default: str = "") -> str:
|
|
47
|
+
return value if isinstance(value, str) else default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_int(value: object, default: int = 0) -> int:
|
|
51
|
+
try:
|
|
52
|
+
return int(value) # type: ignore[arg-type]
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _as_float(value: object, default: float = 0.0) -> float:
|
|
58
|
+
try:
|
|
59
|
+
return float(value) # type: ignore[arg-type]
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ModelState:
|
|
66
|
+
"""Everything remembered about one model across runs."""
|
|
67
|
+
|
|
68
|
+
verdict: str = ""
|
|
69
|
+
kind: str = ""
|
|
70
|
+
note: str = ""
|
|
71
|
+
provider: str = ""
|
|
72
|
+
fail_streak: int = 0
|
|
73
|
+
ok_streak: int = 0
|
|
74
|
+
last_seen: str = ""
|
|
75
|
+
last_ok: str = ""
|
|
76
|
+
latency_s: float = 0.0
|
|
77
|
+
status: int = 0
|
|
78
|
+
attempts: int = 1
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def dead(self) -> bool:
|
|
82
|
+
return self.verdict in taxonomy.DEAD
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, raw: object) -> "ModelState":
|
|
86
|
+
"""Tolerant reader: a state file is a cache, not a contract.
|
|
87
|
+
|
|
88
|
+
Every field is coerced, so a half-written or hand-edited file degrades to
|
|
89
|
+
defaults instead of raising at load time.
|
|
90
|
+
"""
|
|
91
|
+
data = raw if isinstance(raw, dict) else {}
|
|
92
|
+
return cls(
|
|
93
|
+
verdict=_as_str(data.get("verdict")),
|
|
94
|
+
kind=_as_str(data.get("kind")),
|
|
95
|
+
note=_as_str(data.get("note")),
|
|
96
|
+
provider=_as_str(data.get("provider")),
|
|
97
|
+
fail_streak=max(0, _as_int(data.get("fail_streak"))),
|
|
98
|
+
ok_streak=max(0, _as_int(data.get("ok_streak"))),
|
|
99
|
+
last_seen=_as_str(data.get("last_seen")),
|
|
100
|
+
last_ok=_as_str(data.get("last_ok")),
|
|
101
|
+
latency_s=max(0.0, _as_float(data.get("latency_s"))),
|
|
102
|
+
status=_as_int(data.get("status")),
|
|
103
|
+
attempts=max(1, _as_int(data.get("attempts"), 1)),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class RunStats:
|
|
109
|
+
"""What one :meth:`State.record` did, for the report footer."""
|
|
110
|
+
|
|
111
|
+
updated: int = 0
|
|
112
|
+
dropped: list[str] = field(default_factory=list)
|
|
113
|
+
run_count: int = 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class State:
|
|
117
|
+
"""JSON file of per-model verdict history.
|
|
118
|
+
|
|
119
|
+
Written atomically (temp file in the same directory + ``os.replace``) with a
|
|
120
|
+
single ``.bak`` rotation, because this file is read by cron runs that can
|
|
121
|
+
overlap a manual sweep. A torn write here would look exactly like a lost
|
|
122
|
+
history.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, path: str | Path):
|
|
126
|
+
self.path = Path(path).expanduser()
|
|
127
|
+
self.models_: dict[str, ModelState] = {}
|
|
128
|
+
self.run_count = 0
|
|
129
|
+
self.updated = ""
|
|
130
|
+
self.warnings: list[str] = []
|
|
131
|
+
|
|
132
|
+
# -- reading -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def load(self) -> "State":
|
|
135
|
+
"""Read the file if it is usable; otherwise start empty and say so.
|
|
136
|
+
|
|
137
|
+
Corrupt input is preserved as ``.corrupt.bak`` before being discarded —
|
|
138
|
+
the operator may want to know what the tool forgot, and silently throwing
|
|
139
|
+
away weeks of history is how a gate goes blind without a trace.
|
|
140
|
+
"""
|
|
141
|
+
self.warnings = []
|
|
142
|
+
self.models_ = {}
|
|
143
|
+
self.run_count = 0
|
|
144
|
+
self.updated = ""
|
|
145
|
+
if not self.path.exists():
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
self._quarantine(f"state file is not readable JSON ({type(exc).__name__}: {exc})")
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
if not isinstance(raw, dict):
|
|
155
|
+
self._quarantine("state file is not a JSON object")
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
version = _as_int(raw.get("version"), VERSION)
|
|
159
|
+
if version != VERSION:
|
|
160
|
+
# Still read it: an unknown version means someone upgraded or
|
|
161
|
+
# downgraded the tool, and the streaks are the valuable part.
|
|
162
|
+
self.warnings.append(
|
|
163
|
+
f"state version {version} != {VERSION}, reading it anyway"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
table = raw.get("models")
|
|
167
|
+
if not isinstance(table, dict):
|
|
168
|
+
self._quarantine("state file has no models{} object")
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
for name, entry in table.items():
|
|
172
|
+
if not isinstance(name, str) or not name:
|
|
173
|
+
continue
|
|
174
|
+
self.models_[name] = ModelState.from_dict(entry)
|
|
175
|
+
self.run_count = max(0, _as_int(raw.get("run_count")))
|
|
176
|
+
self.updated = _as_str(raw.get("updated"))
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def _quarantine(self, reason: str) -> None:
|
|
180
|
+
self.warnings.append(f"{self.path}: {reason} — starting from empty state")
|
|
181
|
+
try:
|
|
182
|
+
shutil.copy2(self.path, self.path.with_name(self.path.name + ".corrupt.bak"))
|
|
183
|
+
self.warnings.append(f"previous state preserved at {self.path.name}.corrupt.bak")
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# -- writing -----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def record(
|
|
190
|
+
self,
|
|
191
|
+
results: "list[ProbeResult]",
|
|
192
|
+
*,
|
|
193
|
+
full_sweep: bool = False,
|
|
194
|
+
now: str | None = None,
|
|
195
|
+
) -> RunStats:
|
|
196
|
+
"""Fold one run's results into the history.
|
|
197
|
+
|
|
198
|
+
``full_sweep=True`` means "these results cover the entire pool", which is
|
|
199
|
+
the only condition under which unseen models are dropped. Streaks move in
|
|
200
|
+
both directions: a dead verdict increments ``fail_streak`` and zeroes
|
|
201
|
+
``ok_streak``, a live one does the opposite. ``EMPTY-LENGTH`` counts as
|
|
202
|
+
live, because it is — the route answered, it just ran out of budget.
|
|
203
|
+
"""
|
|
204
|
+
stamp = now or _now()
|
|
205
|
+
seen: set[str] = set()
|
|
206
|
+
for result in results:
|
|
207
|
+
name = getattr(result, "model", "") or ""
|
|
208
|
+
if not name:
|
|
209
|
+
continue
|
|
210
|
+
seen.add(name)
|
|
211
|
+
previous = self.models_.get(name) or ModelState()
|
|
212
|
+
dead = getattr(result, "verdict", "") in taxonomy.DEAD
|
|
213
|
+
provider = getattr(result, "provider", "") or previous.provider
|
|
214
|
+
self.models_[name] = ModelState(
|
|
215
|
+
verdict=getattr(result, "verdict", ""),
|
|
216
|
+
kind=getattr(result, "kind", ""),
|
|
217
|
+
note=getattr(result, "note", ""),
|
|
218
|
+
provider=provider,
|
|
219
|
+
fail_streak=(previous.fail_streak + 1) if dead else 0,
|
|
220
|
+
ok_streak=0 if dead else (previous.ok_streak + 1),
|
|
221
|
+
last_seen=stamp,
|
|
222
|
+
last_ok=stamp if not dead else previous.last_ok,
|
|
223
|
+
latency_s=_as_float(getattr(result, "latency_s", 0.0)),
|
|
224
|
+
status=_as_int(getattr(result, "status", 0)),
|
|
225
|
+
attempts=max(1, _as_int(getattr(result, "attempts", 1), 1)),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
dropped: list[str] = []
|
|
229
|
+
if full_sweep:
|
|
230
|
+
dropped = sorted(set(self.models_) - seen)
|
|
231
|
+
for name in dropped:
|
|
232
|
+
del self.models_[name]
|
|
233
|
+
|
|
234
|
+
self.run_count += 1
|
|
235
|
+
self.updated = stamp
|
|
236
|
+
return RunStats(updated=len(seen), dropped=dropped, run_count=self.run_count)
|
|
237
|
+
|
|
238
|
+
def save(self) -> Path:
|
|
239
|
+
"""Atomic write with one ``.bak`` rotation."""
|
|
240
|
+
payload = {
|
|
241
|
+
"version": VERSION,
|
|
242
|
+
"updated": self.updated or _now(),
|
|
243
|
+
"run_count": self.run_count,
|
|
244
|
+
"models": {name: asdict(state) for name, state in sorted(self.models_.items())},
|
|
245
|
+
}
|
|
246
|
+
return write_atomic(
|
|
247
|
+
self.path,
|
|
248
|
+
json.dumps(payload, indent=2, sort_keys=False, ensure_ascii=False) + "\n",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# -- queries -----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def get(self, model: str) -> ModelState | None:
|
|
254
|
+
return self.models_.get(model)
|
|
255
|
+
|
|
256
|
+
def streak(self, model: str) -> int:
|
|
257
|
+
"""Consecutive failing runs for ``model`` (0 when unknown)."""
|
|
258
|
+
state = self.models_.get(model)
|
|
259
|
+
return state.fail_streak if state else 0
|
|
260
|
+
|
|
261
|
+
def models(self) -> list[str]:
|
|
262
|
+
return sorted(self.models_)
|
|
263
|
+
|
|
264
|
+
def fingerprint(self) -> str:
|
|
265
|
+
"""Stable hash of the current dead/alive picture.
|
|
266
|
+
|
|
267
|
+
Used to dedupe alerts: the same pool state should notify once, not once
|
|
268
|
+
per cron tick. Sorting before hashing keeps it independent of discovery
|
|
269
|
+
order, which is not stable across runs.
|
|
270
|
+
"""
|
|
271
|
+
lines = [f"{name}:{state.kind or state.verdict}" for name, state in sorted(self.models_.items())]
|
|
272
|
+
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
|
poolprobe/taxonomy.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Verdict and death-kind classification for OpenAI-compatible gateway probes.
|
|
2
|
+
|
|
3
|
+
This module is the reason poolprobe exists. Every rule in it was paid for on a
|
|
4
|
+
live pool of ~60 routed models behind a self-hosted gateway, over six weeks of
|
|
5
|
+
scheduled sweeps. The rules read as paranoid because each one is a scar:
|
|
6
|
+
|
|
7
|
+
* ``EMPTY-LENGTH`` is alive, not dead — a reasoning model burned ``max_tokens``
|
|
8
|
+
on thinking and never reached content. Pruning it kills a working route.
|
|
9
|
+
* ``only supported on /v1/images/generations`` is a wrong-modality error, not a
|
|
10
|
+
dead model. Image, video, TTS and music models legitimately fail a chat probe.
|
|
11
|
+
* A single probe lies. Rate limits, cold starts and one hung upstream produced
|
|
12
|
+
red verdicts for models that answered 5/5 two minutes later. Nothing in this
|
|
13
|
+
package is allowed to delete a model on one observation.
|
|
14
|
+
* Only a hard upstream "this model does not exist" is permanent. Empty bodies,
|
|
15
|
+
429s, quota errors and transport timeouts are transient or credential-level
|
|
16
|
+
and must never drive an automatic prune.
|
|
17
|
+
|
|
18
|
+
Nothing here performs I/O. Pure functions over already-collected evidence, so
|
|
19
|
+
the whole taxonomy is testable offline (see ``selfcheck.py``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# --- probe verdicts ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
OK = "OK"
|
|
27
|
+
EMPTY = "EMPTY"
|
|
28
|
+
EMPTY_LENGTH = "EMPTY-LENGTH"
|
|
29
|
+
ERR = "ERR"
|
|
30
|
+
TIMEOUT = "TIMEOUT"
|
|
31
|
+
|
|
32
|
+
VERDICTS: tuple[str, ...] = (OK, EMPTY_LENGTH, EMPTY, ERR, TIMEOUT)
|
|
33
|
+
|
|
34
|
+
#: Verdicts that mean "this probe got nothing usable back".
|
|
35
|
+
#: Dead is NOT the same as prunable — see :func:`prunable`.
|
|
36
|
+
DEAD: frozenset[str] = frozenset({EMPTY, ERR, TIMEOUT})
|
|
37
|
+
|
|
38
|
+
# --- death kinds ------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
ALIVE = "ALIVE"
|
|
41
|
+
ALIVE_STARVED = "ALIVE-STARVED"
|
|
42
|
+
PERMANENT = "PERMANENT"
|
|
43
|
+
TRANSIENT = "TRANSIENT"
|
|
44
|
+
CREDENTIAL = "CREDENTIAL"
|
|
45
|
+
MODALITY = "MODALITY"
|
|
46
|
+
UNKNOWN = "UNKNOWN"
|
|
47
|
+
|
|
48
|
+
KINDS: tuple[str, ...] = (
|
|
49
|
+
ALIVE,
|
|
50
|
+
ALIVE_STARVED,
|
|
51
|
+
PERMANENT,
|
|
52
|
+
TRANSIENT,
|
|
53
|
+
CREDENTIAL,
|
|
54
|
+
MODALITY,
|
|
55
|
+
UNKNOWN,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: The only kind that may ever justify removing a model from a config.
|
|
59
|
+
PRUNABLE_KINDS: frozenset[str] = frozenset({PERMANENT})
|
|
60
|
+
|
|
61
|
+
# --- signature tables -------------------------------------------------------
|
|
62
|
+
# Matched case-insensitively as substrings of the note. Order of the *tables*
|
|
63
|
+
# matters; see classify_death for the precedence and why it is that way.
|
|
64
|
+
|
|
65
|
+
MODALITY_SIGNS: tuple[str, ...] = (
|
|
66
|
+
"only supported",
|
|
67
|
+
"not supported on",
|
|
68
|
+
"use the /v1/images",
|
|
69
|
+
"use the /v1/audio",
|
|
70
|
+
"images/generations",
|
|
71
|
+
"audio/speech",
|
|
72
|
+
"audio/transcriptions",
|
|
73
|
+
"videos/generations",
|
|
74
|
+
"wrong endpoint",
|
|
75
|
+
"invalid endpoint",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
PERMANENT_SIGNS: tuple[str, ...] = (
|
|
79
|
+
"not found",
|
|
80
|
+
"does not exist",
|
|
81
|
+
"unknown model",
|
|
82
|
+
"no such model",
|
|
83
|
+
"model_not_found",
|
|
84
|
+
"invalid model",
|
|
85
|
+
"unsupported model",
|
|
86
|
+
"is not supported",
|
|
87
|
+
"model is not available",
|
|
88
|
+
"not a valid model",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
CREDENTIAL_SIGNS: tuple[str, ...] = (
|
|
92
|
+
"unauthorized",
|
|
93
|
+
"forbidden",
|
|
94
|
+
"invalid api key",
|
|
95
|
+
"invalid_api_key",
|
|
96
|
+
"incorrect api key",
|
|
97
|
+
"authentication",
|
|
98
|
+
"auth_unavailable",
|
|
99
|
+
"permission denied",
|
|
100
|
+
"quota",
|
|
101
|
+
"credit",
|
|
102
|
+
"insufficient",
|
|
103
|
+
"billing",
|
|
104
|
+
"account tier",
|
|
105
|
+
"x-ratelimit-limit-req-minute: 0",
|
|
106
|
+
"用户额度不足", # "user quota insufficient" — the literal upstream text
|
|
107
|
+
"余额不足",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
TRANSIENT_SIGNS: tuple[str, ...] = (
|
|
111
|
+
"rate limit",
|
|
112
|
+
"rate_limit",
|
|
113
|
+
"too many requests",
|
|
114
|
+
"overloaded",
|
|
115
|
+
"upstream",
|
|
116
|
+
"timed out",
|
|
117
|
+
"timeout",
|
|
118
|
+
"temporarily",
|
|
119
|
+
"try again",
|
|
120
|
+
"bad gateway",
|
|
121
|
+
"service unavailable",
|
|
122
|
+
"gateway time-out",
|
|
123
|
+
"connection reset",
|
|
124
|
+
"connection refused",
|
|
125
|
+
"remote end closed",
|
|
126
|
+
"expecting value", # non-JSON body from a proxy that was mid-failure
|
|
127
|
+
"ledger_unavailable",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
#: HTTP statuses that mean "the credential or the account, not the model".
|
|
131
|
+
CREDENTIAL_STATUSES: frozenset[int] = frozenset({401, 403})
|
|
132
|
+
|
|
133
|
+
#: HTTP statuses that mean "the upstream is having a moment".
|
|
134
|
+
TRANSIENT_STATUSES: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504, 529})
|
|
135
|
+
|
|
136
|
+
# --- generation (non-chat) model detection ----------------------------------
|
|
137
|
+
# A chat probe against an image generator is a category error, not a liveness
|
|
138
|
+
# signal. These substrings are deliberately loose: pool aliases are named by
|
|
139
|
+
# whoever wired the route ("banana", "flux", "suno"), not by a schema.
|
|
140
|
+
|
|
141
|
+
GENERATION_TOKENS: tuple[str, ...] = (
|
|
142
|
+
"image",
|
|
143
|
+
"imagen",
|
|
144
|
+
"img",
|
|
145
|
+
"video",
|
|
146
|
+
"tts",
|
|
147
|
+
"speech",
|
|
148
|
+
"audio",
|
|
149
|
+
"voice",
|
|
150
|
+
"music",
|
|
151
|
+
"song",
|
|
152
|
+
"suno",
|
|
153
|
+
"flux",
|
|
154
|
+
"banana",
|
|
155
|
+
"dall",
|
|
156
|
+
"midjourney",
|
|
157
|
+
"kling",
|
|
158
|
+
"wan-2",
|
|
159
|
+
"imagine",
|
|
160
|
+
"embed",
|
|
161
|
+
"rerank",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
#: One line of human-facing advice per death kind. Printed in every report so
|
|
165
|
+
#: the reader gets an action, not just a colour.
|
|
166
|
+
ADVICE: dict[str, str] = {
|
|
167
|
+
ALIVE: "",
|
|
168
|
+
ALIVE_STARVED: "alive but starved: raise --max-tokens (>=1024) for reasoning models",
|
|
169
|
+
MODALITY: "not a chat model: probe it on its own endpoint (images/audio/video)",
|
|
170
|
+
CREDENTIAL: "credential or account level: rotate/top up the key, never prune the model",
|
|
171
|
+
TRANSIENT: "upstream hiccup: burst-confirm before believing it, then retry later",
|
|
172
|
+
PERMANENT: "upstream does not know this model: prunable after burst confirmation",
|
|
173
|
+
UNKNOWN: "unrecognised failure: read the note by hand, do not automate on it",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def base_name(model: str) -> str:
|
|
178
|
+
"""Strip a namespace prefix: ``Lite/glm-5.3-flash`` -> ``glm-5.3-flash``.
|
|
179
|
+
|
|
180
|
+
Gateways that expose several upstreams publish namespaced aliases. The
|
|
181
|
+
namespace is a routing hint, not a different model, so protection rules
|
|
182
|
+
keyed on the real name have to be checked against both forms.
|
|
183
|
+
"""
|
|
184
|
+
return model.split("/", 1)[-1].strip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_generation(model: str) -> bool:
|
|
188
|
+
"""True if the model name advertises a non-chat modality."""
|
|
189
|
+
lowered = model.lower()
|
|
190
|
+
return any(token in lowered for token in GENERATION_TOKENS)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def classify(status: int, body: object, error: str | None = None) -> tuple[str, str]:
|
|
194
|
+
"""Turn one HTTP exchange into ``(verdict, note)``.
|
|
195
|
+
|
|
196
|
+
``body`` is the parsed JSON reply (may be any shape — upstreams are not
|
|
197
|
+
trustworthy), ``error`` is the exception text when the exchange itself
|
|
198
|
+
failed. Parsing lives inside a guard: a model that once replied with a
|
|
199
|
+
top-level JSON list killed an entire sweep run because the extraction was
|
|
200
|
+
unguarded.
|
|
201
|
+
"""
|
|
202
|
+
if error is not None:
|
|
203
|
+
return (TIMEOUT, "no answer within timeout") if _is_timeout(error) else (
|
|
204
|
+
ERR,
|
|
205
|
+
error[:160],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
choices = (body or {}).get("choices") or [{}] # type: ignore[union-attr]
|
|
210
|
+
message = choices[0].get("message") or {}
|
|
211
|
+
content = (message.get("content") or "").strip()
|
|
212
|
+
reasoning = (message.get("reasoning_content") or message.get("reasoning") or "")
|
|
213
|
+
finish = choices[0].get("finish_reason") or "?"
|
|
214
|
+
except Exception as exc: # reply shape is untrusted upstream JSON
|
|
215
|
+
return ERR, f"unparsable reply: {type(exc).__name__} {str(body)[:80]}"
|
|
216
|
+
|
|
217
|
+
if content or (isinstance(reasoning, str) and reasoning.strip()):
|
|
218
|
+
return OK, (content or reasoning.strip())[:48].replace("\n", " ")
|
|
219
|
+
if finish == "length":
|
|
220
|
+
return EMPTY_LENGTH, "thinking ate max_tokens"
|
|
221
|
+
if status and status != 200:
|
|
222
|
+
return EMPTY, f"http {status} finish={finish}"
|
|
223
|
+
return EMPTY, f"finish={finish} reasoning={bool(reasoning)}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def classify_death(verdict: str, note: str, status: int = 0) -> str:
|
|
227
|
+
"""Map a verdict + note onto a :data:`KINDS` member.
|
|
228
|
+
|
|
229
|
+
Precedence is the whole design:
|
|
230
|
+
|
|
231
|
+
1. ``TIMEOUT`` is always transient. A 30s budget once timed out on a route
|
|
232
|
+
that answered ``ok`` in 66.8s — a timeout proves nothing about existence.
|
|
233
|
+
2. 401/403 is the credential, not the model. Pruning here deletes a good
|
|
234
|
+
alias because a key ran dry.
|
|
235
|
+
3. Modality beats permanence. ``model X only supported on /v1/images/...``
|
|
236
|
+
contains "supported" and would otherwise match a permanent signature.
|
|
237
|
+
4. Only then does an explicit "not found" family count as permanent.
|
|
238
|
+
5. A bare 404 with no readable body is a permanent *candidate* — the caller
|
|
239
|
+
still has to burst-confirm and check the streak before pruning.
|
|
240
|
+
"""
|
|
241
|
+
if verdict == OK:
|
|
242
|
+
return ALIVE
|
|
243
|
+
if verdict == EMPTY_LENGTH:
|
|
244
|
+
return ALIVE_STARVED
|
|
245
|
+
if verdict == TIMEOUT:
|
|
246
|
+
return TRANSIENT
|
|
247
|
+
|
|
248
|
+
lowered = note.lower()
|
|
249
|
+
if status in CREDENTIAL_STATUSES:
|
|
250
|
+
return CREDENTIAL
|
|
251
|
+
if any(sign in lowered for sign in MODALITY_SIGNS):
|
|
252
|
+
return MODALITY
|
|
253
|
+
if any(sign in lowered for sign in PERMANENT_SIGNS):
|
|
254
|
+
return PERMANENT
|
|
255
|
+
if status == 404:
|
|
256
|
+
return PERMANENT
|
|
257
|
+
if any(sign in lowered for sign in CREDENTIAL_SIGNS):
|
|
258
|
+
return CREDENTIAL
|
|
259
|
+
if status in TRANSIENT_STATUSES or any(sign in lowered for sign in TRANSIENT_SIGNS):
|
|
260
|
+
return TRANSIENT
|
|
261
|
+
return UNKNOWN
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def advice(kind: str) -> str:
|
|
265
|
+
return ADVICE.get(kind, "")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def protected(model: str, protect: frozenset[str] | set[str]) -> bool:
|
|
269
|
+
"""True if the model (or its base name) is on the protect list."""
|
|
270
|
+
if not protect:
|
|
271
|
+
return False
|
|
272
|
+
return model in protect or base_name(model) in protect
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def prunable(
|
|
276
|
+
model: str,
|
|
277
|
+
verdict: str,
|
|
278
|
+
kind: str,
|
|
279
|
+
fail_streak: int,
|
|
280
|
+
streak_min: int,
|
|
281
|
+
protect: frozenset[str] | set[str] = frozenset(),
|
|
282
|
+
) -> bool:
|
|
283
|
+
"""The single prune predicate. Report and prune loop both call this.
|
|
284
|
+
|
|
285
|
+
One function on purpose: a report that says "prunable" and a prune loop
|
|
286
|
+
that deletes something else is the worst possible failure mode for a tool
|
|
287
|
+
whose job is to not delete working routes.
|
|
288
|
+
|
|
289
|
+
All five conditions must hold:
|
|
290
|
+
|
|
291
|
+
* the verdict is dead (empty body, error, or timeout),
|
|
292
|
+
* the death kind is permanent — upstream does not know the model,
|
|
293
|
+
* the streak reached ``streak_min`` consecutive failing sweeps,
|
|
294
|
+
* the model is not on the protect list,
|
|
295
|
+
* the model is not a non-chat (generation/embedding) modality.
|
|
296
|
+
"""
|
|
297
|
+
if verdict not in DEAD:
|
|
298
|
+
return False
|
|
299
|
+
if kind not in PRUNABLE_KINDS:
|
|
300
|
+
return False
|
|
301
|
+
if fail_streak < streak_min:
|
|
302
|
+
return False
|
|
303
|
+
if protected(model, protect):
|
|
304
|
+
return False
|
|
305
|
+
if is_generation(model) or is_generation(base_name(model)):
|
|
306
|
+
return False
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_timeout(error: str) -> bool:
|
|
311
|
+
lowered = error.lower()
|
|
312
|
+
return "timed out" in lowered or "timeout" in lowered
|