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/chains.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""poolprobe.chains — a fallback chain that looks configured and is not.
|
|
2
|
+
|
|
3
|
+
A chain is the last thing standing between an agent and a dead upstream, and
|
|
4
|
+
every way it breaks is silent. The runtime does not warn about a rung it
|
|
5
|
+
dropped, a duplicate it collapsed, or a key nobody reads; the agent just falls
|
|
6
|
+
through to a worse model and the operator finds out a week later.
|
|
7
|
+
|
|
8
|
+
Two lanes exist and they do not share rules:
|
|
9
|
+
|
|
10
|
+
* **main** — top-level ``fallback_providers`` (plus the legacy single-dict
|
|
11
|
+
``fallback_model``). A rung needs *both* ``provider`` and ``model``; an
|
|
12
|
+
incomplete one is skipped without a word. Identical rungs are deduped away.
|
|
13
|
+
* **aux** — ``auxiliary.<task>.fallback_chain``. A rung needs *only*
|
|
14
|
+
``provider``; ``model`` is optional and resolves later. A chain that is not a
|
|
15
|
+
list is ignored in its entirety. No dedup, so a repeated rung really does
|
|
16
|
+
burn a retry.
|
|
17
|
+
|
|
18
|
+
And in both lanes ``priority`` is decoration: nothing sorts by it, the order in
|
|
19
|
+
the file is the order they are tried.
|
|
20
|
+
|
|
21
|
+
``validate`` returns plain dicts — ``{"label", "rungs", "issues"}`` — which is
|
|
22
|
+
exactly what :class:`poolprobe.report.Report` renders. No logic lives in the
|
|
23
|
+
report layer.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .adapter import ConfigError, _load_yaml
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ConfigError",
|
|
35
|
+
"load_config",
|
|
36
|
+
"validate",
|
|
37
|
+
"validate_file",
|
|
38
|
+
"selfcheck",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: keys that look like chain config but are read by nothing
|
|
42
|
+
DEAD_CHAIN_KEYS: tuple[tuple[str, str], ...] = (
|
|
43
|
+
("model", "fallback_chain"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _norm(value: Any) -> str:
|
|
48
|
+
"""Config values arrive as whatever YAML produced; chains compare strings."""
|
|
49
|
+
if value is None:
|
|
50
|
+
return ""
|
|
51
|
+
return str(value).strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _short(value: Any, limit: int = 48) -> str:
|
|
55
|
+
text = value if isinstance(value, str) else repr(value)
|
|
56
|
+
return text if len(text) <= limit else text[: limit - 1] + "\u2026"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config(path: str | Path) -> dict:
|
|
60
|
+
"""Read a gateway/agent YAML config into a dict (empty dict if not a mapping)."""
|
|
61
|
+
data = _load_yaml(Path(path).expanduser())
|
|
62
|
+
return data if isinstance(data, dict) else {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _same_backend(
|
|
66
|
+
rung_provider: str,
|
|
67
|
+
rung_model: str,
|
|
68
|
+
primary_provider: str,
|
|
69
|
+
primary_model: str,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""A rung pointing at the backend that just failed is not a fallback."""
|
|
72
|
+
if not rung_model or not primary_model:
|
|
73
|
+
return False
|
|
74
|
+
if rung_model.lower() != primary_model.lower():
|
|
75
|
+
return False
|
|
76
|
+
if not rung_provider or not primary_provider:
|
|
77
|
+
return True
|
|
78
|
+
return rung_provider.lower() == primary_provider.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _entries_of(raw: Any) -> list[Any] | None:
|
|
82
|
+
"""Normalise a chain value the way the runtime does.
|
|
83
|
+
|
|
84
|
+
``None`` means "nothing to check". A non-list non-dict value yields ``[]``
|
|
85
|
+
with the caller reporting it, because that is precisely the silent drop.
|
|
86
|
+
"""
|
|
87
|
+
if raw is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(raw, dict):
|
|
90
|
+
return [raw]
|
|
91
|
+
if isinstance(raw, list):
|
|
92
|
+
return raw
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _priority_issue(raw: Any) -> str:
|
|
97
|
+
"""``priority`` never reorders anything — flag it only when it misleads."""
|
|
98
|
+
if not isinstance(raw, list):
|
|
99
|
+
return ""
|
|
100
|
+
declared: list[tuple[int, Any]] = []
|
|
101
|
+
for index, entry in enumerate(raw, start=1):
|
|
102
|
+
if isinstance(entry, dict) and entry.get("priority") is not None:
|
|
103
|
+
declared.append((index, entry.get("priority")))
|
|
104
|
+
if len(declared) < 2:
|
|
105
|
+
return ""
|
|
106
|
+
for index, value in declared:
|
|
107
|
+
try:
|
|
108
|
+
number = int(value)
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return (
|
|
111
|
+
f"rung {index} declares priority {_short(value)!r} \u2014 priority is not "
|
|
112
|
+
"read at all; file order is the only order"
|
|
113
|
+
)
|
|
114
|
+
if number != index:
|
|
115
|
+
return (
|
|
116
|
+
f"rung {index} declares priority {number} \u2014 nothing sorts by priority, "
|
|
117
|
+
"rungs are tried in file order; the numbers are decoration"
|
|
118
|
+
)
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_chain(
|
|
123
|
+
label: str,
|
|
124
|
+
raw: Any,
|
|
125
|
+
*,
|
|
126
|
+
primary: tuple[str, str] = ("", ""),
|
|
127
|
+
require_model: bool = True,
|
|
128
|
+
dedup: bool = True,
|
|
129
|
+
live_models: set[str] | None = None,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""Validate one chain against the lane rules it will actually be read by.
|
|
132
|
+
|
|
133
|
+
``require_model`` and ``dedup`` are the two places the main and aux lanes
|
|
134
|
+
genuinely differ; everything else is shared so the wording stays consistent.
|
|
135
|
+
"""
|
|
136
|
+
rungs: list[str] = []
|
|
137
|
+
issues: list[str] = []
|
|
138
|
+
entries = _entries_of(raw)
|
|
139
|
+
if entries is None:
|
|
140
|
+
return {"label": label, "rungs": rungs, "issues": issues}
|
|
141
|
+
|
|
142
|
+
if raw is not None and not isinstance(raw, (list, dict)):
|
|
143
|
+
issues.append(
|
|
144
|
+
f"declared as {_short(raw)} ({type(raw).__name__}), not a list \u2014 the whole "
|
|
145
|
+
"chain is ignored silently"
|
|
146
|
+
)
|
|
147
|
+
return {"label": label, "rungs": rungs, "issues": issues}
|
|
148
|
+
|
|
149
|
+
if isinstance(raw, list) and not raw:
|
|
150
|
+
issues.append("declared as an empty list \u2014 nothing to fall back to")
|
|
151
|
+
return {"label": label, "rungs": rungs, "issues": issues}
|
|
152
|
+
|
|
153
|
+
priority_issue = _priority_issue(raw)
|
|
154
|
+
if priority_issue:
|
|
155
|
+
issues.append(priority_issue)
|
|
156
|
+
|
|
157
|
+
primary_provider, primary_model = primary
|
|
158
|
+
seen: dict[tuple[str, str, str], int] = {}
|
|
159
|
+
usable = 0
|
|
160
|
+
|
|
161
|
+
for index, entry in enumerate(entries, start=1):
|
|
162
|
+
if not isinstance(entry, dict):
|
|
163
|
+
text = _short(entry)
|
|
164
|
+
issues.append(
|
|
165
|
+
f"rung {index} is a bare {type(entry).__name__} ({text}) \u2014 a rung needs "
|
|
166
|
+
"a mapping with at least a provider; dropped silently"
|
|
167
|
+
)
|
|
168
|
+
rungs.append(text)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
provider = _norm(entry.get("provider"))
|
|
172
|
+
model = _norm(entry.get("model"))
|
|
173
|
+
base_url = _norm(entry.get("base_url")).rstrip("/")
|
|
174
|
+
shown = f"{provider}/{model}" if provider and model else (provider or model or _short(entry))
|
|
175
|
+
rungs.append(shown)
|
|
176
|
+
|
|
177
|
+
if not provider:
|
|
178
|
+
issues.append(f"rung {index} ({shown}) has no provider \u2014 dropped silently")
|
|
179
|
+
continue
|
|
180
|
+
if require_model and not model:
|
|
181
|
+
issues.append(
|
|
182
|
+
f"rung {index} ({provider}) has no model \u2014 this lane requires provider "
|
|
183
|
+
"AND model; dropped silently"
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
usable += 1
|
|
188
|
+
identity = (provider.lower(), model.lower(), base_url.lower())
|
|
189
|
+
if identity in seen:
|
|
190
|
+
first = seen[identity]
|
|
191
|
+
tail = (
|
|
192
|
+
"the runtime dedupes it away, it never runs"
|
|
193
|
+
if dedup
|
|
194
|
+
else "no dedup here, it just burns a retry on the same backend"
|
|
195
|
+
)
|
|
196
|
+
issues.append(f"rung {index} ({shown}) duplicates rung {first} \u2014 {tail}")
|
|
197
|
+
continue
|
|
198
|
+
seen[identity] = index
|
|
199
|
+
|
|
200
|
+
if _same_backend(provider, model, primary_provider, primary_model):
|
|
201
|
+
issues.append(
|
|
202
|
+
f"rung {index} ({shown}) is the primary itself \u2014 falls back onto the "
|
|
203
|
+
"backend that just failed"
|
|
204
|
+
)
|
|
205
|
+
if live_models is not None and model and model not in live_models:
|
|
206
|
+
issues.append(
|
|
207
|
+
f"rung {index} model '{model}' is not in the live gateway catalog \u2014 this "
|
|
208
|
+
"rung can only ever fail"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if entries and usable == 0:
|
|
212
|
+
issues.append(
|
|
213
|
+
"every rung is dropped or duplicated \u2014 the lane falls straight through to "
|
|
214
|
+
"the safety net"
|
|
215
|
+
)
|
|
216
|
+
return {"label": label, "rungs": rungs, "issues": issues}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _dead_key_chains(config: dict) -> list[dict]:
|
|
220
|
+
"""Keys an operator edits in good faith that no code path reads."""
|
|
221
|
+
rungs: list[str] = []
|
|
222
|
+
issues: list[str] = []
|
|
223
|
+
for parent, key in DEAD_CHAIN_KEYS:
|
|
224
|
+
section = config.get(parent)
|
|
225
|
+
if isinstance(section, dict) and key in section:
|
|
226
|
+
rungs.append(f"{parent}.{key}")
|
|
227
|
+
issues.append(
|
|
228
|
+
f"{parent}.{key} is set but nothing reads it \u2014 the main lane reads "
|
|
229
|
+
"top-level fallback_providers only; edits here are lost"
|
|
230
|
+
)
|
|
231
|
+
if not rungs:
|
|
232
|
+
return []
|
|
233
|
+
return [{"label": "dead keys", "rungs": rungs, "issues": issues}]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate(
|
|
237
|
+
config: dict | None,
|
|
238
|
+
*,
|
|
239
|
+
live_models: set[str] | None = None,
|
|
240
|
+
) -> list[dict]:
|
|
241
|
+
"""Validate every fallback chain in an agent config.
|
|
242
|
+
|
|
243
|
+
``live_models`` is optional: pass the gateway catalog to also catch rungs
|
|
244
|
+
that name a model the upstream no longer serves.
|
|
245
|
+
"""
|
|
246
|
+
cfg = config if isinstance(config, dict) else {}
|
|
247
|
+
out: list[dict] = []
|
|
248
|
+
|
|
249
|
+
model_cfg = cfg.get("model")
|
|
250
|
+
model_cfg = model_cfg if isinstance(model_cfg, dict) else {}
|
|
251
|
+
main_primary = (_norm(model_cfg.get("provider")), _norm(model_cfg.get("default")))
|
|
252
|
+
|
|
253
|
+
main = check_chain(
|
|
254
|
+
"main",
|
|
255
|
+
cfg.get("fallback_providers"),
|
|
256
|
+
primary=main_primary,
|
|
257
|
+
require_model=True,
|
|
258
|
+
dedup=True,
|
|
259
|
+
live_models=live_models,
|
|
260
|
+
)
|
|
261
|
+
if main["rungs"] or main["issues"]:
|
|
262
|
+
out.append(main)
|
|
263
|
+
|
|
264
|
+
legacy = cfg.get("fallback_model")
|
|
265
|
+
if legacy is not None:
|
|
266
|
+
out.append(
|
|
267
|
+
check_chain(
|
|
268
|
+
"main (legacy fallback_model)",
|
|
269
|
+
legacy,
|
|
270
|
+
primary=main_primary,
|
|
271
|
+
require_model=True,
|
|
272
|
+
dedup=True,
|
|
273
|
+
live_models=live_models,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
aux_root = cfg.get("auxiliary")
|
|
278
|
+
if isinstance(aux_root, dict):
|
|
279
|
+
for task in sorted(aux_root):
|
|
280
|
+
task_cfg = aux_root[task]
|
|
281
|
+
if not isinstance(task_cfg, dict) or "fallback_chain" not in task_cfg:
|
|
282
|
+
continue
|
|
283
|
+
out.append(
|
|
284
|
+
check_chain(
|
|
285
|
+
f"aux:{task}",
|
|
286
|
+
task_cfg.get("fallback_chain"),
|
|
287
|
+
primary=(_norm(task_cfg.get("provider")), _norm(task_cfg.get("model"))),
|
|
288
|
+
require_model=False,
|
|
289
|
+
dedup=False,
|
|
290
|
+
live_models=live_models,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
out.extend(_dead_key_chains(cfg))
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def validate_file(
|
|
299
|
+
path: str | Path,
|
|
300
|
+
*,
|
|
301
|
+
live_models: set[str] | None = None,
|
|
302
|
+
) -> list[dict]:
|
|
303
|
+
"""``load_config`` + ``validate`` in one call."""
|
|
304
|
+
return validate(load_config(path), live_models=live_models)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def selfcheck() -> None:
|
|
308
|
+
"""Offline asserts over the lane rules. Run: ``python -m poolprobe.chains``."""
|
|
309
|
+
# main lane: incomplete rung dropped, duplicate deduped, priority is a lie
|
|
310
|
+
broken = {
|
|
311
|
+
"model": {"provider": "custom:cliproxy", "default": "qwen3.8-max"},
|
|
312
|
+
"fallback_providers": [
|
|
313
|
+
{"provider": "custom:cliproxy", "model": "glm-5.3-flash", "priority": 1},
|
|
314
|
+
{"provider": "custom:cliproxy", "model": "glm-5.3-flash", "priority": 2},
|
|
315
|
+
{"provider": "custom:cliproxy", "priority": 3},
|
|
316
|
+
"deepseek-v4-flash",
|
|
317
|
+
],
|
|
318
|
+
}
|
|
319
|
+
(main,) = validate(broken)
|
|
320
|
+
assert main["label"] == "main"
|
|
321
|
+
assert main["rungs"] == [
|
|
322
|
+
"custom:cliproxy/glm-5.3-flash",
|
|
323
|
+
"custom:cliproxy/glm-5.3-flash",
|
|
324
|
+
"custom:cliproxy",
|
|
325
|
+
"deepseek-v4-flash",
|
|
326
|
+
], main["rungs"]
|
|
327
|
+
joined = "\n".join(main["issues"])
|
|
328
|
+
assert "duplicates rung 1" in joined
|
|
329
|
+
assert "dedupes it away" in joined
|
|
330
|
+
assert "has no model" in joined
|
|
331
|
+
assert "bare str" in joined
|
|
332
|
+
# priority 1,2,3 sitting on rungs 1,2,3 misleads nobody: no issue by design
|
|
333
|
+
assert "priority" not in joined
|
|
334
|
+
|
|
335
|
+
# priority that disagrees with position is the trap worth naming
|
|
336
|
+
shuffled = {
|
|
337
|
+
"fallback_providers": [
|
|
338
|
+
{"provider": "p", "model": "a", "priority": 9},
|
|
339
|
+
{"provider": "p", "model": "b", "priority": 1},
|
|
340
|
+
]
|
|
341
|
+
}
|
|
342
|
+
(misleading,) = validate(shuffled)
|
|
343
|
+
assert "nothing sorts by priority" in "\n".join(misleading["issues"])
|
|
344
|
+
|
|
345
|
+
# a rung that is the primary is not a fallback
|
|
346
|
+
loop = {
|
|
347
|
+
"model": {"provider": "custom:cliproxy", "default": "gpt-5.4"},
|
|
348
|
+
"fallback_providers": [
|
|
349
|
+
{"provider": "custom:cliproxy", "model": "gpt-5.4"},
|
|
350
|
+
{"provider": "custom:cliproxy", "model": "glm-5.3-flash"},
|
|
351
|
+
],
|
|
352
|
+
}
|
|
353
|
+
(chain,) = validate(loop)
|
|
354
|
+
assert "is the primary itself" in "\n".join(chain["issues"])
|
|
355
|
+
|
|
356
|
+
# aux lane: model is optional, no dedup, non-list chain is ignored whole
|
|
357
|
+
aux = {
|
|
358
|
+
"auxiliary": {
|
|
359
|
+
"vision": {
|
|
360
|
+
"provider": "custom:cliproxy",
|
|
361
|
+
"model": "gemini-3.8-flash-high",
|
|
362
|
+
"fallback_chain": [
|
|
363
|
+
{"provider": "custom:cliproxy"},
|
|
364
|
+
{"provider": "custom:cliproxy"},
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
"compression": {"fallback_chain": "glm-5.3-flash"},
|
|
368
|
+
"curator": {"fallback_chain": []},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
by_label = {item["label"]: item for item in validate(aux)}
|
|
372
|
+
assert by_label["aux:vision"]["rungs"] == ["custom:cliproxy", "custom:cliproxy"]
|
|
373
|
+
assert "no model" not in "\n".join(by_label["aux:vision"]["issues"])
|
|
374
|
+
assert "burns a retry" in "\n".join(by_label["aux:vision"]["issues"])
|
|
375
|
+
assert "not a list" in "\n".join(by_label["aux:compression"]["issues"])
|
|
376
|
+
assert "empty list" in "\n".join(by_label["aux:curator"]["issues"])
|
|
377
|
+
|
|
378
|
+
# dead key + live-catalog cross-check
|
|
379
|
+
dead = {"model": {"fallback_chain": [{"provider": "x", "model": "y"}]},
|
|
380
|
+
"fallback_providers": [{"provider": "p", "model": "gone-model"}]}
|
|
381
|
+
labels = {item["label"] for item in validate(dead, live_models={"live-model"})}
|
|
382
|
+
assert "dead keys" in labels
|
|
383
|
+
dead_report = validate(dead, live_models={"live-model"})
|
|
384
|
+
assert "model.fallback_chain is set but nothing reads it" in "\n".join(
|
|
385
|
+
issue for item in dead_report for issue in item["issues"]
|
|
386
|
+
)
|
|
387
|
+
main_issues = "\n".join(
|
|
388
|
+
issue for item in dead_report if item["label"] == "main" for issue in item["issues"]
|
|
389
|
+
)
|
|
390
|
+
assert "not in the live gateway catalog" in main_issues
|
|
391
|
+
# same chain, catalog says the model exists -> that issue disappears
|
|
392
|
+
assert "not in the live gateway catalog" not in "\n".join(
|
|
393
|
+
issue
|
|
394
|
+
for item in validate(dead, live_models={"gone-model"})
|
|
395
|
+
if item["label"] == "main"
|
|
396
|
+
for issue in item["issues"]
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# a clean config produces no chains at all, not fake-green ones
|
|
400
|
+
assert validate({}) == []
|
|
401
|
+
clean = {
|
|
402
|
+
"model": {"provider": "custom:cliproxy", "default": "gpt-5.4"},
|
|
403
|
+
"fallback_providers": [
|
|
404
|
+
{"provider": "custom:cliproxy", "model": "glm-5.3-flash"},
|
|
405
|
+
{"provider": "custom:cliproxy", "model": "deepseek-v4-flash"},
|
|
406
|
+
],
|
|
407
|
+
}
|
|
408
|
+
(only,) = validate(clean)
|
|
409
|
+
assert only["issues"] == [], only["issues"]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
selfcheck()
|
|
414
|
+
print("chains selfcheck ok")
|