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/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