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/probe.py ADDED
@@ -0,0 +1,352 @@
1
+ """Transport layer: one chat completion per probe, plus burst confirmation.
2
+
3
+ Stdlib only (``urllib``). The request shape is boring on purpose — the
4
+ interesting part is what comes back and how little of it can be trusted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import Callable, Iterable, Sequence
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from dataclasses import dataclass, field
16
+ from typing import TypeVar
17
+
18
+ from . import taxonomy
19
+
20
+ #: A short, cheap prompt. Long prompts make the sweep cost real tokens on
21
+ #: metered upstreams and change the latency you are measuring.
22
+ DEFAULT_PROMPT = "Reply with the single word: alive"
23
+
24
+ #: Reasoning models spend tokens on thinking before content. At 32 tokens they
25
+ #: return an empty body with ``finish_reason=length`` and look dead. 1024 is the
26
+ #: floor that stopped the false positives; do not lower it to make sweeps faster.
27
+ DEFAULT_MAX_TOKENS = 1024
28
+
29
+ DEFAULT_TIMEOUT = 60.0
30
+
31
+ #: Generic over the worker's return type so ``bench`` can reuse the same bounded
32
+ #: pool without a second scheduler.
33
+ T = TypeVar("T")
34
+ R = TypeVar("R")
35
+
36
+
37
+ class GatewayError(RuntimeError):
38
+ """The gateway itself is unreachable or refused us (not a model problem)."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Gateway:
43
+ """An OpenAI-compatible endpoint.
44
+
45
+ ``base_url`` is the versioned root (``http://host:port/v1``); a trailing
46
+ slash is tolerated. ``api_key`` may be empty for keyless local gateways.
47
+ """
48
+
49
+ base_url: str
50
+ api_key: str = ""
51
+ name: str = "gateway"
52
+ extra_headers: dict[str, str] = field(default_factory=dict)
53
+
54
+ @property
55
+ def root(self) -> str:
56
+ return self.base_url.rstrip("/")
57
+
58
+ @property
59
+ def chat_url(self) -> str:
60
+ return f"{self.root}/chat/completions"
61
+
62
+ @property
63
+ def models_url(self) -> str:
64
+ return f"{self.root}/models"
65
+
66
+ def headers(self, json_body: bool = False) -> dict[str, str]:
67
+ out = dict(self.extra_headers)
68
+ if self.api_key:
69
+ out["Authorization"] = f"Bearer {self.api_key}"
70
+ if json_body:
71
+ out["Content-Type"] = "application/json"
72
+ return out
73
+
74
+
75
+ @dataclass
76
+ class ProbeResult:
77
+ """One observation about one model."""
78
+
79
+ model: str
80
+ verdict: str
81
+ note: str
82
+ kind: str
83
+ status: int = 0
84
+ latency_s: float = 0.0
85
+ finish_reason: str = "?"
86
+ attempts: int = 1
87
+ provider: str = ""
88
+ history: list[tuple[str, str]] = field(default_factory=list)
89
+
90
+ @property
91
+ def dead(self) -> bool:
92
+ return self.verdict in taxonomy.DEAD
93
+
94
+ @property
95
+ def advice(self) -> str:
96
+ return taxonomy.advice(self.kind)
97
+
98
+ def as_dict(self) -> dict[str, object]:
99
+ return {
100
+ "model": self.model,
101
+ "verdict": self.verdict,
102
+ "kind": self.kind,
103
+ "note": self.note,
104
+ "status": self.status,
105
+ "latency_s": round(self.latency_s, 2),
106
+ "finish_reason": self.finish_reason,
107
+ "attempts": self.attempts,
108
+ "provider": self.provider,
109
+ "advice": self.advice,
110
+ "history": [{"verdict": v, "note": n} for v, n in self.history],
111
+ }
112
+
113
+
114
+ def probe(
115
+ gateway: Gateway,
116
+ model: str,
117
+ timeout: float = DEFAULT_TIMEOUT,
118
+ max_tokens: int = DEFAULT_MAX_TOKENS,
119
+ prompt: str = DEFAULT_PROMPT,
120
+ ) -> ProbeResult:
121
+ """POST one chat completion and classify whatever comes back.
122
+
123
+ ``"stream": false`` is load-bearing. Some gateway routes default to SSE
124
+ even for a non-streaming request; ``json.loads`` then reads the first chunk
125
+ as ``Expecting value: line 1 column 1`` and the model is reported dead while
126
+ it is answering perfectly. That false red repeated every 30 minutes on a
127
+ live pool until the flag was pinned.
128
+ """
129
+ payload = json.dumps(
130
+ {
131
+ "model": model,
132
+ "messages": [{"role": "user", "content": prompt}],
133
+ "max_tokens": max_tokens,
134
+ "stream": False,
135
+ }
136
+ ).encode()
137
+ request = urllib.request.Request(
138
+ gateway.chat_url, data=payload, headers=gateway.headers(json_body=True)
139
+ )
140
+
141
+ started = time.monotonic()
142
+ status = 0
143
+ # Defaults for every path that never reaches a parseable body. Without them
144
+ # an HTTPError or a timeout would raise UnboundLocalError while building the
145
+ # result, and one bad upstream would take the whole sweep down with it.
146
+ finish = "?"
147
+ note = ""
148
+ verdict = taxonomy.ERR
149
+ try:
150
+ with urllib.request.urlopen(request, timeout=timeout) as response:
151
+ status = getattr(response, "status", 200) or 200
152
+ raw = response.read()
153
+ except urllib.error.HTTPError as exc:
154
+ status = exc.code
155
+ # urlopen puts the response body on the exception; that body is the only
156
+ # evidence of *why* the upstream refused, so it becomes the note.
157
+ detail = ""
158
+ try:
159
+ detail = exc.read().decode("utf-8", "replace")
160
+ except Exception:
161
+ detail = str(exc.reason or exc)
162
+ note = _flatten(detail)
163
+ verdict = taxonomy.ERR
164
+ except Exception as exc: # transport, DNS, TLS, socket timeout, proxy
165
+ note = _flatten(f"{type(exc).__name__}: {exc}")
166
+ verdict = taxonomy.TIMEOUT if taxonomy._is_timeout(note) else taxonomy.ERR
167
+ else:
168
+ try:
169
+ body = json.loads(raw.decode("utf-8", "replace"))
170
+ except Exception as exc:
171
+ body = None
172
+ note = _flatten(f"non-JSON body: {type(exc).__name__}: {raw[:80]!r}")
173
+ verdict = taxonomy.ERR
174
+ else:
175
+ verdict, note = taxonomy.classify(status, body)
176
+ finish = _finish_of(body)
177
+
178
+ elapsed = time.monotonic() - started
179
+ kind = taxonomy.classify_death(verdict, note, status)
180
+ return ProbeResult(
181
+ model=model,
182
+ verdict=verdict,
183
+ note=note,
184
+ kind=kind,
185
+ status=status,
186
+ latency_s=elapsed,
187
+ finish_reason=finish,
188
+ attempts=1,
189
+ history=[(verdict, note)],
190
+ )
191
+
192
+
193
+ def burst(
194
+ gateway: Gateway,
195
+ model: str,
196
+ attempts: int = 5,
197
+ gap_s: float = 0.0,
198
+ timeout: float = DEFAULT_TIMEOUT,
199
+ max_tokens: int = DEFAULT_MAX_TOKENS,
200
+ prompt: str = DEFAULT_PROMPT,
201
+ early_exit_ok: bool = True,
202
+ ) -> ProbeResult:
203
+ """Probe the same model up to ``attempts`` times and keep the best truth.
204
+
205
+ One observation is not a verdict. On a measured pool, 39% of scheduled
206
+ liveness runs went red and every single red was a transient — including a
207
+ model that failed a probe at 17:47 and answered 5/5 two minutes later.
208
+
209
+ The returned result carries ``attempts`` actually made and the full
210
+ ``history``, so a report can show "recovered on attempt 3" instead of
211
+ hiding the flakiness.
212
+ """
213
+ attempts = max(1, int(attempts))
214
+ last: ProbeResult | None = None
215
+ best: ProbeResult | None = None
216
+ history: list[tuple[str, str]] = []
217
+ for index in range(attempts):
218
+ if index and gap_s:
219
+ time.sleep(gap_s)
220
+ result = probe(
221
+ gateway,
222
+ model,
223
+ timeout=timeout,
224
+ max_tokens=max_tokens,
225
+ prompt=prompt,
226
+ )
227
+ history.append((result.verdict, result.note))
228
+ last = result
229
+ if not result.dead:
230
+ best = result
231
+ if early_exit_ok:
232
+ break
233
+ elif result.kind not in taxonomy.PRUNABLE_KINDS and early_exit_ok:
234
+ # A transient failure is already the answer we needed: this model is
235
+ # not a prune candidate, so stop spending requests on it.
236
+ break
237
+ assert last is not None
238
+ # "Best truth wins": one good answer inside the burst outranks any number of
239
+ # bad ones, otherwise a model that recovers on attempt 3 and flakes on 5
240
+ # would be reported dead on the strength of its last observation.
241
+ winner = best or last
242
+ winner.attempts = len(history)
243
+ winner.history = history
244
+ return winner
245
+
246
+
247
+ def list_models(gateway: Gateway, timeout: float = 20.0) -> list[str]:
248
+ """Model ids published by ``GET /models``.
249
+
250
+ Raises :class:`GatewayError` when the catalog cannot be read: guessing an
251
+ empty pool would silently turn every downstream gate green.
252
+ """
253
+ request = urllib.request.Request(gateway.models_url, headers=gateway.headers())
254
+ try:
255
+ with urllib.request.urlopen(request, timeout=timeout) as response:
256
+ payload = json.loads(response.read().decode("utf-8", "replace"))
257
+ except Exception as exc:
258
+ raise GatewayError(
259
+ f"cannot read {gateway.models_url}: {type(exc).__name__}: {exc}"
260
+ ) from exc
261
+
262
+ entries = payload.get("data") if isinstance(payload, dict) else None
263
+ if not isinstance(entries, list):
264
+ raise GatewayError(f"{gateway.models_url} returned no data[] list")
265
+ ids = [entry.get("id") for entry in entries if isinstance(entry, dict)]
266
+ return sorted({str(i) for i in ids if i})
267
+
268
+
269
+ def ping(gateway: Gateway, timeout: float = 10.0) -> str:
270
+ """Cheap reachability check used by ``--selfcheck``-adjacent smoke runs."""
271
+ try:
272
+ count = len(list_models(gateway, timeout=timeout))
273
+ except GatewayError as exc:
274
+ return str(exc)
275
+ return f"{count} models published"
276
+
277
+
278
+ def parallel_map(
279
+ items: Iterable[T],
280
+ worker: Callable[[T], R],
281
+ threads: int = 8,
282
+ ) -> list[R]:
283
+ """Run ``worker`` over ``items``, bounded and order-preserving.
284
+
285
+ Threads, not processes: one blocking socket read per model makes the sweep
286
+ I/O bound. Order is preserved so two runs can be diffed line by line, which
287
+ is how a slow drift in the pool stays visible across weeks of cron.
288
+ """
289
+ todo = list(items)
290
+ if not todo:
291
+ return []
292
+ threads = max(1, min(int(threads), len(todo)))
293
+ if threads == 1:
294
+ return [worker(model) for model in todo]
295
+ with ThreadPoolExecutor(max_workers=threads) as pool:
296
+ return list(pool.map(worker, todo))
297
+
298
+
299
+ def sweep(
300
+ gateway: Gateway,
301
+ models: Sequence[str],
302
+ *,
303
+ threads: int = 8,
304
+ timeout: float = DEFAULT_TIMEOUT,
305
+ max_tokens: int = DEFAULT_MAX_TOKENS,
306
+ prompt: str = DEFAULT_PROMPT,
307
+ attempts: int = 1,
308
+ gap_s: float = 0.0,
309
+ on_result: Callable[[ProbeResult], None] | None = None,
310
+ ) -> list[ProbeResult]:
311
+ """Probe every model once, or burst-confirm each when ``attempts > 1``.
312
+
313
+ ``on_result`` fires from worker threads as each model lands, so a caller can
314
+ stream progress; it must be thread-safe (the CLI wraps its printer in a
315
+ lock). Results are returned in the order of ``models`` regardless.
316
+ """
317
+
318
+ def work(model: str) -> ProbeResult:
319
+ if attempts > 1:
320
+ result = burst(
321
+ gateway,
322
+ model,
323
+ attempts=attempts,
324
+ gap_s=gap_s,
325
+ timeout=timeout,
326
+ max_tokens=max_tokens,
327
+ prompt=prompt,
328
+ )
329
+ else:
330
+ result = probe(
331
+ gateway, model, timeout=timeout, max_tokens=max_tokens, prompt=prompt
332
+ )
333
+ if on_result is not None:
334
+ on_result(result)
335
+ return result
336
+
337
+ return parallel_map(models, work, threads=threads)
338
+
339
+
340
+ def _flatten(text: str) -> str:
341
+ """Collapse a body into one printable line, capped."""
342
+ cleaned = " ".join((text or "").split())
343
+ return (cleaned or "empty")[:160]
344
+
345
+
346
+ def _finish_of(body: object) -> str:
347
+ """``finish_reason`` of the first choice, or ``?`` when the shape is odd."""
348
+ try:
349
+ choices = (body or {}).get("choices") or [{}] # type: ignore[union-attr]
350
+ return str(choices[0].get("finish_reason") or "?")
351
+ except Exception:
352
+ return "?"