@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2
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.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/cliproxy.py
CHANGED
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
"""The CLI Proxy API hub adapter: a model added to one of its openai-compatibility providers.
|
|
2
|
-
|
|
3
|
-
python metrics/cliproxy.py <provider> <model id> # what would change, changes nothing
|
|
4
|
-
|
|
5
|
-
CLI Proxy API reaches OpenAI-compatible upstreams through the
|
|
6
|
-
`openai-compatibility` list in its config.yaml: a provider each, with a name,
|
|
7
|
-
a base-url, its upstream keys and a `models` list of {name, alias}. Only
|
|
8
|
-
listed models are served, as the alias (else the name), and also as
|
|
9
|
-
`<prefix>/<alias>` when the provider has a prefix. A provider is a
|
|
10
|
-
CONNECTION here, by its name. Adding a model is one PATCH of that provider's
|
|
11
|
-
`models` through the management API, which saves config.yaml and reloads
|
|
12
|
-
itself. The PATCH carries the models list and nothing else, so no upstream
|
|
13
|
-
key is ever sent back (the GET does return them; nothing here prints one).
|
|
14
|
-
|
|
15
|
-
The list is REPLACED WHOLE, so it is read, merged and written: read again
|
|
16
|
-
just before the PATCH and read back after. Anything in the list sent that is
|
|
17
|
-
missing afterwards means something else wrote it meanwhile, and this stops
|
|
18
|
-
and says so. ponytail: an edit landing between that last read and the PATCH
|
|
19
|
-
is still lost, the read-modify-write ceiling owui.py notes; CLI Proxy API has
|
|
20
|
-
no version field to make the write conditional.
|
|
21
|
-
|
|
22
|
-
The management key is not one of its API keys: it is the endpoint's admin key
|
|
23
|
-
(endpoints.admin_key, with no fallback to the API key). CLI Proxy API bans an
|
|
24
|
-
address for 30 minutes after 5 refused keys, so nothing here retries a
|
|
25
|
-
refusal, and without a stored key nothing is sent at all.
|
|
26
|
-
|
|
27
|
-
Checked against CLI Proxy API 8.0.36's source (2026-10-21):
|
|
28
|
-
internal/api/server_management.go (GET and PATCH
|
|
29
|
-
/v0/management/openai-compatibility), internal/api/handlers/management/
|
|
30
|
-
config_lists.go (PATCH {"name", "value": {"models": [...]}} replaces only the
|
|
31
|
-
fields given; the reply is {"status": "ok"}), handler.go (Authorization:
|
|
32
|
-
Bearer or X-Management-Key; the X-CPA-VERSION header; 401 for a wrong key,
|
|
33
|
-
403 "remote management disabled" from another machine unless
|
|
34
|
-
remote-management.allow-remote is true), server_management.go again (every
|
|
35
|
-
management route 404s while remote-management.secret-key is unset).
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
from __future__ import annotations
|
|
39
|
-
|
|
40
|
-
import json
|
|
41
|
-
import pathlib
|
|
42
|
-
import sys
|
|
43
|
-
import time
|
|
44
|
-
import urllib.error
|
|
45
|
-
import urllib.request
|
|
46
|
-
|
|
47
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
48
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
49
|
-
import endpoints # noqa: E402
|
|
50
|
-
|
|
51
|
-
# What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
|
|
52
|
-
SUPPORTS = {"allow_list": "models list", "tags": "", "logo": "", "access": "",
|
|
53
|
-
"link": "/management.html#/ai-providers"}
|
|
54
|
-
CHECKED = ("8.0",)
|
|
55
|
-
PATH = "/v0/management/openai-compatibility"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def endpoint() -> str:
|
|
59
|
-
"""The CLI Proxy API hub's endpoint name; ValueError when there is none."""
|
|
60
|
-
name = endpoints.find_hub("cliproxy")
|
|
61
|
-
if not name:
|
|
62
|
-
raise ValueError("no endpoint is a CLI Proxy API hub; add it on the Endpoints page as kind hub,"
|
|
63
|
-
" CLI Proxy API")
|
|
64
|
-
return name
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _call(method: str = "GET", body: dict | None = None) -> tuple[dict, str]:
|
|
68
|
-
"""(reply, X-CPA-VERSION) from the openai-compatibility route."""
|
|
69
|
-
name = endpoint()
|
|
70
|
-
secret = endpoints.admin_key(name, fallback=False)
|
|
71
|
-
if not secret:
|
|
72
|
-
env = endpoints.ENV_KEY + endpoints.admin_name(endpoints.load()[name]).upper().replace("-", "_")
|
|
73
|
-
raise ValueError(f"CLI Proxy API's management key is not stored: give it as the admin key on the"
|
|
74
|
-
f" Endpoints page (Change key on {name}), or set {env}")
|
|
75
|
-
req = urllib.request.Request(endpoints.base(name).removesuffix("/v1") + PATH, method=method,
|
|
76
|
-
data=None if body is None else json.dumps(body).encode(),
|
|
77
|
-
headers={"Authorization": f"Bearer {secret}", "Content-Type": "application/json",
|
|
78
|
-
"Accept": "application/json"})
|
|
79
|
-
try:
|
|
80
|
-
with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
|
|
81
|
-
return json.loads(r.read() or b"null") or {}, r.headers.get("X-CPA-VERSION", "")
|
|
82
|
-
except urllib.error.HTTPError as exc:
|
|
83
|
-
raise ValueError(_refusal(exc)) from None
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _refusal(exc: urllib.error.HTTPError) -> str:
|
|
87
|
-
"""What to change, from a refused management call."""
|
|
88
|
-
try:
|
|
89
|
-
said = str(json.loads(exc.read()).get("error") or "")[:200]
|
|
90
|
-
except (ValueError, AttributeError):
|
|
91
|
-
said = ""
|
|
92
|
-
if exc.code == 404 and said == "item not found": # the PATCH's: no provider of that name now
|
|
93
|
-
return "the provider left CLI Proxy API while this ran; nothing changed"
|
|
94
|
-
if exc.code == 404:
|
|
95
|
-
return ("CLI Proxy API's management API is off: set remote-management.secret-key in its config.yaml"
|
|
96
|
-
" (every management route answers 404 without one)")
|
|
97
|
-
if exc.code == 403 and "remote management disabled" in said:
|
|
98
|
-
return ("CLI Proxy API refuses management from another machine: set remote-management.allow-remote:"
|
|
99
|
-
" true in its config.yaml")
|
|
100
|
-
if exc.code == 401:
|
|
101
|
-
return (f"CLI Proxy API refused the management key ({said}); it bans an address for 30 minutes after"
|
|
102
|
-
" 5 refusals, so check the key before trying again")
|
|
103
|
-
return f"CLI Proxy API answered {exc.code}: {said}"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _providers() -> tuple[list[dict], str]:
|
|
107
|
-
data, release = _call()
|
|
108
|
-
return data.get("openai-compatibility") or [], release
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _named(entries: list[dict], connection: str) -> dict | None:
|
|
112
|
-
return next((e for e in entries if e.get("name") == connection), None)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _alias(m: dict) -> str:
|
|
116
|
-
"""What a listed model is served as: its alias, else its name."""
|
|
117
|
-
return str(m.get("alias") or m.get("name") or "").strip()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def upstreams() -> dict[str, tuple[str, str]]:
|
|
121
|
-
"""Provider -> (base URL, the prefix its served ids take, "" for none).
|
|
122
|
-
What Discover matches a provider against (discover.hub_routes)."""
|
|
123
|
-
return {e["name"]: (str(e.get("base-url") or "").rstrip("/"), str(e.get("prefix") or ""))
|
|
124
|
-
for e in _providers()[0] if e.get("name") and not e.get("disabled")}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def plan(connection: str, model_id: str) -> dict:
|
|
128
|
-
"""What adding would change. Writes nothing; ValueError when it cannot be done here."""
|
|
129
|
-
entries, release = _providers()
|
|
130
|
-
entry = _named(entries, connection)
|
|
131
|
-
if entry is None:
|
|
132
|
-
raise ValueError(f"CLI Proxy API has no openai-compatibility provider named {connection!r}")
|
|
133
|
-
if entry.get("disabled"):
|
|
134
|
-
raise ValueError(f"the {connection} provider is switched off in CLI Proxy API")
|
|
135
|
-
prefix = str(entry.get("prefix") or "")
|
|
136
|
-
# One id on two providers: CLI Proxy API would share its calls between them.
|
|
137
|
-
# A prefixed provider serves the bare alias too (unless force-model-prefix),
|
|
138
|
-
# so prefixes are not compared. ponytail: an OAuth model of the same id is not.
|
|
139
|
-
elsewhere = next((e.get("name") for e in entries if e is not entry and not e.get("disabled")
|
|
140
|
-
and any(_alias(m) == model_id for m in e.get("models") or [])), None)
|
|
141
|
-
if elsewhere:
|
|
142
|
-
raise ValueError(f"CLI Proxy API's {elsewhere} provider already serves {model_id}; listing it on"
|
|
143
|
-
f" {connection} too would share its calls between them")
|
|
144
|
-
models = entry.get("models") or []
|
|
145
|
-
return {"connection": connection, "base_url": entry.get("base-url"), "model_id": model_id,
|
|
146
|
-
"served": f"{prefix}/{model_id}" if prefix else model_id,
|
|
147
|
-
"add_to_list": not any(_alias(m) == model_id for m in models), "listed": len(models),
|
|
148
|
-
"version": release, "version_note": endpoints.version_note("cliproxy", release, CHECKED)}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def add(connection: str, model_id: str) -> tuple[str, list[str]]:
|
|
152
|
-
"""Do it. (served id, what was done)."""
|
|
153
|
-
p = plan(connection, model_id)
|
|
154
|
-
done = [p["version_note"]] if p["version_note"] else []
|
|
155
|
-
entry = _named(_providers()[0], connection) # again, just before the write: it decides, not plan()
|
|
156
|
-
if entry is None:
|
|
157
|
-
raise ValueError(f"the {connection} provider left CLI Proxy API while this ran; nothing changed")
|
|
158
|
-
sent = list(entry.get("models") or [])
|
|
159
|
-
if any(_alias(m) == model_id for m in sent):
|
|
160
|
-
done.append(f"the {connection} provider already lists {model_id}; nothing changed")
|
|
161
|
-
else:
|
|
162
|
-
sent.append({"name": model_id, "alias": model_id})
|
|
163
|
-
_call("PATCH", {"name": connection, "value": {"models": sent}})
|
|
164
|
-
back = (_named(_providers()[0], connection) or {}).get("models") or []
|
|
165
|
-
have = {(m.get("name"), m.get("alias")) for m in back}
|
|
166
|
-
lost = [_alias(m) for m in sent if (m.get("name"), m.get("alias")) not in have]
|
|
167
|
-
if lost:
|
|
168
|
-
raise ValueError(f"CLI Proxy API's {connection} list changed while this wrote it: {', '.join(lost)}"
|
|
169
|
-
" missing from it now, so something else edited it at the same moment; look at its"
|
|
170
|
-
f" providers page ({endpoints.base(endpoint()).removesuffix('/v1')}{SUPPORTS['link']})"
|
|
171
|
-
" before adding again")
|
|
172
|
-
done.append(f"added {model_id} to the {connection} provider's models ({len(sent)} listed);"
|
|
173
|
-
" CLI Proxy API reloads by itself")
|
|
174
|
-
for _ in range(10): # the reload runs after the reply
|
|
175
|
-
if p["served"] in endpoints.models(endpoint()):
|
|
176
|
-
break
|
|
177
|
-
time.sleep(2)
|
|
178
|
-
else:
|
|
179
|
-
done.append(f"CLI Proxy API does not list {p['served']} yet; onboarding will say if it never does")
|
|
180
|
-
return p["served"], done
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def main() -> None:
|
|
184
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
185
|
-
if len(sys.argv) != 3:
|
|
186
|
-
sys.exit(__doc__)
|
|
187
|
-
print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if __name__ == "__main__":
|
|
191
|
-
main()
|
|
1
|
+
"""The CLI Proxy API hub adapter: a model added to one of its openai-compatibility providers.
|
|
2
|
+
|
|
3
|
+
python metrics/cliproxy.py <provider> <model id> # what would change, changes nothing
|
|
4
|
+
|
|
5
|
+
CLI Proxy API reaches OpenAI-compatible upstreams through the
|
|
6
|
+
`openai-compatibility` list in its config.yaml: a provider each, with a name,
|
|
7
|
+
a base-url, its upstream keys and a `models` list of {name, alias}. Only
|
|
8
|
+
listed models are served, as the alias (else the name), and also as
|
|
9
|
+
`<prefix>/<alias>` when the provider has a prefix. A provider is a
|
|
10
|
+
CONNECTION here, by its name. Adding a model is one PATCH of that provider's
|
|
11
|
+
`models` through the management API, which saves config.yaml and reloads
|
|
12
|
+
itself. The PATCH carries the models list and nothing else, so no upstream
|
|
13
|
+
key is ever sent back (the GET does return them; nothing here prints one).
|
|
14
|
+
|
|
15
|
+
The list is REPLACED WHOLE, so it is read, merged and written: read again
|
|
16
|
+
just before the PATCH and read back after. Anything in the list sent that is
|
|
17
|
+
missing afterwards means something else wrote it meanwhile, and this stops
|
|
18
|
+
and says so. ponytail: an edit landing between that last read and the PATCH
|
|
19
|
+
is still lost, the read-modify-write ceiling owui.py notes; CLI Proxy API has
|
|
20
|
+
no version field to make the write conditional.
|
|
21
|
+
|
|
22
|
+
The management key is not one of its API keys: it is the endpoint's admin key
|
|
23
|
+
(endpoints.admin_key, with no fallback to the API key). CLI Proxy API bans an
|
|
24
|
+
address for 30 minutes after 5 refused keys, so nothing here retries a
|
|
25
|
+
refusal, and without a stored key nothing is sent at all.
|
|
26
|
+
|
|
27
|
+
Checked against CLI Proxy API 8.0.36's source (2026-10-21):
|
|
28
|
+
internal/api/server_management.go (GET and PATCH
|
|
29
|
+
/v0/management/openai-compatibility), internal/api/handlers/management/
|
|
30
|
+
config_lists.go (PATCH {"name", "value": {"models": [...]}} replaces only the
|
|
31
|
+
fields given; the reply is {"status": "ok"}), handler.go (Authorization:
|
|
32
|
+
Bearer or X-Management-Key; the X-CPA-VERSION header; 401 for a wrong key,
|
|
33
|
+
403 "remote management disabled" from another machine unless
|
|
34
|
+
remote-management.allow-remote is true), server_management.go again (every
|
|
35
|
+
management route 404s while remote-management.secret-key is unset).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import pathlib
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
import urllib.error
|
|
45
|
+
import urllib.request
|
|
46
|
+
|
|
47
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
48
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
49
|
+
import endpoints # noqa: E402
|
|
50
|
+
|
|
51
|
+
# What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
|
|
52
|
+
SUPPORTS = {"allow_list": "models list", "tags": "", "logo": "", "access": "",
|
|
53
|
+
"link": "/management.html#/ai-providers"}
|
|
54
|
+
CHECKED = ("8.0",)
|
|
55
|
+
PATH = "/v0/management/openai-compatibility"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def endpoint() -> str:
|
|
59
|
+
"""The CLI Proxy API hub's endpoint name; ValueError when there is none."""
|
|
60
|
+
name = endpoints.find_hub("cliproxy")
|
|
61
|
+
if not name:
|
|
62
|
+
raise ValueError("no endpoint is a CLI Proxy API hub; add it on the Endpoints page as kind hub,"
|
|
63
|
+
" CLI Proxy API")
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _call(method: str = "GET", body: dict | None = None) -> tuple[dict, str]:
|
|
68
|
+
"""(reply, X-CPA-VERSION) from the openai-compatibility route."""
|
|
69
|
+
name = endpoint()
|
|
70
|
+
secret = endpoints.admin_key(name, fallback=False)
|
|
71
|
+
if not secret:
|
|
72
|
+
env = endpoints.ENV_KEY + endpoints.admin_name(endpoints.load()[name]).upper().replace("-", "_")
|
|
73
|
+
raise ValueError(f"CLI Proxy API's management key is not stored: give it as the admin key on the"
|
|
74
|
+
f" Endpoints page (Change key on {name}), or set {env}")
|
|
75
|
+
req = urllib.request.Request(endpoints.base(name).removesuffix("/v1") + PATH, method=method,
|
|
76
|
+
data=None if body is None else json.dumps(body).encode(),
|
|
77
|
+
headers={"Authorization": f"Bearer {secret}", "Content-Type": "application/json",
|
|
78
|
+
"Accept": "application/json"})
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
|
|
81
|
+
return json.loads(r.read() or b"null") or {}, r.headers.get("X-CPA-VERSION", "")
|
|
82
|
+
except urllib.error.HTTPError as exc:
|
|
83
|
+
raise ValueError(_refusal(exc)) from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _refusal(exc: urllib.error.HTTPError) -> str:
|
|
87
|
+
"""What to change, from a refused management call."""
|
|
88
|
+
try:
|
|
89
|
+
said = str(json.loads(exc.read()).get("error") or "")[:200]
|
|
90
|
+
except (ValueError, AttributeError):
|
|
91
|
+
said = ""
|
|
92
|
+
if exc.code == 404 and said == "item not found": # the PATCH's: no provider of that name now
|
|
93
|
+
return "the provider left CLI Proxy API while this ran; nothing changed"
|
|
94
|
+
if exc.code == 404:
|
|
95
|
+
return ("CLI Proxy API's management API is off: set remote-management.secret-key in its config.yaml"
|
|
96
|
+
" (every management route answers 404 without one)")
|
|
97
|
+
if exc.code == 403 and "remote management disabled" in said:
|
|
98
|
+
return ("CLI Proxy API refuses management from another machine: set remote-management.allow-remote:"
|
|
99
|
+
" true in its config.yaml")
|
|
100
|
+
if exc.code == 401:
|
|
101
|
+
return (f"CLI Proxy API refused the management key ({said}); it bans an address for 30 minutes after"
|
|
102
|
+
" 5 refusals, so check the key before trying again")
|
|
103
|
+
return f"CLI Proxy API answered {exc.code}: {said}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _providers() -> tuple[list[dict], str]:
|
|
107
|
+
data, release = _call()
|
|
108
|
+
return data.get("openai-compatibility") or [], release
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _named(entries: list[dict], connection: str) -> dict | None:
|
|
112
|
+
return next((e for e in entries if e.get("name") == connection), None)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _alias(m: dict) -> str:
|
|
116
|
+
"""What a listed model is served as: its alias, else its name."""
|
|
117
|
+
return str(m.get("alias") or m.get("name") or "").strip()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def upstreams() -> dict[str, tuple[str, str]]:
|
|
121
|
+
"""Provider -> (base URL, the prefix its served ids take, "" for none).
|
|
122
|
+
What Discover matches a provider against (discover.hub_routes)."""
|
|
123
|
+
return {e["name"]: (str(e.get("base-url") or "").rstrip("/"), str(e.get("prefix") or ""))
|
|
124
|
+
for e in _providers()[0] if e.get("name") and not e.get("disabled")}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def plan(connection: str, model_id: str) -> dict:
|
|
128
|
+
"""What adding would change. Writes nothing; ValueError when it cannot be done here."""
|
|
129
|
+
entries, release = _providers()
|
|
130
|
+
entry = _named(entries, connection)
|
|
131
|
+
if entry is None:
|
|
132
|
+
raise ValueError(f"CLI Proxy API has no openai-compatibility provider named {connection!r}")
|
|
133
|
+
if entry.get("disabled"):
|
|
134
|
+
raise ValueError(f"the {connection} provider is switched off in CLI Proxy API")
|
|
135
|
+
prefix = str(entry.get("prefix") or "")
|
|
136
|
+
# One id on two providers: CLI Proxy API would share its calls between them.
|
|
137
|
+
# A prefixed provider serves the bare alias too (unless force-model-prefix),
|
|
138
|
+
# so prefixes are not compared. ponytail: an OAuth model of the same id is not.
|
|
139
|
+
elsewhere = next((e.get("name") for e in entries if e is not entry and not e.get("disabled")
|
|
140
|
+
and any(_alias(m) == model_id for m in e.get("models") or [])), None)
|
|
141
|
+
if elsewhere:
|
|
142
|
+
raise ValueError(f"CLI Proxy API's {elsewhere} provider already serves {model_id}; listing it on"
|
|
143
|
+
f" {connection} too would share its calls between them")
|
|
144
|
+
models = entry.get("models") or []
|
|
145
|
+
return {"connection": connection, "base_url": entry.get("base-url"), "model_id": model_id,
|
|
146
|
+
"served": f"{prefix}/{model_id}" if prefix else model_id,
|
|
147
|
+
"add_to_list": not any(_alias(m) == model_id for m in models), "listed": len(models),
|
|
148
|
+
"version": release, "version_note": endpoints.version_note("cliproxy", release, CHECKED)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def add(connection: str, model_id: str) -> tuple[str, list[str]]:
|
|
152
|
+
"""Do it. (served id, what was done)."""
|
|
153
|
+
p = plan(connection, model_id)
|
|
154
|
+
done = [p["version_note"]] if p["version_note"] else []
|
|
155
|
+
entry = _named(_providers()[0], connection) # again, just before the write: it decides, not plan()
|
|
156
|
+
if entry is None:
|
|
157
|
+
raise ValueError(f"the {connection} provider left CLI Proxy API while this ran; nothing changed")
|
|
158
|
+
sent = list(entry.get("models") or [])
|
|
159
|
+
if any(_alias(m) == model_id for m in sent):
|
|
160
|
+
done.append(f"the {connection} provider already lists {model_id}; nothing changed")
|
|
161
|
+
else:
|
|
162
|
+
sent.append({"name": model_id, "alias": model_id})
|
|
163
|
+
_call("PATCH", {"name": connection, "value": {"models": sent}})
|
|
164
|
+
back = (_named(_providers()[0], connection) or {}).get("models") or []
|
|
165
|
+
have = {(m.get("name"), m.get("alias")) for m in back}
|
|
166
|
+
lost = [_alias(m) for m in sent if (m.get("name"), m.get("alias")) not in have]
|
|
167
|
+
if lost:
|
|
168
|
+
raise ValueError(f"CLI Proxy API's {connection} list changed while this wrote it: {', '.join(lost)}"
|
|
169
|
+
" missing from it now, so something else edited it at the same moment; look at its"
|
|
170
|
+
f" providers page ({endpoints.base(endpoint()).removesuffix('/v1')}{SUPPORTS['link']})"
|
|
171
|
+
" before adding again")
|
|
172
|
+
done.append(f"added {model_id} to the {connection} provider's models ({len(sent)} listed);"
|
|
173
|
+
" CLI Proxy API reloads by itself")
|
|
174
|
+
for _ in range(10): # the reload runs after the reply
|
|
175
|
+
if p["served"] in endpoints.models(endpoint()):
|
|
176
|
+
break
|
|
177
|
+
time.sleep(2)
|
|
178
|
+
else:
|
|
179
|
+
done.append(f"CLI Proxy API does not list {p['served']} yet; onboarding will say if it never does")
|
|
180
|
+
return p["served"], done
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main() -> None:
|
|
184
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
185
|
+
if len(sys.argv) != 3:
|
|
186
|
+
sys.exit(__doc__)
|
|
187
|
+
print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|