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/__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
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
|