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