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/cli.py
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
"""Command line surface: one report in, three renderers out, honest exit codes.
|
|
2
|
+
|
|
3
|
+
Every subcommand ends in the same shape — measure, fold into ``Report``, hand it
|
|
4
|
+
to the renderers — so the terminal, JSON and HTML views can never disagree about
|
|
5
|
+
what happened. The commands differ only in what they measure and in which of the
|
|
6
|
+
five prune guards they are allowed to act on.
|
|
7
|
+
|
|
8
|
+
Exit codes are a cron contract::
|
|
9
|
+
|
|
10
|
+
0 nothing dead (or nothing that met every guard)
|
|
11
|
+
1 something dead / a fallback chain is broken
|
|
12
|
+
2 usage error, unreadable config, missing PyYAML
|
|
13
|
+
3 the gateway itself could not be reached or published an empty catalog
|
|
14
|
+
130 interrupted
|
|
15
|
+
|
|
16
|
+
``1`` is deliberately burst-confirmed: with the default ``--attempts 5`` a model
|
|
17
|
+
only reports dead after five consecutive failures inside one run, so a red exit
|
|
18
|
+
is a finding, not weather.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import dataclasses
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from argparse import Namespace
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import NoReturn
|
|
33
|
+
|
|
34
|
+
from . import __version__, bench, chains, safewrite, taxonomy
|
|
35
|
+
from .adapter import ConfigError, build_adapter
|
|
36
|
+
from .probe import (
|
|
37
|
+
DEFAULT_MAX_TOKENS,
|
|
38
|
+
DEFAULT_PROMPT,
|
|
39
|
+
DEFAULT_TIMEOUT,
|
|
40
|
+
Gateway,
|
|
41
|
+
GatewayError,
|
|
42
|
+
list_models,
|
|
43
|
+
sweep,
|
|
44
|
+
)
|
|
45
|
+
from .report import (
|
|
46
|
+
VERDICT_STYLE,
|
|
47
|
+
Palette,
|
|
48
|
+
Prunable,
|
|
49
|
+
Report,
|
|
50
|
+
RunMeta,
|
|
51
|
+
render_html,
|
|
52
|
+
render_json,
|
|
53
|
+
render_terminal,
|
|
54
|
+
summarize_probes,
|
|
55
|
+
)
|
|
56
|
+
from .safewrite import write_atomic
|
|
57
|
+
from .state import State
|
|
58
|
+
|
|
59
|
+
__all__ = ["main", "build_parser", "selfcheck"]
|
|
60
|
+
|
|
61
|
+
# --- exit codes -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
EXIT_OK = 0
|
|
64
|
+
EXIT_DEAD = 1
|
|
65
|
+
EXIT_USAGE = 2
|
|
66
|
+
EXIT_GATEWAY = 3
|
|
67
|
+
EXIT_INTERRUPT = 130
|
|
68
|
+
|
|
69
|
+
#: A sweep is I/O-bound (five attempts, dozens of models); a bench contends
|
|
70
|
+
#: inside the gateway, which is why ``bench`` keeps its own lower default.
|
|
71
|
+
SWEEP_THREADS = 8
|
|
72
|
+
#: Doctrine: one observation is not a verdict. Five attempts inside one run is
|
|
73
|
+
#: the floor below which a red row means nothing.
|
|
74
|
+
SWEEP_ATTEMPTS = 5
|
|
75
|
+
#: Consecutive *runs* a model must stay dead before it becomes prunable.
|
|
76
|
+
DEFAULT_STREAK_MIN = 2
|
|
77
|
+
#: Above this many published models a ``ping`` stops listing names.
|
|
78
|
+
LIST_LIMIT = 60
|
|
79
|
+
#: Progress glyphs per stderr line.
|
|
80
|
+
TICK_WIDTH = 40
|
|
81
|
+
|
|
82
|
+
_SECRET_FLAGS = frozenset({"-k", "--api-key"})
|
|
83
|
+
#: argv[0] is ``poolprobe`` for a console script but ``__main__.py`` under
|
|
84
|
+
#: ``python -m``; the report echoes the program name, not the launcher file.
|
|
85
|
+
_PROGRAM = "poolprobe"
|
|
86
|
+
|
|
87
|
+
_EPILOG = """\
|
|
88
|
+
examples:
|
|
89
|
+
poolprobe ping --base-url http://127.0.0.1:8317/v1
|
|
90
|
+
poolprobe sweep --base-url http://127.0.0.1:8317/v1 --attempts 5 --html out.html
|
|
91
|
+
poolprobe sweep --base-url ... --state ~/.cache/poolprobe.json gpt-5.4 glm-5.3-flash
|
|
92
|
+
poolprobe bench --base-url ... --repeat 3
|
|
93
|
+
poolprobe prune --base-url ... --adapter cliproxy --config ~/.config/gateway.yaml
|
|
94
|
+
poolprobe chains ~/.hermes/config.yaml
|
|
95
|
+
poolprobe selfcheck
|
|
96
|
+
|
|
97
|
+
A run without --state has no memory: every fail streak reads 0, so `prune` can
|
|
98
|
+
list candidates but nothing passes the streak guard. That is the safe default.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _die(message: str, code: int) -> NoReturn:
|
|
103
|
+
sys.stderr.write(f"poolprobe: error: {message}\n")
|
|
104
|
+
raise SystemExit(code)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _note(message: str) -> None:
|
|
108
|
+
sys.stderr.write(f"poolprobe: {message}\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _silence_pipes() -> None:
|
|
112
|
+
"""``poolprobe sweep | head`` must not kill the run with a traceback."""
|
|
113
|
+
try:
|
|
114
|
+
sys.stdout.flush()
|
|
115
|
+
except (BrokenPipeError, ValueError):
|
|
116
|
+
pass
|
|
117
|
+
finally:
|
|
118
|
+
try:
|
|
119
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --- argument plumbing ------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _redacted_argv(argv: object = None) -> list:
|
|
128
|
+
"""The command line as typed, with the bearer key masked.
|
|
129
|
+
|
|
130
|
+
Reports get pasted into issues and attached to posts; an argv echo is the
|
|
131
|
+
easiest secret in the whole tool to leak by accident.
|
|
132
|
+
"""
|
|
133
|
+
items = list(sys.argv if argv is None else argv)
|
|
134
|
+
out: list = []
|
|
135
|
+
mask_next = False
|
|
136
|
+
for index, item in enumerate(items):
|
|
137
|
+
text = str(item)
|
|
138
|
+
if mask_next:
|
|
139
|
+
out.append("***")
|
|
140
|
+
mask_next = False
|
|
141
|
+
continue
|
|
142
|
+
if index == 0:
|
|
143
|
+
out.append(_PROGRAM)
|
|
144
|
+
continue
|
|
145
|
+
if text in _SECRET_FLAGS:
|
|
146
|
+
out.append(text)
|
|
147
|
+
mask_next = True
|
|
148
|
+
continue
|
|
149
|
+
if text.startswith("--api-key="):
|
|
150
|
+
out.append("--api-key=***")
|
|
151
|
+
continue
|
|
152
|
+
out.append(text)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _common_parser() -> argparse.ArgumentParser:
|
|
157
|
+
"""Options every measuring command shares.
|
|
158
|
+
|
|
159
|
+
Measurement defaults are ``None`` here and resolved in ``_measure_args``:
|
|
160
|
+
a sweep and a bench want different threads, token budgets and prompts, and
|
|
161
|
+
argparse cannot express "default depends on the subcommand".
|
|
162
|
+
"""
|
|
163
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
164
|
+
|
|
165
|
+
conn = common.add_argument_group("connection")
|
|
166
|
+
conn.add_argument(
|
|
167
|
+
"-u",
|
|
168
|
+
"--base-url",
|
|
169
|
+
metavar="URL",
|
|
170
|
+
default=os.environ.get("POOLPROBE_BASE_URL", ""),
|
|
171
|
+
help="OpenAI-compatible base URL, e.g. http://127.0.0.1:8317/v1 (env POOLPROBE_BASE_URL)",
|
|
172
|
+
)
|
|
173
|
+
conn.add_argument(
|
|
174
|
+
"-k",
|
|
175
|
+
"--api-key",
|
|
176
|
+
default=os.environ.get("POOLPROBE_API_KEY") or os.environ.get("OPENAI_API_KEY") or "",
|
|
177
|
+
help="bearer key (env POOLPROBE_API_KEY, then OPENAI_API_KEY)",
|
|
178
|
+
)
|
|
179
|
+
conn.add_argument(
|
|
180
|
+
"--adapter",
|
|
181
|
+
choices=("auto", "cliproxy", "generic"),
|
|
182
|
+
default="auto",
|
|
183
|
+
help="where model->provider attribution comes from; 'auto' reads --config when it "
|
|
184
|
+
"parses and degrades to read-only, 'cliproxy' demands it, 'generic' skips it",
|
|
185
|
+
)
|
|
186
|
+
conn.add_argument(
|
|
187
|
+
"--config",
|
|
188
|
+
metavar="PATH",
|
|
189
|
+
default="",
|
|
190
|
+
help="gateway config to attribute (and prune) against: cliproxy-style YAML",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
meas = common.add_argument_group("measurement")
|
|
194
|
+
meas.add_argument(
|
|
195
|
+
"--threads",
|
|
196
|
+
type=int,
|
|
197
|
+
metavar="N",
|
|
198
|
+
help=f"parallel requests (sweep {SWEEP_THREADS}, bench {bench.DEFAULT_THREADS})",
|
|
199
|
+
)
|
|
200
|
+
meas.add_argument(
|
|
201
|
+
"--timeout",
|
|
202
|
+
type=float,
|
|
203
|
+
metavar="S",
|
|
204
|
+
help=f"per-request timeout in seconds (default {DEFAULT_TIMEOUT:g})",
|
|
205
|
+
)
|
|
206
|
+
meas.add_argument(
|
|
207
|
+
"--max-tokens",
|
|
208
|
+
type=int,
|
|
209
|
+
metavar="N",
|
|
210
|
+
help=f"token budget (sweep {DEFAULT_MAX_TOKENS}, bench {bench.DEFAULT_BENCH_MAX_TOKENS}); "
|
|
211
|
+
"a floor, not a cap — reasoning models spend it on thinking and answer EMPTY-LENGTH",
|
|
212
|
+
)
|
|
213
|
+
meas.add_argument("--prompt", help="override the probe prompt")
|
|
214
|
+
|
|
215
|
+
mem = common.add_argument_group("memory")
|
|
216
|
+
mem.add_argument(
|
|
217
|
+
"--state",
|
|
218
|
+
metavar="PATH",
|
|
219
|
+
default=os.environ.get("POOLPROBE_STATE", ""),
|
|
220
|
+
help="JSON history file for fail streaks (env POOLPROBE_STATE); without it nothing "
|
|
221
|
+
"can pass the streak guard, so `prune` only lists candidates",
|
|
222
|
+
)
|
|
223
|
+
mem.add_argument(
|
|
224
|
+
"--streak-min",
|
|
225
|
+
type=int,
|
|
226
|
+
default=DEFAULT_STREAK_MIN,
|
|
227
|
+
metavar="N",
|
|
228
|
+
help=f"consecutive dead runs required before a model is prunable (default {DEFAULT_STREAK_MIN})",
|
|
229
|
+
)
|
|
230
|
+
mem.add_argument(
|
|
231
|
+
"--protect",
|
|
232
|
+
action="append",
|
|
233
|
+
default=[],
|
|
234
|
+
metavar="MODEL",
|
|
235
|
+
help="never prune this model (repeatable, comma-separated); the base name of a "
|
|
236
|
+
"namespaced alias is matched too",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
out = common.add_argument_group("output")
|
|
240
|
+
out.add_argument("--json", metavar="PATH", default="", help="also write the report as JSON")
|
|
241
|
+
out.add_argument("--html", metavar="PATH", default="", help="also write a self-contained HTML report")
|
|
242
|
+
out.add_argument(
|
|
243
|
+
"--color",
|
|
244
|
+
choices=("auto", "always", "never"),
|
|
245
|
+
default="auto",
|
|
246
|
+
help="ANSI policy; 'auto' honours NO_COLOR, FORCE_COLOR, TERM=dumb and isatty",
|
|
247
|
+
)
|
|
248
|
+
out.add_argument("-q", "--quiet", action="store_true", help="suppress the terminal report and progress glyphs")
|
|
249
|
+
return common
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
253
|
+
parser = argparse.ArgumentParser(
|
|
254
|
+
prog="poolprobe",
|
|
255
|
+
description="Doctor for OpenAI-compatible LLM gateways: probe the pool, classify every "
|
|
256
|
+
"failure, and only then talk about pruning.",
|
|
257
|
+
epilog=_EPILOG,
|
|
258
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
259
|
+
)
|
|
260
|
+
parser.add_argument(
|
|
261
|
+
"-V",
|
|
262
|
+
"--version",
|
|
263
|
+
action="version",
|
|
264
|
+
version=f"poolprobe {__version__}",
|
|
265
|
+
help="print the version and exit",
|
|
266
|
+
)
|
|
267
|
+
common = _common_parser()
|
|
268
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
269
|
+
|
|
270
|
+
ping = sub.add_parser(
|
|
271
|
+
"ping",
|
|
272
|
+
parents=[common],
|
|
273
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
274
|
+
help="can the gateway be reached, and what does it publish?",
|
|
275
|
+
description="Reach the gateway, list the published catalog and break it down by provider. "
|
|
276
|
+
"No chat request is sent, so this is the cheapest possible liveness check.",
|
|
277
|
+
)
|
|
278
|
+
ping.set_defaults(func=cmd_ping)
|
|
279
|
+
|
|
280
|
+
p_sweep = sub.add_parser(
|
|
281
|
+
"sweep",
|
|
282
|
+
parents=[common],
|
|
283
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
284
|
+
help="probe every model (or the named ones) and classify the failures",
|
|
285
|
+
description="One burst-confirmed chat probe per model. Each result carries a verdict "
|
|
286
|
+
"(OK/EMPTY/EMPTY-LENGTH/ERR/TIMEOUT) and a kind (ALIVE/ALIVE-STARVED/PERMANENT/"
|
|
287
|
+
"TRANSIENT/CREDENTIAL/MODALITY/UNKNOWN); only PERMANENT is ever a prune candidate.",
|
|
288
|
+
)
|
|
289
|
+
p_sweep.add_argument("models", nargs="*", help="restrict the sweep to these models (default: the whole catalog)")
|
|
290
|
+
p_sweep.add_argument(
|
|
291
|
+
"--attempts",
|
|
292
|
+
type=int,
|
|
293
|
+
default=SWEEP_ATTEMPTS,
|
|
294
|
+
metavar="N",
|
|
295
|
+
help=f"burst size per model — the best answer wins (default {SWEEP_ATTEMPTS})",
|
|
296
|
+
)
|
|
297
|
+
p_sweep.add_argument("--gap", type=float, default=0.0, metavar="S", help="sleep between burst attempts")
|
|
298
|
+
p_sweep.set_defaults(func=cmd_sweep)
|
|
299
|
+
|
|
300
|
+
p_bench = sub.add_parser(
|
|
301
|
+
"bench",
|
|
302
|
+
parents=[common],
|
|
303
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
304
|
+
help="measure latency and throughput instead of liveness",
|
|
305
|
+
description="Throughput is reported only when the gateway's own usage block supplies a "
|
|
306
|
+
"token count; otherwise it is a lower bound or a dash. Never estimated from SSE chunk counts.",
|
|
307
|
+
)
|
|
308
|
+
p_bench.add_argument("models", nargs="*", help="restrict the bench to these models (default: the whole catalog)")
|
|
309
|
+
p_bench.add_argument("--repeat", type=int, default=1, metavar="N", help="samples per model; the median is reported")
|
|
310
|
+
p_bench.add_argument("--stream", action="store_true", help="stream the reply to measure time-to-first-token")
|
|
311
|
+
p_bench.set_defaults(func=cmd_bench)
|
|
312
|
+
|
|
313
|
+
p_prune = sub.add_parser(
|
|
314
|
+
"prune",
|
|
315
|
+
parents=[common],
|
|
316
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
317
|
+
help="list models that passed all five prune guards, and plan the config edit",
|
|
318
|
+
description="Five guards have to agree before a model is called prunable: a dead verdict, "
|
|
319
|
+
"kind PERMANENT, a fail streak of at least --streak-min runs, not in --protect, and not a "
|
|
320
|
+
"generation modality (image/video/tts models legitimately fail a chat probe). Without "
|
|
321
|
+
"--apply nothing on disk changes; the diff is shown either way.",
|
|
322
|
+
)
|
|
323
|
+
p_prune.add_argument("models", nargs="*", help="restrict the sweep to these models (default: the whole catalog)")
|
|
324
|
+
p_prune.add_argument(
|
|
325
|
+
"--attempts",
|
|
326
|
+
type=int,
|
|
327
|
+
default=SWEEP_ATTEMPTS,
|
|
328
|
+
metavar="N",
|
|
329
|
+
help=f"burst size per model — the best answer wins (default {SWEEP_ATTEMPTS})",
|
|
330
|
+
)
|
|
331
|
+
p_prune.add_argument("--gap", type=float, default=0.0, metavar="S", help="sleep between burst attempts")
|
|
332
|
+
p_prune.add_argument("--apply", action="store_true", help="write the planned edit (previous copy kept as .bak)")
|
|
333
|
+
p_prune.add_argument("--diff-out", metavar="PATH", default="", help="write the unified diff to PATH instead of only showing it")
|
|
334
|
+
p_prune.set_defaults(func=cmd_prune)
|
|
335
|
+
|
|
336
|
+
p_chains = sub.add_parser(
|
|
337
|
+
"chains",
|
|
338
|
+
parents=[common],
|
|
339
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
340
|
+
help="validate agent fallback chains against the lane rules that read them",
|
|
341
|
+
description="A fallback chain that parses is not a fallback chain that runs. Bare strings, "
|
|
342
|
+
"rungs missing a field the lane requires, duplicates the runtime dedupes away, a rung equal "
|
|
343
|
+
"to its own primary, and keys nothing reads are all reported. Exit 1 when any chain has issues.",
|
|
344
|
+
)
|
|
345
|
+
p_chains.add_argument("path", help="agent config to read (YAML)")
|
|
346
|
+
p_chains.add_argument(
|
|
347
|
+
"--cross-check",
|
|
348
|
+
action="store_true",
|
|
349
|
+
help="also fetch the gateway catalog and flag rungs naming a model the upstream no longer serves",
|
|
350
|
+
)
|
|
351
|
+
p_chains.set_defaults(func=cmd_chains)
|
|
352
|
+
|
|
353
|
+
p_self = sub.add_parser(
|
|
354
|
+
"selfcheck",
|
|
355
|
+
help="run the offline gate: no network, no config, nothing written",
|
|
356
|
+
description="Exercises the prune guards, the report renderers, the argv redaction and the "
|
|
357
|
+
"measurement defaults. This is what CI runs.",
|
|
358
|
+
)
|
|
359
|
+
p_self.set_defaults(func=cmd_selfcheck)
|
|
360
|
+
return parser
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# --- shared helpers ---------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _palette(args: object) -> Palette:
|
|
367
|
+
pal = Palette.detect()
|
|
368
|
+
choice = str(getattr(args, "color", "auto") or "auto")
|
|
369
|
+
if choice == "always":
|
|
370
|
+
return dataclasses.replace(pal, color=True)
|
|
371
|
+
if choice == "never":
|
|
372
|
+
return dataclasses.replace(pal, color=False)
|
|
373
|
+
return pal
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class _Ticker:
|
|
377
|
+
"""One glyph per finished request, on stderr, from worker threads.
|
|
378
|
+
|
|
379
|
+
Progress belongs on stderr so ``--json -`` style piping and the report itself
|
|
380
|
+
stay clean; the lock is real because ``on_result`` fires inside the pool.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(self, pal: Palette, width: int = TICK_WIDTH) -> None:
|
|
384
|
+
self._pal = pal
|
|
385
|
+
self._width = max(1, int(width))
|
|
386
|
+
self._lock = threading.Lock()
|
|
387
|
+
self._n = 0
|
|
388
|
+
|
|
389
|
+
def __call__(self, item: object) -> None:
|
|
390
|
+
verdict = getattr(item, "verdict", "") or (
|
|
391
|
+
taxonomy.OK if getattr(item, "ok", False) else taxonomy.ERR
|
|
392
|
+
)
|
|
393
|
+
glyph = self._pal.paint(self._pal.glyph(verdict), *VERDICT_STYLE.get(verdict, ("white",)))
|
|
394
|
+
with self._lock:
|
|
395
|
+
self._n += 1
|
|
396
|
+
sys.stderr.write(glyph)
|
|
397
|
+
if self._n % self._width == 0:
|
|
398
|
+
sys.stderr.write("\n")
|
|
399
|
+
sys.stderr.flush()
|
|
400
|
+
|
|
401
|
+
def close(self) -> None:
|
|
402
|
+
with self._lock:
|
|
403
|
+
if self._n and self._n % self._width:
|
|
404
|
+
sys.stderr.write("\n")
|
|
405
|
+
sys.stderr.flush()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _ticker(args: object, pal: Palette) -> "_Ticker | None":
|
|
409
|
+
if getattr(args, "quiet", False):
|
|
410
|
+
return None
|
|
411
|
+
try:
|
|
412
|
+
if not sys.stderr.isatty():
|
|
413
|
+
return None
|
|
414
|
+
except (AttributeError, ValueError):
|
|
415
|
+
return None
|
|
416
|
+
return _Ticker(pal)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _gateway(args: object) -> Gateway:
|
|
420
|
+
base = str(getattr(args, "base_url", "") or "").strip()
|
|
421
|
+
if not base:
|
|
422
|
+
_die("no gateway: pass --base-url URL or set POOLPROBE_BASE_URL", EXIT_USAGE)
|
|
423
|
+
return Gateway(base, api_key=str(getattr(args, "api_key", "") or ""))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _adapter(args: object, gateway: Gateway, warnings: list) -> object:
|
|
427
|
+
kind = str(getattr(args, "adapter", "auto") or "auto")
|
|
428
|
+
config = str(getattr(args, "config", "") or "").strip()
|
|
429
|
+
try:
|
|
430
|
+
# build_adapter treats None as "no config"; an empty string would become
|
|
431
|
+
# Path('.') and produce a "config not found: ." message that misleads.
|
|
432
|
+
return build_adapter(kind, gateway, Path(config).expanduser() if config else None)
|
|
433
|
+
except ConfigError as exc:
|
|
434
|
+
_die(str(exc), EXIT_USAGE)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _catalog(args: object, adapter: object, warnings: list) -> list:
|
|
438
|
+
named = list(getattr(args, "models", None) or [])
|
|
439
|
+
if named:
|
|
440
|
+
return list(dict.fromkeys(named))
|
|
441
|
+
try:
|
|
442
|
+
models = adapter.models()
|
|
443
|
+
except GatewayError as exc:
|
|
444
|
+
_die(f"gateway catalog failed: {exc}", EXIT_GATEWAY)
|
|
445
|
+
if not models:
|
|
446
|
+
# An empty pool would make every gate vacuously green — refuse it.
|
|
447
|
+
_die("gateway published an empty catalog — nothing to probe", EXIT_GATEWAY)
|
|
448
|
+
return list(models)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _attributions(adapter: object, models: list, warnings: list) -> dict:
|
|
452
|
+
try:
|
|
453
|
+
known = adapter.attribution_map()
|
|
454
|
+
except ConfigError as exc:
|
|
455
|
+
warnings.append(f"attribution unavailable, provider column will be empty: {exc}")
|
|
456
|
+
return {}
|
|
457
|
+
return {name: known[name] for name in models if name in known}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _attach_providers(results: list, attrs: dict) -> None:
|
|
461
|
+
"""``sweep`` cannot know providers; the adapter can. Fill the gap in place."""
|
|
462
|
+
for item in results:
|
|
463
|
+
attr = attrs.get(item.model)
|
|
464
|
+
if attr is not None and attr.provider and not getattr(item, "provider", ""):
|
|
465
|
+
item.provider = attr.provider
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _protect(args: object) -> set:
|
|
469
|
+
out: set = set()
|
|
470
|
+
for chunk in getattr(args, "protect", None) or []:
|
|
471
|
+
for name in str(chunk).split(","):
|
|
472
|
+
name = name.strip()
|
|
473
|
+
if name:
|
|
474
|
+
out.add(name)
|
|
475
|
+
return out
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _measure_args(args: object, bench_mode: bool = False) -> tuple:
|
|
479
|
+
threads = getattr(args, "threads", None) or (bench.DEFAULT_THREADS if bench_mode else SWEEP_THREADS)
|
|
480
|
+
timeout = getattr(args, "timeout", None) or DEFAULT_TIMEOUT
|
|
481
|
+
max_tokens = getattr(args, "max_tokens", None) or (
|
|
482
|
+
bench.DEFAULT_BENCH_MAX_TOKENS if bench_mode else DEFAULT_MAX_TOKENS
|
|
483
|
+
)
|
|
484
|
+
prompt = getattr(args, "prompt", None) or (bench.DEFAULT_BENCH_PROMPT if bench_mode else DEFAULT_PROMPT)
|
|
485
|
+
return int(threads), float(timeout), int(max_tokens), str(prompt)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _load_state(args: object, warnings: list) -> "State | None":
|
|
489
|
+
path = str(getattr(args, "state", "") or "").strip()
|
|
490
|
+
if not path:
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
state = State(path).load()
|
|
494
|
+
except OSError as exc: # load() is fail-open by design, but the open itself can still raise
|
|
495
|
+
warnings.append(f"state not readable, starting empty: {exc}")
|
|
496
|
+
return State(path)
|
|
497
|
+
warnings.extend(state.warnings)
|
|
498
|
+
return state
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _save_state(state: "State | None", warnings: list) -> int:
|
|
502
|
+
if state is None:
|
|
503
|
+
return 0
|
|
504
|
+
try:
|
|
505
|
+
state.save()
|
|
506
|
+
except OSError as exc:
|
|
507
|
+
warnings.append(f"state not saved: {exc}")
|
|
508
|
+
return int(state.run_count)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _streak_map(state: "State | None", results: list) -> dict:
|
|
512
|
+
if state is None:
|
|
513
|
+
return {}
|
|
514
|
+
out: dict = {}
|
|
515
|
+
for item in results:
|
|
516
|
+
entry = state.get(item.model)
|
|
517
|
+
if entry is not None:
|
|
518
|
+
out[item.model] = (int(entry.fail_streak), int(entry.ok_streak))
|
|
519
|
+
return out
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _prunable_rows(results: list, state: object, args: object, attrs: dict) -> list:
|
|
523
|
+
"""The five guards, applied in one place so report and prune cannot drift."""
|
|
524
|
+
streak_min = int(getattr(args, "streak_min", DEFAULT_STREAK_MIN) or 0)
|
|
525
|
+
protect = _protect(args)
|
|
526
|
+
rows: list = []
|
|
527
|
+
for item in results:
|
|
528
|
+
streak = int(state.streak(item.model)) if state is not None else 0
|
|
529
|
+
if not taxonomy.prunable(item.model, item.verdict, item.kind, streak, streak_min, protect):
|
|
530
|
+
continue
|
|
531
|
+
attr = attrs.get(item.model)
|
|
532
|
+
rows.append(
|
|
533
|
+
Prunable(
|
|
534
|
+
model=item.model,
|
|
535
|
+
kind=item.kind,
|
|
536
|
+
verdict=item.verdict,
|
|
537
|
+
streak=streak,
|
|
538
|
+
attempts=int(getattr(item, "attempts", 1) or 1),
|
|
539
|
+
provider=(attr.provider if attr is not None and attr.provider else getattr(item, "provider", "")),
|
|
540
|
+
note=item.note,
|
|
541
|
+
advice=item.advice,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
return rows
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _meta(args: object, command: str, **kwargs) -> RunMeta:
|
|
548
|
+
"""Build the header. Every field is read defensively so an empty
|
|
549
|
+
``Namespace()`` works — the selfcheck depends on that."""
|
|
550
|
+
started = kwargs.get("started")
|
|
551
|
+
gateway = kwargs.get("gateway")
|
|
552
|
+
adapter = kwargs.get("adapter")
|
|
553
|
+
warnings = kwargs.get("warnings") or []
|
|
554
|
+
notes = kwargs.get("notes") or []
|
|
555
|
+
return RunMeta(
|
|
556
|
+
command=command,
|
|
557
|
+
gateway=str(getattr(gateway, "root", "") or "") if gateway is not None else "",
|
|
558
|
+
adapter=str(adapter.describe()) if adapter is not None else "",
|
|
559
|
+
generated_at=datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
560
|
+
duration_s=round(time.monotonic() - float(started), 2) if started is not None else 0.0,
|
|
561
|
+
pool=int(kwargs.get("pool") or 0),
|
|
562
|
+
run_count=int(kwargs.get("run_count") or 0),
|
|
563
|
+
state_path=str(getattr(args, "state", "") or ""),
|
|
564
|
+
attempts=int(getattr(args, "attempts", 1) or 1),
|
|
565
|
+
threads=int(kwargs.get("threads") or 1),
|
|
566
|
+
timeout=float(kwargs.get("timeout") or 0.0),
|
|
567
|
+
max_tokens=int(kwargs.get("max_tokens") or 0),
|
|
568
|
+
stream=bool(kwargs.get("stream", False)),
|
|
569
|
+
repeat=int(kwargs.get("repeat", 1) or 1),
|
|
570
|
+
argv=_redacted_argv(),
|
|
571
|
+
# Kept as the live lists, not copies: commands append to warnings/notes
|
|
572
|
+
# after the report exists and the renderers must see the final state.
|
|
573
|
+
warnings=list(warnings) if not isinstance(warnings, list) else warnings,
|
|
574
|
+
notes=list(notes) if not isinstance(notes, list) else notes,
|
|
575
|
+
version=__version__,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _emit(report: Report, args: object, *, dead: bool) -> int:
|
|
580
|
+
"""Terminal always (unless --quiet), JSON/HTML only when a path was given."""
|
|
581
|
+
if not getattr(args, "quiet", False):
|
|
582
|
+
try:
|
|
583
|
+
sys.stdout.write(render_terminal(report, palette=_palette(args)) + "\n")
|
|
584
|
+
sys.stdout.flush()
|
|
585
|
+
except BrokenPipeError:
|
|
586
|
+
_silence_pipes()
|
|
587
|
+
return EXIT_OK
|
|
588
|
+
for flag, renderer in (("json", render_json), ("html", render_html)):
|
|
589
|
+
path = str(getattr(args, flag, "") or "")
|
|
590
|
+
if not path:
|
|
591
|
+
continue
|
|
592
|
+
try:
|
|
593
|
+
write_atomic(path, renderer(report) + "\n", backup=False)
|
|
594
|
+
except OSError as exc:
|
|
595
|
+
_note(f"could not write {path}: {exc}")
|
|
596
|
+
continue
|
|
597
|
+
_note(f"wrote {path}")
|
|
598
|
+
return EXIT_DEAD if dead else EXIT_OK
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# --- commands ---------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def cmd_ping(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
605
|
+
warnings: list = []
|
|
606
|
+
notes: list = []
|
|
607
|
+
started = time.monotonic()
|
|
608
|
+
gateway = _gateway(args)
|
|
609
|
+
adapter = _adapter(args, gateway, warnings)
|
|
610
|
+
try:
|
|
611
|
+
models = list_models(gateway, timeout=float(getattr(args, "timeout", None) or 20.0))
|
|
612
|
+
except GatewayError as exc:
|
|
613
|
+
_die(f"gateway unreachable: {exc}", EXIT_GATEWAY)
|
|
614
|
+
notes.append(f"{len(models)} model(s) published")
|
|
615
|
+
attrs = _attributions(adapter, models, warnings)
|
|
616
|
+
per_provider: dict = {}
|
|
617
|
+
for name in models:
|
|
618
|
+
attr = attrs.get(name)
|
|
619
|
+
if attr is not None and attr.provider:
|
|
620
|
+
per_provider[attr.provider] = per_provider.get(attr.provider, 0) + 1
|
|
621
|
+
if per_provider:
|
|
622
|
+
ranked = sorted(per_provider.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
623
|
+
notes.append("providers: " + ", ".join(f"{provider}\u00d7{count}" for provider, count in ranked))
|
|
624
|
+
disabled = sorted(name for name, attr in attrs.items() if attr.disabled)
|
|
625
|
+
if disabled:
|
|
626
|
+
notes.append(f"{len(disabled)} configured model(s) marked disabled: {', '.join(disabled[:8])}")
|
|
627
|
+
if len(models) <= LIST_LIMIT:
|
|
628
|
+
notes.append("models: " + ", ".join(models))
|
|
629
|
+
else:
|
|
630
|
+
notes.append(f"models: {len(models)} published, too many to list here \u2014 run `poolprobe sweep` for the table")
|
|
631
|
+
report = Report()
|
|
632
|
+
report.meta = _meta(
|
|
633
|
+
args, "ping", gateway=gateway, adapter=adapter, started=started,
|
|
634
|
+
pool=len(models), warnings=warnings, notes=notes,
|
|
635
|
+
)
|
|
636
|
+
return _emit(report, args, dead=False)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def cmd_sweep(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
640
|
+
warnings: list = []
|
|
641
|
+
notes: list = []
|
|
642
|
+
started = time.monotonic()
|
|
643
|
+
gateway = _gateway(args)
|
|
644
|
+
adapter = _adapter(args, gateway, warnings)
|
|
645
|
+
models = _catalog(args, adapter, warnings)
|
|
646
|
+
attrs = _attributions(adapter, models, warnings)
|
|
647
|
+
threads, timeout, max_tokens, prompt = _measure_args(args)
|
|
648
|
+
tick = _ticker(args, _palette(args))
|
|
649
|
+
results = sweep(
|
|
650
|
+
gateway,
|
|
651
|
+
models,
|
|
652
|
+
threads=threads,
|
|
653
|
+
timeout=timeout,
|
|
654
|
+
max_tokens=max_tokens,
|
|
655
|
+
prompt=prompt,
|
|
656
|
+
attempts=int(args.attempts),
|
|
657
|
+
gap_s=float(args.gap),
|
|
658
|
+
on_result=tick,
|
|
659
|
+
)
|
|
660
|
+
if tick is not None:
|
|
661
|
+
tick.close()
|
|
662
|
+
_attach_providers(results, attrs)
|
|
663
|
+
|
|
664
|
+
state = _load_state(args, warnings)
|
|
665
|
+
if state is not None:
|
|
666
|
+
stats = state.record(results, full_sweep=not getattr(args, "models", None))
|
|
667
|
+
if stats.dropped:
|
|
668
|
+
notes.append(
|
|
669
|
+
f"dropped {len(stats.dropped)} model(s) unseen in this full sweep: "
|
|
670
|
+
f"{', '.join(sorted(stats.dropped)[:8])}"
|
|
671
|
+
)
|
|
672
|
+
run_count = _save_state(state, warnings)
|
|
673
|
+
|
|
674
|
+
report = Report(
|
|
675
|
+
probes=results,
|
|
676
|
+
prunable=_prunable_rows(results, state, args, attrs),
|
|
677
|
+
streaks=_streak_map(state, results),
|
|
678
|
+
attribution={name: attr.as_dict() for name, attr in attrs.items()},
|
|
679
|
+
)
|
|
680
|
+
summary = _summarize(results)
|
|
681
|
+
report.meta = _meta(
|
|
682
|
+
args, "sweep", gateway=gateway, adapter=adapter, started=started,
|
|
683
|
+
threads=threads, timeout=timeout, max_tokens=max_tokens,
|
|
684
|
+
pool=len(models), run_count=run_count, warnings=warnings, notes=notes,
|
|
685
|
+
)
|
|
686
|
+
return _emit(report, args, dead=bool(summary["dead"]))
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def cmd_bench(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
690
|
+
warnings: list = []
|
|
691
|
+
notes: list = []
|
|
692
|
+
started = time.monotonic()
|
|
693
|
+
gateway = _gateway(args)
|
|
694
|
+
adapter = _adapter(args, gateway, warnings)
|
|
695
|
+
models = _catalog(args, adapter, warnings)
|
|
696
|
+
attrs = _attributions(adapter, models, warnings)
|
|
697
|
+
threads, timeout, max_tokens, prompt = _measure_args(args, bench_mode=True)
|
|
698
|
+
tick = _ticker(args, _palette(args))
|
|
699
|
+
results = bench.bench_many(
|
|
700
|
+
gateway,
|
|
701
|
+
models,
|
|
702
|
+
threads=threads,
|
|
703
|
+
prompt=prompt,
|
|
704
|
+
max_tokens=max_tokens,
|
|
705
|
+
timeout=timeout,
|
|
706
|
+
stream=bool(getattr(args, "stream", False)),
|
|
707
|
+
repeat=int(getattr(args, "repeat", 1) or 1),
|
|
708
|
+
providers={name: attr.provider for name, attr in attrs.items()},
|
|
709
|
+
on_result=tick,
|
|
710
|
+
)
|
|
711
|
+
if tick is not None:
|
|
712
|
+
tick.close()
|
|
713
|
+
|
|
714
|
+
report = Report(
|
|
715
|
+
bench=results,
|
|
716
|
+
bench_summary=bench.summarize(results),
|
|
717
|
+
attribution={name: attr.as_dict() for name, attr in attrs.items()},
|
|
718
|
+
)
|
|
719
|
+
summary = report.bench_summary
|
|
720
|
+
exact = int(summary.get("with_exact_tokens") or 0)
|
|
721
|
+
if exact < len(results):
|
|
722
|
+
notes.append(
|
|
723
|
+
f"{len(results) - exact} of {len(results)} model(s) gave no usable token count \u2014 "
|
|
724
|
+
"their throughput is a lower bound or a dash, not a measurement"
|
|
725
|
+
)
|
|
726
|
+
report.meta = _meta(
|
|
727
|
+
args, "bench", gateway=gateway, adapter=adapter, started=started,
|
|
728
|
+
threads=threads, timeout=timeout, max_tokens=max_tokens,
|
|
729
|
+
stream=bool(getattr(args, "stream", False)), repeat=int(getattr(args, "repeat", 1) or 1),
|
|
730
|
+
pool=len(models), warnings=warnings, notes=notes,
|
|
731
|
+
)
|
|
732
|
+
return _emit(report, args, dead=bool(int(summary.get("dead") or 0)))
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def cmd_prune(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
736
|
+
warnings: list = []
|
|
737
|
+
notes: list = []
|
|
738
|
+
started = time.monotonic()
|
|
739
|
+
gateway = _gateway(args)
|
|
740
|
+
adapter = _adapter(args, gateway, warnings)
|
|
741
|
+
models = _catalog(args, adapter, warnings)
|
|
742
|
+
attrs = _attributions(adapter, models, warnings)
|
|
743
|
+
threads, timeout, max_tokens, prompt = _measure_args(args)
|
|
744
|
+
|
|
745
|
+
state = _load_state(args, warnings)
|
|
746
|
+
if state is None:
|
|
747
|
+
warnings.append(
|
|
748
|
+
"no --state path: every fail streak reads 0, so nothing can pass the streak guard \u2014 "
|
|
749
|
+
"pass --state PATH across runs to make prune decisive"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
tick = _ticker(args, _palette(args))
|
|
753
|
+
results = sweep(
|
|
754
|
+
gateway,
|
|
755
|
+
models,
|
|
756
|
+
threads=threads,
|
|
757
|
+
timeout=timeout,
|
|
758
|
+
max_tokens=max_tokens,
|
|
759
|
+
prompt=prompt,
|
|
760
|
+
attempts=int(args.attempts),
|
|
761
|
+
gap_s=float(args.gap),
|
|
762
|
+
on_result=tick,
|
|
763
|
+
)
|
|
764
|
+
if tick is not None:
|
|
765
|
+
tick.close()
|
|
766
|
+
_attach_providers(results, attrs)
|
|
767
|
+
|
|
768
|
+
if state is not None:
|
|
769
|
+
state.record(results, full_sweep=not getattr(args, "models", None))
|
|
770
|
+
run_count = _save_state(state, warnings)
|
|
771
|
+
rows = _prunable_rows(results, state, args, attrs)
|
|
772
|
+
|
|
773
|
+
report = Report(
|
|
774
|
+
probes=results,
|
|
775
|
+
prunable=rows,
|
|
776
|
+
streaks=_streak_map(state, results),
|
|
777
|
+
attribution={name: attr.as_dict() for name, attr in attrs.items()},
|
|
778
|
+
)
|
|
779
|
+
summary = _summarize(results)
|
|
780
|
+
|
|
781
|
+
if not rows:
|
|
782
|
+
notes.append("nothing passed all five guards \u2014 no edit planned")
|
|
783
|
+
elif not getattr(adapter, "removable", False):
|
|
784
|
+
warnings.append(
|
|
785
|
+
f"adapter {getattr(adapter, 'name', '?')!r} is read-only: {len(rows)} candidate(s) listed, "
|
|
786
|
+
"config not touched (use --adapter cliproxy --config PATH)"
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
_plan_edit(args, adapter, rows, report, warnings, notes)
|
|
790
|
+
|
|
791
|
+
report.meta = _meta(
|
|
792
|
+
args, "prune", gateway=gateway, adapter=adapter, started=started,
|
|
793
|
+
threads=threads, timeout=timeout, max_tokens=max_tokens,
|
|
794
|
+
pool=len(models), run_count=run_count, warnings=warnings, notes=notes,
|
|
795
|
+
)
|
|
796
|
+
return _emit(report, args, dead=bool(summary["dead"]))
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _plan_edit(args: Namespace, adapter: object, rows: list, report: Report, warnings: list, notes: list) -> None:
|
|
800
|
+
"""Turn candidates into a text-surgical edit plan; write only on --apply."""
|
|
801
|
+
try:
|
|
802
|
+
plan = adapter.plan_removal([row.model for row in rows])
|
|
803
|
+
except ConfigError as exc:
|
|
804
|
+
warnings.append(f"no edit planned: {exc}")
|
|
805
|
+
return
|
|
806
|
+
report.diff = plan.diff()
|
|
807
|
+
report.edit_path = str(plan.path)
|
|
808
|
+
report.edit_removed = list(plan.removed)
|
|
809
|
+
report.edit_not_found = list(plan.not_found)
|
|
810
|
+
warnings.extend(plan.warnings)
|
|
811
|
+
if not plan.changed:
|
|
812
|
+
warnings.append("planned edit is a no-op \u2014 no model entry matched in the config text")
|
|
813
|
+
return
|
|
814
|
+
if getattr(args, "apply", False):
|
|
815
|
+
try:
|
|
816
|
+
write_atomic(plan.path, plan.after)
|
|
817
|
+
except OSError as exc:
|
|
818
|
+
warnings.append(f"apply failed, config untouched: {exc}")
|
|
819
|
+
return
|
|
820
|
+
notes.append(
|
|
821
|
+
f"applied: removed {len(plan.removed)} model entr"
|
|
822
|
+
f"{'y' if len(plan.removed) == 1 else 'ies'} from {plan.path} (previous copy kept as .bak)"
|
|
823
|
+
)
|
|
824
|
+
return
|
|
825
|
+
notes.append("dry run: nothing written \u2014 re-run with --apply to make this edit")
|
|
826
|
+
diff_out = str(getattr(args, "diff_out", "") or "")
|
|
827
|
+
if diff_out:
|
|
828
|
+
try:
|
|
829
|
+
write_atomic(diff_out, plan.diff(), backup=False)
|
|
830
|
+
except OSError as exc:
|
|
831
|
+
warnings.append(f"could not write {diff_out}: {exc}")
|
|
832
|
+
else:
|
|
833
|
+
_note(f"wrote {diff_out}")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def cmd_chains(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
837
|
+
warnings: list = []
|
|
838
|
+
notes: list = []
|
|
839
|
+
started = time.monotonic()
|
|
840
|
+
gateway = None
|
|
841
|
+
adapter = None
|
|
842
|
+
live = None
|
|
843
|
+
if getattr(args, "cross_check", False):
|
|
844
|
+
gateway = _gateway(args)
|
|
845
|
+
adapter = _adapter(args, gateway, warnings)
|
|
846
|
+
try:
|
|
847
|
+
live = set(adapter.models())
|
|
848
|
+
except GatewayError as exc:
|
|
849
|
+
warnings.append(f"cross-check skipped, catalog unavailable: {exc}")
|
|
850
|
+
try:
|
|
851
|
+
found = chains.validate_file(Path(args.path).expanduser(), live_models=live)
|
|
852
|
+
except ConfigError as exc:
|
|
853
|
+
_die(str(exc), EXIT_USAGE)
|
|
854
|
+
broken = [chain for chain in found if chain.get("issues")]
|
|
855
|
+
if not found:
|
|
856
|
+
notes.append(f"no fallback chains found in {args.path}")
|
|
857
|
+
elif not broken:
|
|
858
|
+
notes.append(f"{len(found)} chain(s) clean")
|
|
859
|
+
else:
|
|
860
|
+
# A finding, not a footnote: warnings render loud, notes render dim.
|
|
861
|
+
warnings.append(f"{len(broken)} of {len(found)} chain(s) carry issues")
|
|
862
|
+
report = Report(chains=found)
|
|
863
|
+
report.meta = _meta(
|
|
864
|
+
args, "chains", gateway=gateway, adapter=adapter, started=started,
|
|
865
|
+
warnings=warnings, notes=notes,
|
|
866
|
+
)
|
|
867
|
+
return _emit(report, args, dead=bool(broken))
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_selfcheck(args: Namespace, parser: argparse.ArgumentParser) -> int:
|
|
871
|
+
try:
|
|
872
|
+
selfcheck()
|
|
873
|
+
except AssertionError as exc:
|
|
874
|
+
_die(f"selfcheck failed: {exc}", EXIT_DEAD)
|
|
875
|
+
except ConfigError as exc:
|
|
876
|
+
_die(f"selfcheck failed: {exc}", EXIT_DEAD)
|
|
877
|
+
sys.stdout.write("poolprobe selfcheck ok\n")
|
|
878
|
+
return EXIT_OK
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _summarize(results: list) -> dict:
|
|
882
|
+
return summarize_probes(results)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# --- offline gate -----------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def selfcheck() -> None:
|
|
889
|
+
"""Assert-based gate with no network, no config file and no state on disk.
|
|
890
|
+
|
|
891
|
+
Covers the two places this module owns logic the renderers do not: the prune
|
|
892
|
+
guards (a report that says prunable while the prune cycle removes something
|
|
893
|
+
else is the worst failure mode this tool has) and the header plumbing.
|
|
894
|
+
"""
|
|
895
|
+
from .probe import ProbeResult
|
|
896
|
+
|
|
897
|
+
chains.selfcheck()
|
|
898
|
+
safewrite.selfcheck()
|
|
899
|
+
|
|
900
|
+
class _StubState:
|
|
901
|
+
def __init__(self, streak: int) -> None:
|
|
902
|
+
self._streak = int(streak)
|
|
903
|
+
|
|
904
|
+
def streak(self, model: str) -> int:
|
|
905
|
+
return self._streak
|
|
906
|
+
|
|
907
|
+
def _row(model: str, verdict: str, kind: str, note: str = "note") -> ProbeResult:
|
|
908
|
+
return ProbeResult(model=model, verdict=verdict, kind=kind, note=note)
|
|
909
|
+
|
|
910
|
+
dead = _row("dead-model", taxonomy.ERR, taxonomy.PERMANENT)
|
|
911
|
+
alive = _row("live-model", taxonomy.OK, taxonomy.ALIVE)
|
|
912
|
+
starved = _row("thinker", taxonomy.EMPTY_LENGTH, taxonomy.ALIVE_STARVED)
|
|
913
|
+
transient = _row("flaky", taxonomy.ERR, taxonomy.TRANSIENT)
|
|
914
|
+
painter = _row("some-image-model", taxonomy.ERR, taxonomy.PERMANENT)
|
|
915
|
+
|
|
916
|
+
strict = Namespace(streak_min=DEFAULT_STREAK_MIN, protect=[])
|
|
917
|
+
picked = [row.model for row in _prunable_rows([dead, alive, starved, transient, painter], _StubState(3), strict, {})]
|
|
918
|
+
assert picked == ["dead-model"], picked
|
|
919
|
+
assert not _prunable_rows([dead], _StubState(1), strict, {}), "streak 1 must not clear streak_min 2"
|
|
920
|
+
assert not _prunable_rows([dead], None, strict, {}), "no state means no streak means no prune"
|
|
921
|
+
assert not _prunable_rows([alive, starved, transient], _StubState(9), strict, {}), "only PERMANENT is prunable"
|
|
922
|
+
assert not _prunable_rows([painter], _StubState(9), strict, {}), "generation modalities are never prunable"
|
|
923
|
+
|
|
924
|
+
guarded = Namespace(streak_min=DEFAULT_STREAK_MIN, protect=["dead-model"])
|
|
925
|
+
assert not _prunable_rows([dead], _StubState(9), guarded, {}), "--protect outranks a perfect streak"
|
|
926
|
+
namespaced = _row("Lite/dead-model", taxonomy.ERR, taxonomy.PERMANENT)
|
|
927
|
+
assert not _prunable_rows([namespaced], _StubState(9), guarded, {}), "protect must match the base name too"
|
|
928
|
+
comma = Namespace(streak_min=1, protect=["a, b ,, c"])
|
|
929
|
+
assert _protect(comma) == {"a", "b", "c"}, _protect(comma)
|
|
930
|
+
|
|
931
|
+
rows = _prunable_rows([dead], _StubState(4), Namespace(streak_min=1, protect=[]), {})
|
|
932
|
+
assert len(rows) == 1 and rows[0].streak == 4 and rows[0].verdict == taxonomy.ERR, rows
|
|
933
|
+
|
|
934
|
+
# Header plumbing on an empty Namespace: no attribute may be assumed present.
|
|
935
|
+
meta = _meta(Namespace(), "selfcheck")
|
|
936
|
+
assert meta.version == __version__ and meta.command == "selfcheck", meta
|
|
937
|
+
assert meta.stamp(), "a report without a timestamp is undatable"
|
|
938
|
+
|
|
939
|
+
masked = _redacted_argv(["/usr/bin/poolprobe", "sweep", "-k", "sk-live-123", "--api-key=sk-x", "-u", "http://h/v1"])
|
|
940
|
+
assert masked[0] == "poolprobe" and "sk-live-123" not in " ".join(masked), masked
|
|
941
|
+
assert "sk-x" not in " ".join(masked) and masked.count("***") == 1, masked
|
|
942
|
+
# ``python -m poolprobe`` sets argv[0] to __main__.py; the echo must not leak it.
|
|
943
|
+
assert _redacted_argv(["/x/poolprobe/__main__.py", "ping"])[0] == "poolprobe"
|
|
944
|
+
|
|
945
|
+
# A sweep and a bench must not share measurement defaults.
|
|
946
|
+
sweep_args = _measure_args(Namespace(), bench_mode=False)
|
|
947
|
+
bench_args = _measure_args(Namespace(), bench_mode=True)
|
|
948
|
+
assert sweep_args[0] == SWEEP_THREADS and bench_args[0] == bench.DEFAULT_THREADS, (sweep_args, bench_args)
|
|
949
|
+
assert sweep_args[2] == DEFAULT_MAX_TOKENS and bench_args[2] == bench.DEFAULT_BENCH_MAX_TOKENS, (sweep_args, bench_args)
|
|
950
|
+
pinned = _measure_args(Namespace(threads=2, timeout=5.0, max_tokens=9, prompt="hi"))
|
|
951
|
+
assert pinned == (2, 5.0, 9, "hi"), pinned
|
|
952
|
+
|
|
953
|
+
# All three renderers must accept both a probe report and a chains-only one.
|
|
954
|
+
probe_report = Report(probes=[dead, alive], prunable=rows, streaks={"dead-model": (4, 0)})
|
|
955
|
+
probe_report.meta = _meta(Namespace(), "sweep", warnings=["a warning"], notes=["a note"])
|
|
956
|
+
chain_report = Report(
|
|
957
|
+
chains=[
|
|
958
|
+
{"label": "main", "rungs": ["p/m"], "issues": []},
|
|
959
|
+
{"label": "aux:x", "rungs": ["bare"], "issues": ["rung 1 is a bare str"]},
|
|
960
|
+
]
|
|
961
|
+
)
|
|
962
|
+
chain_report.meta = _meta(Namespace(), "chains")
|
|
963
|
+
for report in (probe_report, chain_report):
|
|
964
|
+
for text in (render_terminal(report, palette=Palette(color=False)), render_json(report), render_html(report)):
|
|
965
|
+
assert text.strip(), "renderer produced an empty document"
|
|
966
|
+
assert "poolprobe" in render_html(report)
|
|
967
|
+
terminal = render_terminal(probe_report, palette=Palette(color=False))
|
|
968
|
+
assert "dead-model" in terminal and "live-model" in terminal, terminal
|
|
969
|
+
assert "a warning" in terminal and "a note" in terminal, terminal
|
|
970
|
+
painted = render_terminal(probe_report, palette=Palette(color=True))
|
|
971
|
+
assert "\033[" in painted, "colour policy ignored an explicit palette"
|
|
972
|
+
assert "\033[" not in render_terminal(probe_report, palette=Palette(color=False))
|
|
973
|
+
|
|
974
|
+
assert len({EXIT_OK, EXIT_DEAD, EXIT_USAGE, EXIT_GATEWAY, EXIT_INTERRUPT}) == 5
|
|
975
|
+
assert _palette(Namespace(color="always")).color is True
|
|
976
|
+
assert _palette(Namespace(color="never")).color is False
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
# --- entry point ------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def main(argv: "list | None" = None) -> int:
|
|
983
|
+
parser = build_parser()
|
|
984
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
985
|
+
if not raw:
|
|
986
|
+
parser.print_help(sys.stderr)
|
|
987
|
+
return EXIT_USAGE
|
|
988
|
+
args = parser.parse_args(raw)
|
|
989
|
+
func = getattr(args, "func", None)
|
|
990
|
+
if func is None:
|
|
991
|
+
parser.print_help(sys.stderr)
|
|
992
|
+
return EXIT_USAGE
|
|
993
|
+
try:
|
|
994
|
+
return int(func(args, parser))
|
|
995
|
+
except KeyboardInterrupt:
|
|
996
|
+
sys.stderr.write("\npoolprobe: interrupted\n")
|
|
997
|
+
return EXIT_INTERRUPT
|
|
998
|
+
except BrokenPipeError:
|
|
999
|
+
_silence_pipes()
|
|
1000
|
+
return EXIT_OK
|
|
1001
|
+
except ConfigError as exc:
|
|
1002
|
+
_die(str(exc), EXIT_USAGE)
|
|
1003
|
+
except GatewayError as exc:
|
|
1004
|
+
_die(str(exc), EXIT_GATEWAY)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
if __name__ == "__main__":
|
|
1008
|
+
raise SystemExit(main())
|