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 ADDED
@@ -0,0 +1,137 @@
1
+ """poolprobe — a doctor for OpenAI-compatible LLM gateways.
2
+
3
+ A pool of routed models rots quietly. An upstream drops a model, a key hits an
4
+ account tier, a reasoning model starts eating its whole token budget on
5
+ thinking, a gateway route begins answering SSE to a non-streaming request. None
6
+ of that raises an exception anywhere: the agent just gets an empty reply, or a
7
+ slower one, or falls through to a worse model and nobody notices for a week.
8
+
9
+ poolprobe probes the pool and refuses to guess. Three rules drive everything
10
+ here:
11
+
12
+ * **A verdict is not a death sentence.** Every failure is classified into a
13
+ kind — ``ALIVE``, ``ALIVE-STARVED``, ``PERMANENT``, ``TRANSIENT``,
14
+ ``CREDENTIAL``, ``MODALITY``, ``UNKNOWN`` — and only ``PERMANENT`` (the
15
+ upstream does not know this model) is ever a prune candidate.
16
+ * **One observation is not a verdict.** A measured pool produced 544 scheduled
17
+ liveness runs in six weeks; 39% went red and every single red was transient.
18
+ Burst-confirm before believing anything.
19
+ * **A number without evidence is not reported.** Throughput comes from the
20
+ gateway's own ``usage`` block or it is marked as a lower bound or it is
21
+ ``0.0``. Never estimated from chunk counts and presented as a measurement.
22
+
23
+ Library use::
24
+
25
+ from poolprobe import Gateway, sweep, taxonomy
26
+
27
+ gw = Gateway("http://127.0.0.1:8317/v1", api_key="local")
28
+ for result in sweep(gw, ["gpt-5.4", "glm-5.3-flash"], attempts=5):
29
+ print(result.model, result.verdict, result.kind, result.advice)
30
+
31
+ CLI use::
32
+
33
+ poolprobe sweep --base-url http://127.0.0.1:8317/v1 --attempts 5 --html out.html
34
+ poolprobe prune --base-url ... --adapter cliproxy --config ~/.config/gateway.yaml
35
+ poolprobe selfcheck
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ __version__ = "1.0.0"
41
+
42
+ # Submodules first: probe, bench and adapter all resolve ``taxonomy`` through
43
+ # the package attribute at import time, and a partially initialised package must
44
+ # not leave that lookup to CPython's submodule fallback.
45
+ from . import chains, taxonomy
46
+ from .adapter import (
47
+ Adapter,
48
+ Attribution,
49
+ CliproxyAdapter,
50
+ ConfigError,
51
+ EditPlan,
52
+ LiveAdapter,
53
+ build_adapter,
54
+ )
55
+ from .bench import (
56
+ BenchResult,
57
+ bench_many,
58
+ bench_one,
59
+ summarize,
60
+ )
61
+ from .chains import (
62
+ load_config,
63
+ validate as validate_chains,
64
+ validate_file as validate_chains_file,
65
+ )
66
+ from .probe import (
67
+ DEFAULT_MAX_TOKENS,
68
+ DEFAULT_PROMPT,
69
+ DEFAULT_TIMEOUT,
70
+ Gateway,
71
+ GatewayError,
72
+ ProbeResult,
73
+ burst,
74
+ list_models,
75
+ parallel_map,
76
+ ping,
77
+ probe,
78
+ sweep,
79
+ )
80
+ from .report import (
81
+ Report,
82
+ RunMeta,
83
+ render_html,
84
+ render_json,
85
+ render_terminal,
86
+ )
87
+ from .safewrite import write_atomic
88
+ from .state import ModelState, RunStats, State
89
+
90
+ __all__ = [
91
+ "__version__",
92
+ # taxonomy
93
+ "taxonomy",
94
+ # transport
95
+ "Gateway",
96
+ "GatewayError",
97
+ "ProbeResult",
98
+ "probe",
99
+ "burst",
100
+ "sweep",
101
+ "list_models",
102
+ "ping",
103
+ "parallel_map",
104
+ "DEFAULT_PROMPT",
105
+ "DEFAULT_MAX_TOKENS",
106
+ "DEFAULT_TIMEOUT",
107
+ # memory
108
+ "State",
109
+ "ModelState",
110
+ "RunStats",
111
+ # bench
112
+ "BenchResult",
113
+ "bench_one",
114
+ "bench_many",
115
+ "summarize",
116
+ # adapters
117
+ "Adapter",
118
+ "LiveAdapter",
119
+ "CliproxyAdapter",
120
+ "Attribution",
121
+ "EditPlan",
122
+ "ConfigError",
123
+ "build_adapter",
124
+ # chains
125
+ "chains",
126
+ "validate_chains",
127
+ "validate_chains_file",
128
+ "load_config",
129
+ # io
130
+ "write_atomic",
131
+ # reporting
132
+ "Report",
133
+ "RunMeta",
134
+ "render_terminal",
135
+ "render_json",
136
+ "render_html",
137
+ ]
poolprobe/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m poolprobe`` — the same entry point the console script uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
poolprobe/adapter.py ADDED
@@ -0,0 +1,525 @@
1
+ """Where the model list comes from, and what a model is allowed to cost you.
2
+
3
+ Two adapters, one interface:
4
+
5
+ * :class:`LiveAdapter` — any OpenAI-compatible gateway. Models come from
6
+ ``GET /models``. Nothing is known about upstreams and nothing can be edited,
7
+ because there is no config file in reach. This is the default and it works
8
+ against OpenRouter, one-api, LiteLLM, vLLM, cliproxy or a bare vendor endpoint.
9
+ * :class:`CliproxyAdapter` — adds attribution (which upstream provider serves
10
+ this alias, is it disabled, what is the real model name) by reading the
11
+ gateway's own YAML config, and can plan a removal.
12
+
13
+ The split between *reading* and *removing* is deliberate:
14
+
15
+ * Attribution is parsed **structurally** with PyYAML. Line-based regex parsing
16
+ of a config whose key order is not fixed is how a gate silently attributes
17
+ model A to provider B.
18
+ * Removal is **text surgery** on the original bytes. A YAML round-trip would
19
+ destroy every comment in the operator's config, and comments in a routed-model
20
+ config are where the "why is this here" lives.
21
+
22
+ So: parse to understand, cut to change. Never the other way round.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import difflib
28
+ from collections.abc import Iterator, Sequence
29
+ from dataclasses import dataclass, field, replace
30
+ from pathlib import Path
31
+
32
+ from .probe import Gateway, GatewayError, list_models
33
+
34
+ #: Top-level YAML keys whose list items are *provider blocks* holding models.
35
+ #: Kept as a tuple so a different gateway layout is a one-line extension.
36
+ PROVIDER_SECTIONS: tuple[str, ...] = ("openai-compatibility", "claude-api-key")
37
+
38
+ #: A list item is a model entry only when its nearest lower-indented ancestor
39
+ #: line is one of these keys. Provider blocks also carry ``name:`` and would
40
+ #: otherwise be mistaken for models — which would delete a whole provider.
41
+ MODEL_LIST_KEYS: frozenset[str] = frozenset({"models"})
42
+
43
+
44
+ class ConfigError(RuntimeError):
45
+ """The gateway config could not be read or understood."""
46
+
47
+
48
+ def _load_yaml(path: Path) -> object:
49
+ try:
50
+ import yaml # PyYAML: hard dependency, imported lazily so the generic
51
+ except ImportError as exc: # lane keeps working without it
52
+ raise ConfigError(
53
+ "PyYAML is required to read a gateway config: pip install 'PyYAML>=6.0'"
54
+ ) from exc
55
+ try:
56
+ with path.open("r", encoding="utf-8") as handle:
57
+ return yaml.safe_load(handle)
58
+ except FileNotFoundError as exc:
59
+ raise ConfigError(f"config not found: {path}") from exc
60
+ except Exception as exc: # includes yaml.YAMLError
61
+ raise ConfigError(f"cannot parse {path}: {type(exc).__name__}: {exc}") from exc
62
+
63
+
64
+ def read_text(path: Path) -> str:
65
+ """Read without newline translation — removal must not rewrite the file."""
66
+ with path.open("r", encoding="utf-8", newline="") as handle:
67
+ return handle.read()
68
+
69
+
70
+ def effective_alias(name: str, alias: str = "") -> str:
71
+ """The id a client actually sends: ``alias`` when set, else ``name``."""
72
+ return (alias or name or "").strip()
73
+
74
+
75
+ # --- attribution ------------------------------------------------------------
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Attribution:
80
+ """What is known about one published model id."""
81
+
82
+ provider: str = ""
83
+ upstream: str = ""
84
+ base_url: str = ""
85
+ disabled: bool = False
86
+ source: str = "live" # live | config | both
87
+
88
+ @property
89
+ def known(self) -> bool:
90
+ return bool(self.provider or self.upstream)
91
+
92
+ def as_dict(self) -> dict[str, object]:
93
+ return {
94
+ "provider": self.provider,
95
+ "upstream": self.upstream,
96
+ "base_url": self.base_url,
97
+ "disabled": self.disabled,
98
+ "source": self.source,
99
+ }
100
+
101
+
102
+ @dataclass
103
+ class EditPlan:
104
+ """A proposed config change. Nothing here writes to disk."""
105
+
106
+ path: str
107
+ before: str
108
+ after: str
109
+ removed: list[str] = field(default_factory=list)
110
+ not_found: list[str] = field(default_factory=list)
111
+ warnings: list[str] = field(default_factory=list)
112
+
113
+ @property
114
+ def changed(self) -> bool:
115
+ return self.before != self.after
116
+
117
+ def diff(self, context: int = 3) -> str:
118
+ name = Path(self.path).name
119
+ return "".join(
120
+ difflib.unified_diff(
121
+ self.before.splitlines(keepends=True),
122
+ self.after.splitlines(keepends=True),
123
+ fromfile=f"a/{name}",
124
+ tofile=f"b/{name}",
125
+ n=context,
126
+ )
127
+ )
128
+
129
+
130
+ class Adapter:
131
+ """Interface both adapters implement."""
132
+
133
+ name = "adapter"
134
+ removable = False
135
+
136
+ def models(self) -> list[str]:
137
+ raise NotImplementedError
138
+
139
+ def attribution(self, model: str) -> Attribution:
140
+ return Attribution()
141
+
142
+ def attribution_map(self) -> dict[str, Attribution]:
143
+ return {}
144
+
145
+ def describe(self) -> str:
146
+ return self.name
147
+
148
+ def plan_removal(self, aliases: Sequence[str]) -> EditPlan:
149
+ raise ConfigError(f"adapter {self.name!r} cannot edit a config")
150
+
151
+
152
+ class LiveAdapter(Adapter):
153
+ """Any OpenAI-compatible gateway, catalog taken from ``GET /models``."""
154
+
155
+ name = "generic"
156
+
157
+ def __init__(self, gateway: Gateway, catalog: Sequence[str] | None = None):
158
+ self.gateway = gateway
159
+ self._catalog: list[str] | None = list(catalog) if catalog is not None else None
160
+
161
+ def models(self) -> list[str]:
162
+ if self._catalog is None:
163
+ self._catalog = list_models(self.gateway)
164
+ return list(self._catalog)
165
+
166
+ def describe(self) -> str:
167
+ # The gateway root is already its own field in the report header, so
168
+ # repeating it here read "URL · URL (? models, read-only)".
169
+ return "generic adapter (read-only)"
170
+
171
+
172
+ class CliproxyAdapter(Adapter):
173
+ """Live catalog + provider attribution from the gateway's YAML config.
174
+
175
+ Models are the **union** of what the gateway publishes and what the config
176
+ declares. The union matters in both directions: an alias present in config
177
+ but missing from ``/models`` is exactly the config rot a sweep should show,
178
+ and an alias published but absent from config means someone edited the
179
+ running process or another section owns it — either way, not removable here.
180
+ """
181
+
182
+ name = "cliproxy"
183
+ removable = True
184
+
185
+ def __init__(self, gateway: Gateway, config_path: str | Path):
186
+ self.gateway = gateway
187
+ self.config_path = Path(config_path).expanduser()
188
+ self._attr: dict[str, Attribution] | None = None
189
+ self._live: list[str] | None = None
190
+ self._providers = 0
191
+
192
+ # -- reading ---------------------------------------------------------
193
+
194
+ def _parse(self) -> dict[str, Attribution]:
195
+ doc = _load_yaml(self.config_path)
196
+ if not isinstance(doc, dict):
197
+ raise ConfigError(f"{self.config_path}: expected a mapping at top level")
198
+
199
+ attr: dict[str, Attribution] = {}
200
+ providers = 0
201
+ for section in PROVIDER_SECTIONS:
202
+ blocks = doc.get(section)
203
+ if not isinstance(blocks, list):
204
+ continue
205
+ for block in blocks:
206
+ if not isinstance(block, dict):
207
+ continue
208
+ providers += 1
209
+ provider = str(block.get("name") or section)
210
+ base_url = str(block.get("base-url") or block.get("base_url") or "")
211
+ disabled = bool(block.get("disabled"))
212
+ entries = block.get("models")
213
+ if not isinstance(entries, list):
214
+ continue
215
+ for entry in entries:
216
+ if isinstance(entry, str):
217
+ name, alias = entry, ""
218
+ elif isinstance(entry, dict):
219
+ name = str(entry.get("name") or "")
220
+ alias = str(entry.get("alias") or "")
221
+ else:
222
+ continue
223
+ key = effective_alias(name, alias)
224
+ if not key:
225
+ continue
226
+ # First declaration wins: a duplicate alias later in the
227
+ # file is a config bug, and silently overwriting attribution
228
+ # would hide it from the report.
229
+ attr.setdefault(
230
+ key,
231
+ Attribution(
232
+ provider=provider,
233
+ upstream=name.strip(),
234
+ base_url=base_url,
235
+ disabled=disabled,
236
+ source="config",
237
+ ),
238
+ )
239
+ self._providers = providers
240
+ return attr
241
+
242
+ def attribution_map(self) -> dict[str, Attribution]:
243
+ if self._attr is None:
244
+ self._attr = self._parse()
245
+ return dict(self._attr)
246
+
247
+ def attribution(self, model: str) -> Attribution:
248
+ return self.attribution_map().get(model, Attribution())
249
+
250
+ def live_models(self) -> list[str]:
251
+ if self._live is None:
252
+ self._live = list_models(self.gateway)
253
+ return list(self._live)
254
+
255
+ def models(self) -> list[str]:
256
+ known = set(self.attribution_map())
257
+ try:
258
+ published = self.live_models()
259
+ except GatewayError:
260
+ # The gateway being down is a finding, not a reason to report an
261
+ # empty pool: fall back to the config so the sweep still has work.
262
+ published = []
263
+ self._live = []
264
+ if self._attr is not None:
265
+ for key in published:
266
+ seen = self._attr.get(key)
267
+ self._attr[key] = (
268
+ replace(seen, source="both")
269
+ if seen is not None
270
+ else Attribution(source="live")
271
+ )
272
+ return sorted(known | set(published))
273
+
274
+ def describe(self) -> str:
275
+ total = len(self.attribution_map())
276
+ head = f"{self.config_path} ({self._providers} provider blocks, {total} declared"
277
+ # _live stays None until the catalog is fetched *through the adapter*;
278
+ # ping reads it straight from the gateway, so don't claim a "0 published"
279
+ # we never measured. An empty list (gateway down) is a real 0 and shows.
280
+ if self._live is not None:
281
+ head += f", {len(self._live)} published"
282
+ return head + ")"
283
+
284
+ # -- removal ---------------------------------------------------------
285
+
286
+ def plan_removal(self, aliases: Sequence[str]) -> EditPlan:
287
+ """Cut model entries out of the config text, preserving everything else.
288
+
289
+ Removal is driven by the text scan, which locates any ``- name:/alias:``
290
+ item under a ``models:`` key in the file. Attribution is narrower — it
291
+ only reads :data:`PROVIDER_SECTIONS` — so an alias from a section this
292
+ adapter does not know can still be cut, and the diff shown to the
293
+ operator is the only authority. Aliases that appear nowhere in the text
294
+ (published by a running process, or already removed) come back in
295
+ ``not_found`` and are left alone.
296
+ """
297
+ wanted = [a for a in dict.fromkeys(aliases) if a]
298
+ before = read_text(self.config_path)
299
+ entries = {entry.alias: entry for entry in iter_model_entries(before)}
300
+
301
+ removed: list[str] = []
302
+ not_found: list[str] = []
303
+ cut_spans: list[tuple[int, int]] = []
304
+ for alias in wanted:
305
+ entry = entries.get(alias)
306
+ if entry is None:
307
+ not_found.append(alias)
308
+ continue
309
+ removed.append(alias)
310
+ cut_spans.append((entry.start, entry.end))
311
+
312
+ lines = before.splitlines(keepends=True)
313
+ for start, end in sorted(cut_spans, reverse=True):
314
+ del lines[start:end]
315
+ after = "".join(lines)
316
+
317
+ return EditPlan(
318
+ path=str(self.config_path),
319
+ before=before,
320
+ after=after,
321
+ removed=removed,
322
+ not_found=not_found,
323
+ warnings=_emptied_providers(before, after),
324
+ )
325
+
326
+
327
+ # --- text surgery -----------------------------------------------------------
328
+
329
+
330
+ @dataclass(frozen=True)
331
+ class TextEntry:
332
+ """One ``- name:/alias:`` list item, located by line span."""
333
+
334
+ alias: str
335
+ upstream: str
336
+ start: int # inclusive line index of the dash line
337
+ end: int # exclusive
338
+ indent: int # column of the first character after "- "
339
+
340
+
341
+ def _indent(line: str) -> int:
342
+ return len(line) - len(line.lstrip(" "))
343
+
344
+
345
+ def _is_item(line: str) -> bool:
346
+ stripped = line.lstrip(" ")
347
+ return stripped.startswith("- ") or stripped.rstrip() == "-"
348
+
349
+
350
+ def _scalar(line: str, content_indent: int) -> tuple[str, str] | None:
351
+ """``key: value`` whose value starts at ``content_indent``, else None.
352
+
353
+ The dash line is part of the item: in ``- name: foo`` the key sits two
354
+ columns right of the indentation, at exactly ``content_indent``. Treating it
355
+ as "not at this indent" would lose the upstream name of every entry.
356
+ """
357
+ indent = _indent(line)
358
+ stripped = line.lstrip(" ")
359
+ if stripped.startswith("- "):
360
+ if indent + 2 != content_indent:
361
+ return None
362
+ stripped = stripped[2:]
363
+ elif indent != content_indent:
364
+ return None
365
+
366
+ stripped = stripped.strip()
367
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
368
+ return None
369
+ key, _, value = stripped.partition(":")
370
+ if not key.strip():
371
+ return None
372
+ return key.strip(), value.split(" #", 1)[0].strip().strip("'\"")
373
+
374
+
375
+ def _ancestor_key(lines: list[str], index: int, content_indent: int) -> str:
376
+ """Key of the mapping line that owns the list item at ``index``.
377
+
378
+ Two kinds of line must be stepped over: sibling items (a dash at
379
+ ``content_indent - 2``, or at the parent indent in the zero-indent list
380
+ style) and keys belonging to a previous sibling's body (anything at
381
+ ``content_indent`` or deeper). Stopping at a sibling instead would read
382
+ ``name`` as the ancestor and drop every entry after the first.
383
+ """
384
+ dash_indent = content_indent - 2
385
+ for back in range(index - 1, -1, -1):
386
+ line = lines[back]
387
+ stripped = line.strip()
388
+ if not stripped or stripped.startswith("#"):
389
+ continue
390
+ indent = _indent(line)
391
+ if stripped.startswith("- ") or stripped.rstrip() == "-":
392
+ if indent >= dash_indent:
393
+ continue
394
+ elif indent >= content_indent:
395
+ continue
396
+ return stripped.lstrip("- ").partition(":")[0].strip()
397
+ return ""
398
+
399
+
400
+ def iter_model_entries(text: str) -> Iterator[TextEntry]:
401
+ """Yield every model list item in a YAML config, with its line span.
402
+
403
+ An item counts as a model entry when its nearest lower-indented ancestor is
404
+ a ``models:`` key. That single rule keeps provider blocks (which also have
405
+ ``name:`` and sit in a list) out of the result, so a prune can never delete
406
+ a provider because its name collided with a model alias.
407
+
408
+ The span extends over every following line indented at least as deep as the
409
+ item content, so a multi-key entry (``name`` + ``alias`` + anything else)
410
+ and its nested lists are removed together. Blank lines and trailing comments
411
+ at lower indent end the span — they belong to the file, not the entry.
412
+ """
413
+ lines = text.splitlines(keepends=True)
414
+ for index, line in enumerate(lines):
415
+ if not _is_item(line):
416
+ continue
417
+ content_indent = _indent(line) + 2
418
+ if _ancestor_key(lines, index, content_indent) not in MODEL_LIST_KEYS:
419
+ continue
420
+
421
+ end = index + 1
422
+ while end < len(lines):
423
+ candidate = lines[end]
424
+ if not candidate.strip():
425
+ break
426
+ if candidate.lstrip().startswith("#") and _indent(candidate) < content_indent:
427
+ break
428
+ if _indent(candidate) < content_indent:
429
+ break
430
+ end += 1
431
+
432
+ name = alias = ""
433
+ for offset in range(index, end):
434
+ pair = _scalar(lines[offset], content_indent)
435
+ if pair is None:
436
+ continue
437
+ key, value = pair
438
+ if key == "name":
439
+ name = value
440
+ elif key == "alias":
441
+ alias = value
442
+
443
+ key = effective_alias(name, alias)
444
+ if key:
445
+ yield TextEntry(
446
+ alias=key, upstream=name, start=index, end=end, indent=content_indent
447
+ )
448
+
449
+
450
+ def _emptied_providers(before: str, after: str) -> list[str]:
451
+ """Warn when a cut left a provider with no models at all.
452
+
453
+ ``models:`` with nothing under it parses as null. Most gateways tolerate it,
454
+ but the operator should see that a provider block is now dead weight rather
455
+ than discover it when a route stops resolving.
456
+ """
457
+ warnings: list[str] = []
458
+ before_counts = _models_per_provider(before)
459
+ after_counts = _models_per_provider(after)
460
+ for provider, count in before_counts.items():
461
+ if count and not after_counts.get(provider):
462
+ warnings.append(f"provider {provider!r} has no models left")
463
+ return warnings
464
+
465
+
466
+ def _models_per_provider(text: str) -> dict[str, int]:
467
+ """Provider name -> declared model count, from a structural parse."""
468
+ try:
469
+ import yaml
470
+
471
+ doc = yaml.safe_load(text)
472
+ except Exception:
473
+ return {}
474
+ counts: dict[str, int] = {}
475
+ if not isinstance(doc, dict):
476
+ return counts
477
+ for section in PROVIDER_SECTIONS:
478
+ blocks = doc.get(section)
479
+ if not isinstance(blocks, list):
480
+ continue
481
+ for block in blocks:
482
+ if not isinstance(block, dict):
483
+ continue
484
+ provider = str(block.get("name") or section)
485
+ entries = block.get("models")
486
+ counts[provider] = counts.get(provider, 0) + (
487
+ len(entries) if isinstance(entries, list) else 0
488
+ )
489
+ return counts
490
+
491
+
492
+ # --- factory ----------------------------------------------------------------
493
+
494
+
495
+ def build_adapter(
496
+ kind: str,
497
+ gateway: Gateway,
498
+ config_path: str | Path | None = None,
499
+ ) -> Adapter:
500
+ """``auto`` picks the config adapter when a usable config is in reach."""
501
+ kind = (kind or "auto").lower()
502
+ if kind in ("generic", "live"):
503
+ return LiveAdapter(gateway)
504
+ if kind not in ("auto", "cliproxy"):
505
+ raise ConfigError(f"unknown adapter {kind!r} (auto|cliproxy|generic)")
506
+
507
+ if config_path is None:
508
+ if kind == "cliproxy":
509
+ raise ConfigError("--adapter cliproxy needs --config PATH")
510
+ return LiveAdapter(gateway)
511
+
512
+ path = Path(config_path).expanduser()
513
+ if not path.is_file():
514
+ if kind == "cliproxy":
515
+ raise ConfigError(f"config not found: {path}")
516
+ return LiveAdapter(gateway)
517
+
518
+ adapter = CliproxyAdapter(gateway, path)
519
+ try: # an unreadable config downgrades to read-only, it does not abort
520
+ adapter.attribution_map()
521
+ except ConfigError:
522
+ if kind == "cliproxy":
523
+ raise
524
+ return LiveAdapter(gateway)
525
+ return adapter