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