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