@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.1
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 +147 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +272 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +368 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +170 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +666 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +755 -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 +295 -295
- package/metrics/client.py +394 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +152 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/guard.py +617 -592
- package/metrics/learn.py +221 -221
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +84 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/paths.py +60 -60
- package/metrics/route.py +579 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +68 -65
- package/perf/README.md +89 -89
- package/perf/run.py +261 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +108 -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 +401 -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 +233 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- 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 +214 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_serve_edit.py +448 -438
- package/scripts/test_verdict.py +98 -98
- package/scripts/test_vision.py +228 -228
- package/serve.py +3904 -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 +1756 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/litellm_hub.py
CHANGED
|
@@ -1,206 +1,206 @@
|
|
|
1
|
-
"""The LiteLLM hub adapter: a model added as a new deployment on a connection LiteLLM already has.
|
|
2
|
-
|
|
3
|
-
python metrics/litellm_hub.py <connection> <model id> # what would change, changes nothing
|
|
4
|
-
|
|
5
|
-
Named litellm_hub, not litellm: metrics/ is on sys.path, and a module called
|
|
6
|
-
litellm would stand in for the real package in anything that imports it.
|
|
7
|
-
|
|
8
|
-
LiteLLM serves each model as a deployment: a public `model_name` and the
|
|
9
|
-
`litellm_params` that reach the upstream (`groq/<id>`, an `api_base`, a key).
|
|
10
|
-
A model is added with POST /model/new, which works only in LiteLLM's database
|
|
11
|
-
mode (DATABASE_URL set, and STORE_MODEL_IN_DB=True or general_settings
|
|
12
|
-
store_model_in_db), and only with the master key or an admin's: the
|
|
13
|
-
endpoint's admin key (endpoints.admin_key, falling back to the endpoint's
|
|
14
|
-
own key, which is often the master key).
|
|
15
|
-
|
|
16
|
-
A CONNECTION is an upstream: the host of a deployment's `api_base` when it
|
|
17
|
-
sets one (an `openai/...` deployment pointed at any OpenAI-compatible API),
|
|
18
|
-
else its provider (`groq` for `groq/llama-...`). The new deployment copies
|
|
19
|
-
one sibling's connection-level params (COPIED), so it reaches the same
|
|
20
|
-
upstream the same way. LiteLLM never hands an `api_key` back, so a sibling's
|
|
21
|
-
key travels only as a stored credential (`litellm_credential_name`); a
|
|
22
|
-
sibling without one is copied without a key, and LiteLLM then uses the
|
|
23
|
-
provider's environment variable (GROQ_API_KEY), as that sibling does unless it
|
|
24
|
-
carries a key of its own. Onboarding's probe says which.
|
|
25
|
-
ponytail: one connection per upstream; two accounts on one provider count as
|
|
26
|
-
one, and the sibling with a credential is the one copied.
|
|
27
|
-
|
|
28
|
-
Checked against LiteLLM 1.106.1's source (2026-10-21):
|
|
29
|
-
proxy/management_endpoints/model_management_endpoints.py (POST /model/new:
|
|
30
|
-
no database refuses with "No DB Connected", database mode off with
|
|
31
|
-
"STORE_MODEL_IN_DB", both before anything is written; the reply is the stored
|
|
32
|
-
row, which may carry the encrypted key, so nothing here prints it),
|
|
33
|
-
proxy/proxy_server.py (GET /model/info: `data`, each with `model_name`,
|
|
34
|
-
`litellm_params` without `api_key` and other secrets masked with '*',
|
|
35
|
-
`model_info.db_model`), proxy/health_endpoints (GET /health/readiness/details:
|
|
36
|
-
`db` and `litellm_version`; releases before it said both on /health/readiness).
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
from __future__ import annotations
|
|
40
|
-
|
|
41
|
-
import json
|
|
42
|
-
import pathlib
|
|
43
|
-
import sys
|
|
44
|
-
import time
|
|
45
|
-
import urllib.error
|
|
46
|
-
import urllib.parse
|
|
47
|
-
import urllib.request
|
|
48
|
-
|
|
49
|
-
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
50
|
-
sys.path.insert(0, str(ROOT / "metrics"))
|
|
51
|
-
import endpoints # noqa: E402
|
|
52
|
-
|
|
53
|
-
# What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
|
|
54
|
-
SUPPORTS = {"allow_list": "deployments, as one more",
|
|
55
|
-
"tags": "a label in model_info (LiteLLM's own tags drive routing, so not those)", "logo": "",
|
|
56
|
-
"access": "open to every key without a model list (access groups, keys and teams stay LiteLLM's)",
|
|
57
|
-
"link": "/ui/models-and-endpoints?model=<deployment id>"}
|
|
58
|
-
CHECKED = ("1.106",)
|
|
59
|
-
# A sibling's params that say where the upstream is and how to reach it, not
|
|
60
|
-
# which model it is or what it costs.
|
|
61
|
-
COPIED = ("api_base", "api_version", "custom_llm_provider", "litellm_credential_name", "organization",
|
|
62
|
-
"extra_headers", "timeout", "stream_timeout", "max_retries")
|
|
63
|
-
NO_DB = ("LiteLLM runs without a database, and adds a model only in its database mode: give it"
|
|
64
|
-
" DATABASE_URL (Postgres) and STORE_MODEL_IN_DB=True, restart it, then add again")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def endpoint() -> str:
|
|
68
|
-
"""The LiteLLM hub's endpoint name; ValueError when there is none."""
|
|
69
|
-
name = endpoints.find_hub("litellm")
|
|
70
|
-
if not name:
|
|
71
|
-
raise ValueError("no endpoint is a LiteLLM hub; add it on the Endpoints page as kind hub, LiteLLM")
|
|
72
|
-
return name
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _call(path: str, body: dict | None = None):
|
|
76
|
-
name = endpoint()
|
|
77
|
-
root = endpoints.base(name).removesuffix("/v1")
|
|
78
|
-
req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
|
|
79
|
-
method="GET" if body is None else "POST",
|
|
80
|
-
headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
|
|
81
|
-
"Content-Type": "application/json", "Accept": "application/json"})
|
|
82
|
-
with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
|
|
83
|
-
return json.loads(r.read() or b"null")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _said(exc: urllib.error.HTTPError) -> str:
|
|
87
|
-
"""LiteLLM's message from its {"error": {"message": ...}} reply, trimmed."""
|
|
88
|
-
text = exc.read().decode("utf-8", "replace")
|
|
89
|
-
try:
|
|
90
|
-
err = json.loads(text).get("error")
|
|
91
|
-
text = str(err.get("message") if isinstance(err, dict) else err or text)
|
|
92
|
-
except (ValueError, AttributeError):
|
|
93
|
-
pass
|
|
94
|
-
return text[:300]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _upstream(d: dict) -> tuple[str, str, str | None]:
|
|
98
|
-
"""A deployment's (connection, base URL or "", the provider prefix a new
|
|
99
|
-
model id takes, None when its model string is passed through as is)."""
|
|
100
|
-
p = d.get("litellm_params") or {}
|
|
101
|
-
model, base = str(p.get("model") or ""), str(p.get("api_base") or "").rstrip("/")
|
|
102
|
-
prefix = None if p.get("custom_llm_provider") or "/" not in model else model.split("/", 1)[0]
|
|
103
|
-
name = urllib.parse.urlsplit(base).hostname if base else p.get("custom_llm_provider") or prefix or "openai"
|
|
104
|
-
return name or base, base, prefix
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _deployments() -> list[dict]:
|
|
108
|
-
return (_call("/model/info") or {}).get("data") or []
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def upstreams() -> dict[str, tuple[str, str]]:
|
|
112
|
-
"""Connection -> (base URL or "", the prefix served ids take: none on LiteLLM).
|
|
113
|
-
What Discover matches a provider against (discover.hub_routes)."""
|
|
114
|
-
return {n: (b, "") for n, b, _p in map(_upstream, _deployments())}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _ready() -> dict:
|
|
118
|
-
"""The readiness details (any valid key), or the public probe on a release before them."""
|
|
119
|
-
try:
|
|
120
|
-
return _call("/health/readiness/details") or {}
|
|
121
|
-
except urllib.error.HTTPError as exc:
|
|
122
|
-
if exc.code != 404:
|
|
123
|
-
raise
|
|
124
|
-
return _call("/health/readiness") or {}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def plan(connection: str, model_id: str) -> dict:
|
|
128
|
-
"""What adding would send. Writes nothing; ValueError when it cannot be done here."""
|
|
129
|
-
ready = _ready()
|
|
130
|
-
release, db = str(ready.get("litellm_version") or ""), str(ready.get("db") or "")
|
|
131
|
-
if db == "Not connected":
|
|
132
|
-
raise ValueError(NO_DB)
|
|
133
|
-
if db and db != "connected":
|
|
134
|
-
raise ValueError(f"LiteLLM's database is {db}; adding a model needs it reachable")
|
|
135
|
-
deps = _deployments()
|
|
136
|
-
mine = [d for d in deps if _upstream(d)[0] == connection]
|
|
137
|
-
if not mine:
|
|
138
|
-
raise ValueError(f"no LiteLLM deployment is on a connection called {connection!r} (a provider such as"
|
|
139
|
-
" groq, or the host of an api_base); add one model there in LiteLLM first")
|
|
140
|
-
# One name, two upstreams: LiteLLM would spread the calls across both.
|
|
141
|
-
same = [d for d in deps if d.get("model_name") == model_id]
|
|
142
|
-
other = next((_upstream(d)[0] for d in same if _upstream(d)[0] != connection), None)
|
|
143
|
-
if other:
|
|
144
|
-
raise ValueError(f"LiteLLM already serves {model_id} from {other}; a second deployment under that"
|
|
145
|
-
" name would share its calls with it")
|
|
146
|
-
sib = next((d for d in mine if (d.get("litellm_params") or {}).get("litellm_credential_name")), mine[0])
|
|
147
|
-
p, (_n, base, prefix) = sib.get("litellm_params") or {}, _upstream(sib)
|
|
148
|
-
# Masked values ('*') are LiteLLM hiding a secret; sent back they would
|
|
149
|
-
# replace it. A dict holding one (extra_headers) is left out whole, and said.
|
|
150
|
-
given = [k for k in COPIED if p.get(k) not in (None, "", {})]
|
|
151
|
-
params = {k: p[k] for k in given if "**" not in json.dumps(p[k])}
|
|
152
|
-
params["model"] = f"{prefix}/{model_id}" if prefix else model_id
|
|
153
|
-
cred = params.get("litellm_credential_name")
|
|
154
|
-
return {"connection": connection, "base_url": base, "model_id": model_id, "served": model_id,
|
|
155
|
-
"deployment": "exists" if same else "create", "copy_from": sib.get("model_name"),
|
|
156
|
-
"litellm_params": params, "model_info": {"tags": [connection]},
|
|
157
|
-
"not_copied": [k for k in given if k not in params],
|
|
158
|
-
"key": f"the stored credential {cred}" if cred else "none copied (LiteLLM's environment variable for it)",
|
|
159
|
-
"database_mode": True if any((d.get("model_info") or {}).get("db_model") for d in deps)
|
|
160
|
-
else "not shown until the add, which writes nothing if it is off",
|
|
161
|
-
"version": release, "version_note": endpoints.version_note("litellm", release, CHECKED)}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def add(connection: str, model_id: str) -> tuple[str, list[str]]:
|
|
165
|
-
"""Do it. (served id, what was done)."""
|
|
166
|
-
p = plan(connection, model_id)
|
|
167
|
-
done = [p["version_note"]] if p["version_note"] else []
|
|
168
|
-
if p["deployment"] == "exists":
|
|
169
|
-
return p["served"], done + [f"LiteLLM already has a {model_id} deployment on {connection}; nothing added"]
|
|
170
|
-
body = {"model_name": p["served"], "litellm_params": p["litellm_params"], "model_info": p["model_info"]}
|
|
171
|
-
try:
|
|
172
|
-
made = _call("/model/new", body) or {}
|
|
173
|
-
except urllib.error.HTTPError as exc:
|
|
174
|
-
said = _said(exc)
|
|
175
|
-
if "STORE_MODEL_IN_DB" in said:
|
|
176
|
-
raise ValueError("LiteLLM has a database but keeps its models in config.yaml: set STORE_MODEL_IN_DB=True"
|
|
177
|
-
" (or general_settings: store_model_in_db: true), restart it, then add again;"
|
|
178
|
-
" nothing was added") from None
|
|
179
|
-
if "No DB Connected" in said:
|
|
180
|
-
raise ValueError(NO_DB) from None
|
|
181
|
-
if exc.code in (401, 403):
|
|
182
|
-
raise ValueError(f"LiteLLM refused the key for /model/new ({said}): it takes the master key or an"
|
|
183
|
-
" admin's; store it as this endpoint's admin key") from None
|
|
184
|
-
raise ValueError(f"LiteLLM refused /model/new: {exc.code} {said}") from None
|
|
185
|
-
done.append(f"created a {model_id} deployment on the {connection} connection (LiteLLM id"
|
|
186
|
-
f" {made.get('model_id', '?')}), reaching it as {p['copy_from']} does, key: {p['key']};"
|
|
187
|
-
f" labelled {connection}" + (f"; not copied, as LiteLLM masks them: {', '.join(p['not_copied'])}"
|
|
188
|
-
if p["not_copied"] else ""))
|
|
189
|
-
for _ in range(10): # LiteLLM reloads its router from the database
|
|
190
|
-
if p["served"] in endpoints.models(endpoint()):
|
|
191
|
-
break
|
|
192
|
-
time.sleep(2)
|
|
193
|
-
else:
|
|
194
|
-
done.append(f"LiteLLM does not list {p['served']} yet; onboarding will say if it never does")
|
|
195
|
-
return p["served"], done
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def main() -> None:
|
|
199
|
-
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
200
|
-
if len(sys.argv) != 3:
|
|
201
|
-
sys.exit(__doc__)
|
|
202
|
-
print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if __name__ == "__main__":
|
|
206
|
-
main()
|
|
1
|
+
"""The LiteLLM hub adapter: a model added as a new deployment on a connection LiteLLM already has.
|
|
2
|
+
|
|
3
|
+
python metrics/litellm_hub.py <connection> <model id> # what would change, changes nothing
|
|
4
|
+
|
|
5
|
+
Named litellm_hub, not litellm: metrics/ is on sys.path, and a module called
|
|
6
|
+
litellm would stand in for the real package in anything that imports it.
|
|
7
|
+
|
|
8
|
+
LiteLLM serves each model as a deployment: a public `model_name` and the
|
|
9
|
+
`litellm_params` that reach the upstream (`groq/<id>`, an `api_base`, a key).
|
|
10
|
+
A model is added with POST /model/new, which works only in LiteLLM's database
|
|
11
|
+
mode (DATABASE_URL set, and STORE_MODEL_IN_DB=True or general_settings
|
|
12
|
+
store_model_in_db), and only with the master key or an admin's: the
|
|
13
|
+
endpoint's admin key (endpoints.admin_key, falling back to the endpoint's
|
|
14
|
+
own key, which is often the master key).
|
|
15
|
+
|
|
16
|
+
A CONNECTION is an upstream: the host of a deployment's `api_base` when it
|
|
17
|
+
sets one (an `openai/...` deployment pointed at any OpenAI-compatible API),
|
|
18
|
+
else its provider (`groq` for `groq/llama-...`). The new deployment copies
|
|
19
|
+
one sibling's connection-level params (COPIED), so it reaches the same
|
|
20
|
+
upstream the same way. LiteLLM never hands an `api_key` back, so a sibling's
|
|
21
|
+
key travels only as a stored credential (`litellm_credential_name`); a
|
|
22
|
+
sibling without one is copied without a key, and LiteLLM then uses the
|
|
23
|
+
provider's environment variable (GROQ_API_KEY), as that sibling does unless it
|
|
24
|
+
carries a key of its own. Onboarding's probe says which.
|
|
25
|
+
ponytail: one connection per upstream; two accounts on one provider count as
|
|
26
|
+
one, and the sibling with a credential is the one copied.
|
|
27
|
+
|
|
28
|
+
Checked against LiteLLM 1.106.1's source (2026-10-21):
|
|
29
|
+
proxy/management_endpoints/model_management_endpoints.py (POST /model/new:
|
|
30
|
+
no database refuses with "No DB Connected", database mode off with
|
|
31
|
+
"STORE_MODEL_IN_DB", both before anything is written; the reply is the stored
|
|
32
|
+
row, which may carry the encrypted key, so nothing here prints it),
|
|
33
|
+
proxy/proxy_server.py (GET /model/info: `data`, each with `model_name`,
|
|
34
|
+
`litellm_params` without `api_key` and other secrets masked with '*',
|
|
35
|
+
`model_info.db_model`), proxy/health_endpoints (GET /health/readiness/details:
|
|
36
|
+
`db` and `litellm_version`; releases before it said both on /health/readiness).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import pathlib
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
47
|
+
import urllib.request
|
|
48
|
+
|
|
49
|
+
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
50
|
+
sys.path.insert(0, str(ROOT / "metrics"))
|
|
51
|
+
import endpoints # noqa: E402
|
|
52
|
+
|
|
53
|
+
# What this hub takes (RELEASE-PLAN section 2, "Adding a model to a hub"); "" where it has none.
|
|
54
|
+
SUPPORTS = {"allow_list": "deployments, as one more",
|
|
55
|
+
"tags": "a label in model_info (LiteLLM's own tags drive routing, so not those)", "logo": "",
|
|
56
|
+
"access": "open to every key without a model list (access groups, keys and teams stay LiteLLM's)",
|
|
57
|
+
"link": "/ui/models-and-endpoints?model=<deployment id>"}
|
|
58
|
+
CHECKED = ("1.106",)
|
|
59
|
+
# A sibling's params that say where the upstream is and how to reach it, not
|
|
60
|
+
# which model it is or what it costs.
|
|
61
|
+
COPIED = ("api_base", "api_version", "custom_llm_provider", "litellm_credential_name", "organization",
|
|
62
|
+
"extra_headers", "timeout", "stream_timeout", "max_retries")
|
|
63
|
+
NO_DB = ("LiteLLM runs without a database, and adds a model only in its database mode: give it"
|
|
64
|
+
" DATABASE_URL (Postgres) and STORE_MODEL_IN_DB=True, restart it, then add again")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def endpoint() -> str:
|
|
68
|
+
"""The LiteLLM hub's endpoint name; ValueError when there is none."""
|
|
69
|
+
name = endpoints.find_hub("litellm")
|
|
70
|
+
if not name:
|
|
71
|
+
raise ValueError("no endpoint is a LiteLLM hub; add it on the Endpoints page as kind hub, LiteLLM")
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _call(path: str, body: dict | None = None):
|
|
76
|
+
name = endpoint()
|
|
77
|
+
root = endpoints.base(name).removesuffix("/v1")
|
|
78
|
+
req = urllib.request.Request(root + path, data=None if body is None else json.dumps(body).encode(),
|
|
79
|
+
method="GET" if body is None else "POST",
|
|
80
|
+
headers={"Authorization": f"Bearer {endpoints.admin_key(name)}",
|
|
81
|
+
"Content-Type": "application/json", "Accept": "application/json"})
|
|
82
|
+
with urllib.request.urlopen(req, timeout=15 if body is None else 60) as r:
|
|
83
|
+
return json.loads(r.read() or b"null")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _said(exc: urllib.error.HTTPError) -> str:
|
|
87
|
+
"""LiteLLM's message from its {"error": {"message": ...}} reply, trimmed."""
|
|
88
|
+
text = exc.read().decode("utf-8", "replace")
|
|
89
|
+
try:
|
|
90
|
+
err = json.loads(text).get("error")
|
|
91
|
+
text = str(err.get("message") if isinstance(err, dict) else err or text)
|
|
92
|
+
except (ValueError, AttributeError):
|
|
93
|
+
pass
|
|
94
|
+
return text[:300]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _upstream(d: dict) -> tuple[str, str, str | None]:
|
|
98
|
+
"""A deployment's (connection, base URL or "", the provider prefix a new
|
|
99
|
+
model id takes, None when its model string is passed through as is)."""
|
|
100
|
+
p = d.get("litellm_params") or {}
|
|
101
|
+
model, base = str(p.get("model") or ""), str(p.get("api_base") or "").rstrip("/")
|
|
102
|
+
prefix = None if p.get("custom_llm_provider") or "/" not in model else model.split("/", 1)[0]
|
|
103
|
+
name = urllib.parse.urlsplit(base).hostname if base else p.get("custom_llm_provider") or prefix or "openai"
|
|
104
|
+
return name or base, base, prefix
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _deployments() -> list[dict]:
|
|
108
|
+
return (_call("/model/info") or {}).get("data") or []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def upstreams() -> dict[str, tuple[str, str]]:
|
|
112
|
+
"""Connection -> (base URL or "", the prefix served ids take: none on LiteLLM).
|
|
113
|
+
What Discover matches a provider against (discover.hub_routes)."""
|
|
114
|
+
return {n: (b, "") for n, b, _p in map(_upstream, _deployments())}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ready() -> dict:
|
|
118
|
+
"""The readiness details (any valid key), or the public probe on a release before them."""
|
|
119
|
+
try:
|
|
120
|
+
return _call("/health/readiness/details") or {}
|
|
121
|
+
except urllib.error.HTTPError as exc:
|
|
122
|
+
if exc.code != 404:
|
|
123
|
+
raise
|
|
124
|
+
return _call("/health/readiness") or {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def plan(connection: str, model_id: str) -> dict:
|
|
128
|
+
"""What adding would send. Writes nothing; ValueError when it cannot be done here."""
|
|
129
|
+
ready = _ready()
|
|
130
|
+
release, db = str(ready.get("litellm_version") or ""), str(ready.get("db") or "")
|
|
131
|
+
if db == "Not connected":
|
|
132
|
+
raise ValueError(NO_DB)
|
|
133
|
+
if db and db != "connected":
|
|
134
|
+
raise ValueError(f"LiteLLM's database is {db}; adding a model needs it reachable")
|
|
135
|
+
deps = _deployments()
|
|
136
|
+
mine = [d for d in deps if _upstream(d)[0] == connection]
|
|
137
|
+
if not mine:
|
|
138
|
+
raise ValueError(f"no LiteLLM deployment is on a connection called {connection!r} (a provider such as"
|
|
139
|
+
" groq, or the host of an api_base); add one model there in LiteLLM first")
|
|
140
|
+
# One name, two upstreams: LiteLLM would spread the calls across both.
|
|
141
|
+
same = [d for d in deps if d.get("model_name") == model_id]
|
|
142
|
+
other = next((_upstream(d)[0] for d in same if _upstream(d)[0] != connection), None)
|
|
143
|
+
if other:
|
|
144
|
+
raise ValueError(f"LiteLLM already serves {model_id} from {other}; a second deployment under that"
|
|
145
|
+
" name would share its calls with it")
|
|
146
|
+
sib = next((d for d in mine if (d.get("litellm_params") or {}).get("litellm_credential_name")), mine[0])
|
|
147
|
+
p, (_n, base, prefix) = sib.get("litellm_params") or {}, _upstream(sib)
|
|
148
|
+
# Masked values ('*') are LiteLLM hiding a secret; sent back they would
|
|
149
|
+
# replace it. A dict holding one (extra_headers) is left out whole, and said.
|
|
150
|
+
given = [k for k in COPIED if p.get(k) not in (None, "", {})]
|
|
151
|
+
params = {k: p[k] for k in given if "**" not in json.dumps(p[k])}
|
|
152
|
+
params["model"] = f"{prefix}/{model_id}" if prefix else model_id
|
|
153
|
+
cred = params.get("litellm_credential_name")
|
|
154
|
+
return {"connection": connection, "base_url": base, "model_id": model_id, "served": model_id,
|
|
155
|
+
"deployment": "exists" if same else "create", "copy_from": sib.get("model_name"),
|
|
156
|
+
"litellm_params": params, "model_info": {"tags": [connection]},
|
|
157
|
+
"not_copied": [k for k in given if k not in params],
|
|
158
|
+
"key": f"the stored credential {cred}" if cred else "none copied (LiteLLM's environment variable for it)",
|
|
159
|
+
"database_mode": True if any((d.get("model_info") or {}).get("db_model") for d in deps)
|
|
160
|
+
else "not shown until the add, which writes nothing if it is off",
|
|
161
|
+
"version": release, "version_note": endpoints.version_note("litellm", release, CHECKED)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def add(connection: str, model_id: str) -> tuple[str, list[str]]:
|
|
165
|
+
"""Do it. (served id, what was done)."""
|
|
166
|
+
p = plan(connection, model_id)
|
|
167
|
+
done = [p["version_note"]] if p["version_note"] else []
|
|
168
|
+
if p["deployment"] == "exists":
|
|
169
|
+
return p["served"], done + [f"LiteLLM already has a {model_id} deployment on {connection}; nothing added"]
|
|
170
|
+
body = {"model_name": p["served"], "litellm_params": p["litellm_params"], "model_info": p["model_info"]}
|
|
171
|
+
try:
|
|
172
|
+
made = _call("/model/new", body) or {}
|
|
173
|
+
except urllib.error.HTTPError as exc:
|
|
174
|
+
said = _said(exc)
|
|
175
|
+
if "STORE_MODEL_IN_DB" in said:
|
|
176
|
+
raise ValueError("LiteLLM has a database but keeps its models in config.yaml: set STORE_MODEL_IN_DB=True"
|
|
177
|
+
" (or general_settings: store_model_in_db: true), restart it, then add again;"
|
|
178
|
+
" nothing was added") from None
|
|
179
|
+
if "No DB Connected" in said:
|
|
180
|
+
raise ValueError(NO_DB) from None
|
|
181
|
+
if exc.code in (401, 403):
|
|
182
|
+
raise ValueError(f"LiteLLM refused the key for /model/new ({said}): it takes the master key or an"
|
|
183
|
+
" admin's; store it as this endpoint's admin key") from None
|
|
184
|
+
raise ValueError(f"LiteLLM refused /model/new: {exc.code} {said}") from None
|
|
185
|
+
done.append(f"created a {model_id} deployment on the {connection} connection (LiteLLM id"
|
|
186
|
+
f" {made.get('model_id', '?')}), reaching it as {p['copy_from']} does, key: {p['key']};"
|
|
187
|
+
f" labelled {connection}" + (f"; not copied, as LiteLLM masks them: {', '.join(p['not_copied'])}"
|
|
188
|
+
if p["not_copied"] else ""))
|
|
189
|
+
for _ in range(10): # LiteLLM reloads its router from the database
|
|
190
|
+
if p["served"] in endpoints.models(endpoint()):
|
|
191
|
+
break
|
|
192
|
+
time.sleep(2)
|
|
193
|
+
else:
|
|
194
|
+
done.append(f"LiteLLM does not list {p['served']} yet; onboarding will say if it never does")
|
|
195
|
+
return p["served"], done
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> None:
|
|
199
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
200
|
+
if len(sys.argv) != 3:
|
|
201
|
+
sys.exit(__doc__)
|
|
202
|
+
print(json.dumps(plan(sys.argv[1], sys.argv[2]), indent=2))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
package/metrics/model_roles.py
CHANGED
|
@@ -1,84 +1,84 @@
|
|
|
1
|
-
"""What a model is FOR, so the router does not seat a code-completion model to
|
|
2
|
-
review a design, a small instruct model to judge a large packet, or an image
|
|
3
|
-
model to enumerate call sites.
|
|
4
|
-
|
|
5
|
-
`config/model-roles.json` in the data folder gives each alias a ROLE under
|
|
6
|
-
`models` (an alias not listed is `general`) and lists `ad_hoc` aliases. Those
|
|
7
|
-
two are the install's own. The roles themselves and the task SHAPES that admit
|
|
8
|
-
them are the product's, so they are defined here and every install gets them;
|
|
9
|
-
until the split (RELEASE-PLAN Phase 1) all four lived in one personal file.
|
|
10
|
-
|
|
11
|
-
roles: general reasoning / do-it-all: eligible for every shape
|
|
12
|
-
code trained for code completion and edits; sharp on "find every
|
|
13
|
-
place X happens" and "is this function wrong", weak on
|
|
14
|
-
process, prose and design judgement
|
|
15
|
-
light small, fast instruct models (nano/3b/12b/30b-a3b class): short
|
|
16
|
-
bounded tasks (classify, extract, summarise, enumerate over a
|
|
17
|
-
small file); not review or design over a large packet
|
|
18
|
-
image image generation; never a reviewer
|
|
19
|
-
decision typed answers with probabilities through a Decisions API, not
|
|
20
|
-
chat; no shape admits them (config/decision-models.json)
|
|
21
|
-
|
|
22
|
-
shapes: today's task, `route.py --shape <name>`; general when none is given.
|
|
23
|
-
`quick` is a short, bounded task where a fast small model is right.
|
|
24
|
-
`vision` is a question about images: the gateway gives it to a
|
|
25
|
-
request that carries any, unless its collection sets a shape, and
|
|
26
|
-
whatever the shape fills explorer seats only with models the catalog
|
|
27
|
-
says take images (gateway.py). A role is what a model is for, not
|
|
28
|
-
what it can read, so vision admits the roles that answer questions.
|
|
29
|
-
|
|
30
|
-
ad_hoc: aliases NEVER chosen automatically: not by the router's seats or
|
|
31
|
-
reserve, not by a benchmark sweep or `--next` rotation, not by the
|
|
32
|
-
daily perf run. Orthogonal to role: a role says what a model is FOR,
|
|
33
|
-
this says what it COSTS. They answer only when named (`--with`,
|
|
34
|
-
`llm -m`, a benchmark's `--model`).
|
|
35
|
-
|
|
36
|
-
A role never lowers a score: rank is decided by evidence when seats are
|
|
37
|
-
picked; a role only decides which shapes a model is eligible for.
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
from __future__ import annotations
|
|
41
|
-
|
|
42
|
-
import locked
|
|
43
|
-
from paths import DATA # the install's data folder (metrics/paths.py)
|
|
44
|
-
|
|
45
|
-
FILE = DATA / "config" / "model-roles.json"
|
|
46
|
-
|
|
47
|
-
ROLES = {
|
|
48
|
-
"general": "reasoning / do-it-all — eligible for every task shape",
|
|
49
|
-
"code": "code completion and edits — eligible for review, code and enumeration;"
|
|
50
|
-
" weak on design, process and prose",
|
|
51
|
-
"light": "small, fast instruct model — eligible for quick and enumeration;"
|
|
52
|
-
" not for review or design over a large packet",
|
|
53
|
-
"image": "image generation — never a reviewer",
|
|
54
|
-
"decision": "typed choice / score / yes-no with probabilities, through a Decisions API"
|
|
55
|
-
" — no chat, never a seat; see config/decision-models.json",
|
|
56
|
-
}
|
|
57
|
-
SHAPES = {
|
|
58
|
-
"general": ["general"],
|
|
59
|
-
"design": ["general"],
|
|
60
|
-
"prose": ["general"],
|
|
61
|
-
"review": ["general", "code"],
|
|
62
|
-
"code": ["general", "code"],
|
|
63
|
-
"enumeration": ["general", "code", "light"],
|
|
64
|
-
"quick": ["general", "light"],
|
|
65
|
-
"vision": ["general", "light"],
|
|
66
|
-
"image": ["image"],
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def load() -> dict:
|
|
71
|
-
"""{roles, shapes, models, ad_hoc}; {} parts when the file is missing.
|
|
72
|
-
|
|
73
|
-
The data file's own `roles` and `shapes` win over the shipped ones, name by
|
|
74
|
-
name. Files from before the split hold all four keys, with wording their
|
|
75
|
-
owner may have tuned, and an upgrade must not silently replace it; a role
|
|
76
|
-
or shape the product adds later still arrives, because only the names a
|
|
77
|
-
file defines are overridden.
|
|
78
|
-
ponytail: a file can redefine a shipped shape but not remove one; add a
|
|
79
|
-
null-means-remove rule if anyone needs it."""
|
|
80
|
-
data = locked.read_json(FILE)
|
|
81
|
-
return {"roles": {**ROLES, **(data.get("roles") or {})},
|
|
82
|
-
"shapes": {**SHAPES, **(data.get("shapes") or {})},
|
|
83
|
-
"models": data.get("models") or {},
|
|
84
|
-
"ad_hoc": data.get("ad_hoc") or []}
|
|
1
|
+
"""What a model is FOR, so the router does not seat a code-completion model to
|
|
2
|
+
review a design, a small instruct model to judge a large packet, or an image
|
|
3
|
+
model to enumerate call sites.
|
|
4
|
+
|
|
5
|
+
`config/model-roles.json` in the data folder gives each alias a ROLE under
|
|
6
|
+
`models` (an alias not listed is `general`) and lists `ad_hoc` aliases. Those
|
|
7
|
+
two are the install's own. The roles themselves and the task SHAPES that admit
|
|
8
|
+
them are the product's, so they are defined here and every install gets them;
|
|
9
|
+
until the split (RELEASE-PLAN Phase 1) all four lived in one personal file.
|
|
10
|
+
|
|
11
|
+
roles: general reasoning / do-it-all: eligible for every shape
|
|
12
|
+
code trained for code completion and edits; sharp on "find every
|
|
13
|
+
place X happens" and "is this function wrong", weak on
|
|
14
|
+
process, prose and design judgement
|
|
15
|
+
light small, fast instruct models (nano/3b/12b/30b-a3b class): short
|
|
16
|
+
bounded tasks (classify, extract, summarise, enumerate over a
|
|
17
|
+
small file); not review or design over a large packet
|
|
18
|
+
image image generation; never a reviewer
|
|
19
|
+
decision typed answers with probabilities through a Decisions API, not
|
|
20
|
+
chat; no shape admits them (config/decision-models.json)
|
|
21
|
+
|
|
22
|
+
shapes: today's task, `route.py --shape <name>`; general when none is given.
|
|
23
|
+
`quick` is a short, bounded task where a fast small model is right.
|
|
24
|
+
`vision` is a question about images: the gateway gives it to a
|
|
25
|
+
request that carries any, unless its collection sets a shape, and
|
|
26
|
+
whatever the shape fills explorer seats only with models the catalog
|
|
27
|
+
says take images (gateway.py). A role is what a model is for, not
|
|
28
|
+
what it can read, so vision admits the roles that answer questions.
|
|
29
|
+
|
|
30
|
+
ad_hoc: aliases NEVER chosen automatically: not by the router's seats or
|
|
31
|
+
reserve, not by a benchmark sweep or `--next` rotation, not by the
|
|
32
|
+
daily perf run. Orthogonal to role: a role says what a model is FOR,
|
|
33
|
+
this says what it COSTS. They answer only when named (`--with`,
|
|
34
|
+
`llm -m`, a benchmark's `--model`).
|
|
35
|
+
|
|
36
|
+
A role never lowers a score: rank is decided by evidence when seats are
|
|
37
|
+
picked; a role only decides which shapes a model is eligible for.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import locked
|
|
43
|
+
from paths import DATA # the install's data folder (metrics/paths.py)
|
|
44
|
+
|
|
45
|
+
FILE = DATA / "config" / "model-roles.json"
|
|
46
|
+
|
|
47
|
+
ROLES = {
|
|
48
|
+
"general": "reasoning / do-it-all — eligible for every task shape",
|
|
49
|
+
"code": "code completion and edits — eligible for review, code and enumeration;"
|
|
50
|
+
" weak on design, process and prose",
|
|
51
|
+
"light": "small, fast instruct model — eligible for quick and enumeration;"
|
|
52
|
+
" not for review or design over a large packet",
|
|
53
|
+
"image": "image generation — never a reviewer",
|
|
54
|
+
"decision": "typed choice / score / yes-no with probabilities, through a Decisions API"
|
|
55
|
+
" — no chat, never a seat; see config/decision-models.json",
|
|
56
|
+
}
|
|
57
|
+
SHAPES = {
|
|
58
|
+
"general": ["general"],
|
|
59
|
+
"design": ["general"],
|
|
60
|
+
"prose": ["general"],
|
|
61
|
+
"review": ["general", "code"],
|
|
62
|
+
"code": ["general", "code"],
|
|
63
|
+
"enumeration": ["general", "code", "light"],
|
|
64
|
+
"quick": ["general", "light"],
|
|
65
|
+
"vision": ["general", "light"],
|
|
66
|
+
"image": ["image"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load() -> dict:
|
|
71
|
+
"""{roles, shapes, models, ad_hoc}; {} parts when the file is missing.
|
|
72
|
+
|
|
73
|
+
The data file's own `roles` and `shapes` win over the shipped ones, name by
|
|
74
|
+
name. Files from before the split hold all four keys, with wording their
|
|
75
|
+
owner may have tuned, and an upgrade must not silently replace it; a role
|
|
76
|
+
or shape the product adds later still arrives, because only the names a
|
|
77
|
+
file defines are overridden.
|
|
78
|
+
ponytail: a file can redefine a shipped shape but not remove one; add a
|
|
79
|
+
null-means-remove rule if anyone needs it."""
|
|
80
|
+
data = locked.read_json(FILE)
|
|
81
|
+
return {"roles": {**ROLES, **(data.get("roles") or {})},
|
|
82
|
+
"shapes": {**SHAPES, **(data.get("shapes") or {})},
|
|
83
|
+
"models": data.get("models") or {},
|
|
84
|
+
"ad_hoc": data.get("ad_hoc") or []}
|